"""Nexus — Script Service (Business logic for script library + remote execution)
Application layer — orchestrates Repository + SSH exec + DB credential substitution.
"""

import asyncio
import json
import logging
import re
import shlex
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any

from server.config import settings
from server.domain.models import Server, Script, ScriptExecution, DbCredential, AuditLog
from server.infrastructure.redis import script_execution_store as ses
from server.domain.repositories import (
    ScriptRepository, ScriptExecutionRepository,
    DbCredentialRepository, ServerRepository, AuditLogRepository,
)

logger = logging.getLogger("nexus.script_service")

_NEXUS_JOB_LOG_RE = re.compile(r"log=(/var/log/nexus-job/[^\s\"']+)")
_NEXUS_JOB_PID_RE = re.compile(r"pid=(\d+)")


class ScriptService:
    """Business logic for script CRUD and remote command execution via SSH (port 22)"""

    def __init__(
        self,
        script_repo: ScriptRepository,
        execution_repo: ScriptExecutionRepository,
        credential_repo: DbCredentialRepository,
        server_repo: ServerRepository,
        audit_repo: AuditLogRepository,
    ):
        self.script_repo = script_repo
        self.execution_repo = execution_repo
        self.credential_repo = credential_repo
        self.server_repo = server_repo
        self.audit_repo = audit_repo

    async def _agent_api_key_for_server(self, server_id: int) -> str:
        server = await self.server_repo.get_by_id(server_id)
        if server and server.agent_api_key:
            return server.agent_api_key
        if not settings.API_KEY:
            raise ValueError("API_KEY 未配置，无法注册长任务回调")
        return settings.API_KEY

    # ── Script CRUD ──

    async def list_scripts(self, category: Optional[str] = None) -> List[Script]:
        if category:
            return await self.script_repo.get_by_category(category)
        return await self.script_repo.get_all()

    async def get_script(self, id: int) -> Optional[Script]:
        return await self.script_repo.get_by_id(id)

    async def create_script(self, script: Script) -> Script:
        created = await self.script_repo.create(script)
        await self._audit("create_script", "script", created.id, f"名称={script.name}")
        return created

    async def update_script(self, script: Script) -> Script:
        updated = await self.script_repo.update(script)
        await self._audit("update_script", "script", updated.id, f"名称={script.name}")
        return updated

    async def delete_script(self, id: int) -> bool:
        result = await self.script_repo.delete(id)
        if result:
            await self._audit("delete_script", "script", id, f"脚本ID={id}")
        return result

    # ── Command Execution (SSH) ──

    async def execute_command(
        self,
        command: str,
        server_ids: List[int],
        script_id: Optional[int] = None,
        credential_id: Optional[int] = None,
        timeout: int = 30,
        operator: str = "admin",
        long_task: bool = False,
    ) -> ScriptExecution:
        """Execute a shell command on multiple servers via SSH.

        If credential_id is provided, $DB_* variables are substituted on Nexus
        before the command is sent over SSH.
        """
        # Resolve DB credential variables (if provided)
        resolved_command = await self._substitute_db_vars(command, credential_id)

        from server.application.services.script_jobs import (
            build_long_task_command,
            master_callback_url,
            register_script_job,
        )

        callback_url = None
        if long_task:
            callback_url = master_callback_url()

        # Create execution record — use sanitized command (password redacted)
        sanitized_command = await self._substitute_db_vars(command, credential_id, redact=True)
        if long_task:
            sanitized_command = f"[long_task] {sanitized_command}"
        execution = ScriptExecution(
            script_id=script_id,
            command=sanitized_command,
            server_ids=json.dumps(server_ids),
            credential_id=credential_id,
            status="running",
            operator=operator,
        )
        execution = await self.execution_repo.create(execution)
        await ses.init_live_from_execution(execution, long_task=long_task)
        await self._audit(
            "execute_started", "script_execution", execution.id,
            f"开始执行 {len(server_ids)} 台"
            + (" [long_task]" if long_task else ""),
            operator=operator,
        )

        batch_size = max(1, settings.SCRIPT_EXEC_BATCH_SIZE)
        results: Dict[str, dict] = {}
        batches = [
            server_ids[i : i + batch_size]
            for i in range(0, len(server_ids), batch_size)
        ]
        for batch_idx, batch in enumerate(batches, start=1):
            if len(batches) > 1:
                logger.info(
                    "Script exec batch %s/%s (%s servers, execution_id=%s)",
                    batch_idx,
                    len(batches),
                    len(batch),
                    execution.id,
                )
            per_server_commands: Optional[Dict[int, str]] = None
            job_ids_by_server: Dict[int, str] = {}
            if long_task:
                per_server_commands = {}
                for sid in batch:
                    job_id, secret = await register_script_job(execution.id, sid)
                    job_ids_by_server[sid] = job_id
                    agent_key = await self._agent_api_key_for_server(sid)
                    per_server_commands[sid] = build_long_task_command(
                        resolved_command, job_id, sid, secret, callback_url, agent_key
                    )
            batch_results = await self._dispatch_to_servers(
                batch,
                resolved_command,
                timeout,
                execution_id=execution.id,
                per_server_commands=per_server_commands,
                long_task=long_task,
                job_ids_by_server=job_ids_by_server or None,
            )
            results.update(batch_results)
            await ses.apply_update(
                self.execution_repo,
                execution.id,
                status="running",
                results=results,
                event_action="batch_progress",
                event_detail=f"批次 {batch_idx}/{len(batches)} 完成",
                operator=operator,
                terminal=False,
                long_task=long_task,
            )

        # Determine overall status
        if long_task:
            start_failed = sum(
                1 for r in results.values() if r.get("exit_code", -1) != 0
            )
            status = (
                "failed"
                if start_failed == len(server_ids)
                else "running"
            )
            failed_count = start_failed
        else:
            failed_count = sum(1 for r in results.values() if r.get("exit_code", -1) != 0)
            status = (
                "completed"
                if failed_count == 0
                else "failed"
                if failed_count == len(server_ids)
                else "partial"
            )

        terminal = status in ses.TERMINAL_STATUSES
        await ses.apply_update(
            self.execution_repo,
            execution.id,
            status=status,
            results=results,
            event_action="execution_finished",
            event_detail=f"执行结束: {status} ({failed_count} 台失败)",
            operator=operator,
            terminal=terminal,
            long_task=long_task,
        )

        await self._audit(
            "execute_command", "script_execution", execution.id,
            f"执行于 {len(server_ids)} 台: {status} ({failed_count} 台失败)",
            operator=operator,
        )
        return await self.execution_repo.get_by_id(execution.id)

    async def get_execution(self, id: int) -> Optional[ScriptExecution]:
        return await self.execution_repo.get_by_id(id)

    async def list_executions(
        self,
        limit: int = 50,
        offset: int = 0,
        status: Optional[str] = None,
    ) -> dict:
        """List execution history (MySQL list + Redis overlay when live)."""
        rows, total = await self.execution_repo.list_recent(limit, offset, status)
        items = []
        for ex in rows:
            view = await ses.get_execution_view(self.execution_repo, ex.id)
            if view:
                items.append(view)
            else:
                results, events = ses.unpack_results_blob(ex.results)
                try:
                    sids = json.loads(ex.server_ids or "[]")
                except json.JSONDecodeError:
                    sids = []
                items.append({
                    "id": ex.id,
                    "script_id": ex.script_id,
                    "command": ex.command,
                    "server_ids": ex.server_ids,
                    "status": ex.status,
                    "results": results,
                    "events": events,
                    "operator": ex.operator,
                    "started_at": str(ex.started_at) if ex.started_at else None,
                    "completed_at": str(ex.completed_at) if ex.completed_at else None,
                    "progress": ses.execution_progress_summary(results, sids),
                    "source": "mysql",
                })
        return {"items": items, "total": total, "limit": limit, "offset": offset}

    async def get_execution_detail(
        self, id: int, fetch_logs: bool = False, log_tail_lines: int = 100,
    ) -> Optional[dict]:
        """Return execution dict (Redis first); optionally tail nexus-job logs."""
        view = await ses.get_execution_view(self.execution_repo, id)
        if not view:
            return None
        results: Dict[str, Any] = dict(view.get("results") or {})

        if fetch_logs:
            await self._attach_remote_logs(results, log_tail_lines)
            live = await ses.load_live(id)
            if live:
                live["results"] = results
                await ses.save_live(live)

        return {
            "id": view["id"],
            "script_id": view.get("script_id"),
            "command": view.get("command"),
            "server_ids": view.get("server_ids"),
            "status": view.get("status"),
            "results": results,
            "events": view.get("events") or [],
            "operator": view.get("operator"),
            "started_at": view.get("started_at"),
            "completed_at": view.get("completed_at"),
            "long_task": view.get("long_task", False),
            "progress": view.get("progress"),
            "source": view.get("source"),
        }

    @staticmethod
    def _parse_execution_meta(command: str) -> tuple[str, bool]:
        """Strip [long_task] prefix from stored command."""
        if command.startswith("[long_task] "):
            return command[len("[long_task] "):], True
        return command, False

    @staticmethod
    def _extract_nexus_job_pid(stdout: str) -> Optional[int]:
        m = _NEXUS_JOB_PID_RE.search(stdout or "")
        if not m:
            return None
        try:
            return int(m.group(1))
        except ValueError:
            return None

    @staticmethod
    def _build_kill_command(pid: Optional[int], log_path: Optional[str]) -> str:
        parts = []
        if pid:
            parts.append(f"kill -TERM {pid} 2>/dev/null || kill -KILL {pid} 2>/dev/null")
        if log_path:
            # log_path is validated to /var/log/nexus-job/[^\s"']+ (no spaces/quotes).
            # Use single-quote the path directly — do NOT wrap shlex.quote() inside
            # double-quotes, which would embed the single-quote chars literally and
            # cause the cat to target a non-existent filename like "'path'.pid".
            q = shlex.quote(log_path)  # produces '/var/log/nexus-job/...'
            parts.append(
                f'P=$(cat {q}.pid 2>/dev/null); '
                f'[ -n "$P" ] && (kill -TERM "$P" 2>/dev/null || kill -KILL "$P" 2>/dev/null)'
            )
        if not parts:
            return "echo 'cannot stop: pid unknown'; exit 1"
        return " ; ".join(parts) + '; echo "stopped"'

    async def stop_execution(self, execution_id: int, operator: str = "admin") -> Optional[ScriptExecution]:
        """Stop pending long-task processes on child hosts."""
        from server.application.services.script_jobs import revoke_script_job

        view = await ses.get_execution_view(self.execution_repo, execution_id)
        if not view:
            return None
        results: Dict[str, Any] = dict(view.get("results") or {})

        stopped = 0
        for sid_str, entry in list(results.items()):
            if not isinstance(entry, dict) or entry.get("phase") != "pending":
                continue
            try:
                server_id = int(sid_str)
            except ValueError:
                continue
            server = await self.server_repo.get_by_id(server_id)
            if not server or not server.is_online:
                entry["phase"] = "cancelled"
                entry["status"] = "cancelled"
                entry["exit_code"] = -1
                entry["message"] = "服务器离线，标记为已停止"
                stopped += 1
                continue

            job_id = entry.get("job_id")
            if job_id:
                await revoke_script_job(job_id)

            pid = entry.get("pid") or self._extract_nexus_job_pid(entry.get("stdout", ""))
            log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
            try:
                kill_cmd = self._build_kill_command(
                    int(pid) if pid else None,
                    log_path,
                )
                out = await self._call_ssh_exec(server, kill_cmd, timeout=15)
                entry["stop_stdout"] = out.get("stdout", "") or out.get("status", "")
                entry["stop_stderr"] = out.get("stderr", "")
            except Exception as e:
                entry["stop_stderr"] = str(e)

            entry["phase"] = "cancelled"
            entry["status"] = "cancelled"
            entry["exit_code"] = -1
            entry["message"] = "用户停止"
            entry["completed_at"] = datetime.now(timezone.utc).isoformat()
            stopped += 1

        try:
            server_ids = json.loads(view["server_ids"]) if isinstance(view["server_ids"], str) else view.get("server_ids", [])
        except json.JSONDecodeError:
            server_ids = []

        status = _aggregate_from_results(server_ids, results)
        terminal = status in ses.TERMINAL_STATUSES

        await ses.apply_update(
            self.execution_repo,
            execution_id,
            status=status,
            results=results,
            event_action="stopped",
            event_detail=f"用户停止，共停止 {stopped} 台 pending 任务",
            operator=operator,
            terminal=terminal,
        )
        await self._audit(
            "stop_execution", "script_execution", execution_id,
            f"用户停止，共停止 {stopped} 台 pending 任务",
            operator=operator,
        )
        return await self.execution_repo.get_by_id(execution_id)

    async def mark_execution_stuck(
        self,
        execution_id: int,
        operator: str = "admin",
        detail: str = "",
    ) -> Optional[ScriptExecution]:
        """Record suspected stuck state — persisted to Redis + MySQL + audit."""
        view = await ses.get_execution_view(self.execution_repo, execution_id)
        if not view:
            return None
        results = dict(view.get("results") or {})
        msg = detail.strip() or "用户标记执行可能卡住"
        await ses.apply_update(
            self.execution_repo,
            execution_id,
            status=view.get("status") or "running",
            results=results,
            event_action="stuck_marked",
            event_detail=msg,
            operator=operator,
            terminal=False,
        )
        await self._audit(
            "mark_stuck", "script_execution", execution_id, msg, operator=operator,
        )
        return await self.execution_repo.get_by_id(execution_id)

    async def retry_execution(
        self,
        execution_id: int,
        server_ids: Optional[List[int]] = None,
        credential_id: Optional[int] = None,
        timeout: int = 120,
        long_task: Optional[bool] = None,
        operator: str = "admin",
    ) -> ScriptExecution:
        """Re-run command for failed / pending / cancelled servers."""
        execution = await self.execution_repo.get_by_id(execution_id)
        if not execution:
            raise ValueError("Execution not found")

        command, was_long = self._parse_execution_meta(execution.command or "")
        if not command.strip():
            raise ValueError("原执行命令为空，无法重试")

        try:
            all_ids = json.loads(execution.server_ids or "[]")
        except json.JSONDecodeError:
            all_ids = []

        if server_ids is None:
            view = await ses.get_execution_view(self.execution_repo, execution_id)
            results = dict((view or {}).get("results") or {})
            server_ids = []
            for sid in all_ids:
                r = results.get(str(sid), {})
                phase = r.get("phase", "done")
                if phase == "pending" or r.get("exit_code", -1) != 0:
                    server_ids.append(sid)
            if not server_ids:
                server_ids = list(all_ids)

        if not server_ids:
            raise ValueError("没有可重试的服务器")

        retry_results, _ = ses.unpack_results_blob(execution.results)
        await ses.apply_update(
            self.execution_repo,
            execution_id,
            status=execution.status,
            results=retry_results,
            event_action="retry_requested",
            event_detail=f"重试 {len(server_ids)} 台: {server_ids}",
            operator=operator,
            terminal=False,
        )
        await self._audit(
            "retry_execution", "script_execution", execution_id,
            f"重试服务器 {server_ids}",
            operator=operator,
        )

        use_long = was_long if long_task is None else long_task
        cred_id = credential_id if credential_id is not None else getattr(
            execution, "credential_id", None
        )
        return await self.execute_command(
            command=command,
            server_ids=server_ids,
            script_id=execution.script_id,
            credential_id=cred_id,
            timeout=timeout,
            operator=operator,
            long_task=use_long,
        )

    @staticmethod
    def _extract_nexus_job_log(stdout: str) -> Optional[str]:
        m = _NEXUS_JOB_LOG_RE.search(stdout or "")
        if not m:
            return None
        path = m.group(1)
        if not path.startswith("/var/log/nexus-job/"):
            return None
        return path

    async def _attach_remote_logs(self, results: Dict[str, Any], tail_lines: int) -> None:
        """In-place: add remote_log for servers with a nexus-job log path."""
        tail_lines = max(10, min(tail_lines, 500))

        async def _tail_one(sid_str: str, entry: dict) -> None:
            log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
            if not log_path:
                return
            try:
                server_id = int(sid_str)
            except ValueError:
                return
            server = await self.server_repo.get_by_id(server_id)
            if not server or not server.is_online:
                entry["remote_log"] = "(服务器离线，无法拉取日志)"
                return
            cmd = f"tail -n {tail_lines} {shlex.quote(log_path)} 2>&1"
            try:
                out = await self._call_ssh_exec(server, cmd, timeout=30)
                parts = []
                if out.get("stdout"):
                    parts.append(out["stdout"])
                if out.get("stderr"):
                    parts.append(out["stderr"])
                entry["remote_log"] = "\n".join(parts).strip() or "(日志为空)"
                entry["remote_log_fetched_at"] = datetime.now(timezone.utc).isoformat()
            except Exception as e:
                entry["remote_log"] = f"(拉取失败: {e})"

        await asyncio.gather(
            *[_tail_one(sid, entry) for sid, entry in results.items() if isinstance(entry, dict)]
        )

    async def _dispatch_to_servers(
        self,
        server_ids: List[int],
        resolved_command: str,
        timeout: int,
        execution_id: Optional[int] = None,
        per_server_commands: Optional[Dict[int, str]] = None,
        long_task: bool = False,
        job_ids_by_server: Optional[Dict[int, str]] = None,
    ) -> Dict[str, dict]:
        """Run command on server_ids with concurrency limit (one batch wave)."""
        results: Dict[str, dict] = {}
        semaphore = asyncio.Semaphore(settings.SCRIPT_EXEC_CONCURRENCY)

        async def _exec_on_server(server_id: int):
            server = await self.server_repo.get_by_id(server_id)
            if not server or not server.is_online:
                results[str(server_id)] = {
                    "status": "skipped",
                    "stdout": "",
                    "stderr": f"Server offline or not found (ID={server_id})",
                    "exit_code": -1,
                }
                return
            cmd = (
                per_server_commands.get(server_id, resolved_command)
                if per_server_commands
                else resolved_command
            )
            async with semaphore:
                try:
                    exec_timeout = min(timeout, 30) if long_task else timeout
                    result = await self._call_ssh_exec(server, cmd, timeout=exec_timeout)
                    if long_task and result.get("exit_code", -1) == 0:
                        pid = result.get("pid") or self._extract_nexus_job_pid(
                            result.get("stdout", ""),
                        )
                        if pid:
                            result["pid"] = int(pid)
                        result = {
                            **result,
                            "phase": "pending",
                            "background": True,
                            "job_id": (job_ids_by_server or {}).get(server_id, ""),
                            "status": "started",
                            "started_at": datetime.now(timezone.utc).isoformat(),
                            "channel": "ssh",
                        }
                    results[str(server_id)] = result
                except Exception as e:
                    results[str(server_id)] = {
                        "status": "error",
                        "stdout": "",
                        "stderr": str(e),
                        "exit_code": -1,
                    }

        await asyncio.gather(*[_exec_on_server(sid) for sid in server_ids])
        return results

    # ── DB Credential Management ──

    async def list_credentials(self) -> List[DbCredential]:
        return await self.credential_repo.get_all()

    async def create_credential(self, credential: DbCredential) -> DbCredential:
        from server.infrastructure.database.crypto import encrypt_value
        credential.encrypted_password = encrypt_value(credential.encrypted_password)
        created = await self.credential_repo.create(credential)
        await self._audit("create_credential", "db_credential", created.id, f"名称={credential.name}")
        return created

    async def delete_credential(self, id: int) -> bool:
        result = await self.credential_repo.delete(id)
        if result:
            await self._audit("delete_credential", "db_credential", id, f"凭据ID={id}")
        return result

    # ── Private helpers ──

    async def _substitute_db_vars(self, command: str, credential_id: Optional[int], redact: bool = False) -> str:
        """Replace $DB_USER/$DB_PASS/$DB_HOST/$DB_PORT/$DB_NAME in command.

        If redact=True, replaces $DB_PASS with '***' instead of the actual password.
        The redacted version is stored in ScriptExecution.command (DB record).
        The actual substitution (redact=False) is sent over SSH for execution.
        """
        if not credential_id:
            return command

        credential = await self.credential_repo.get_by_id(credential_id)
        if not credential:
            return command

        from server.infrastructure.database.crypto import decrypt_value
        password = decrypt_value(credential.encrypted_password)

        # Use unique sentinels to avoid nested substitution if a credential value
        # itself contains another $DB_* placeholder (e.g. password = "$DB_USER").
        sentinels = {
            "$DB_USER": "\x00DB_USER\x00",
            "$DB_PASS": "\x00DB_PASS\x00",
            "$DB_HOST": "\x00DB_HOST\x00",
            "$DB_PORT": "\x00DB_PORT\x00",
            "$DB_NAME": "\x00DB_NAME\x00",
        }
        values = {
            "\x00DB_USER\x00": credential.username or "",
            "\x00DB_PASS\x00": "***" if redact else password,
            "\x00DB_HOST\x00": credential.host or "",
            "\x00DB_PORT\x00": str(credential.port or ""),
            "\x00DB_NAME\x00": credential.database or "",
        }
        for placeholder, sentinel in sentinels.items():
            command = command.replace(placeholder, sentinel)
        for sentinel, value in values.items():
            command = command.replace(sentinel, value)

        return command

    async def _call_ssh_exec(
        self,
        server: Server,
        command: str,
        timeout: int = 30,
    ) -> dict:
        from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command

        data = await exec_ssh_command(server, command, timeout=timeout)
        out = {
            "status": data.get("status", "unknown"),
            "stdout": data.get("stdout", ""),
            "stderr": data.get("stderr", ""),
            "exit_code": data.get("exit_code", -1),
            "channel": "ssh",
        }
        pid = self._extract_nexus_job_pid(out.get("stdout", ""))
        if pid:
            out["pid"] = int(pid)
        return out

    async def _audit(
        self,
        action: str,
        target_type: str,
        target_id: int,
        detail: str,
        operator: str = "system",
    ):
        """Write audit log entry"""
        log = AuditLog(
            action=action,
            target_type=target_type,
            target_id=target_id,
            detail=detail,
            admin_username=operator,
        )
        await self.audit_repo.create(log)


def _aggregate_from_results(server_ids: List[int], results: Dict[str, Any]) -> str:
    pending = failed = 0
    for sid in server_ids:
        r = results.get(str(sid), {})
        if r.get("phase") == "pending":
            pending += 1
            continue
        if r.get("exit_code", -1) != 0:
            failed += 1
    if pending:
        return "running"
    if failed == 0:
        return "completed"
    if failed == len(server_ids):
        return "failed"
    return "partial"