"""Background server batch jobs (category, health check, Agent ops, path detect)."""

from __future__ import annotations

import asyncio
import logging
import shlex
from typing import Any, Optional

from server.application.server_batch_common import (
    agent_install_skip_result,
    batch_server_display_name,
    detect_and_save_target_path,
    ensure_agent_api_key,
    install_error_msg,
    mark_agent_uninstalled,
    prefetch_batch_context,
    result_item_dict,
    sudo_wrap,
)
from server.utils.agent_version import get_central_agent_version
from server.config import settings
from server.domain.models import AuditLog, Server
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis import server_batch_store as sbs
from server.infrastructure.redis.client import get_redis
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command

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

_BG_TASKS: set[asyncio.Task] = set()
CONCURRENCY = 5

OP_LABELS = {
    "category": "批量修改分类",
    "health-check": "健康检查",
    "detect-path": "自动检测目标路径",
    "onboard": "新服务器初始化",
    "install-agent": "批量安装 Agent",
    "upgrade-agent": "批量升级 Agent",
    "uninstall-agent": "批量卸载 Agent",
}

AUDIT_ACTIONS = {
    "category": "batch_update_category",
    "health-check": "batch_health_check",
    "detect-path": "batch_detect_path",
    "onboard": "batch_server_onboard",
    "install-agent": "batch_install_agent",
    "upgrade-agent": "batch_upgrade_agent",
    "uninstall-agent": "batch_uninstall_agent",
}

HEARTBEAT_PREFIX = "heartbeat:"


async def _notify_progress(live: dict[str, Any]) -> None:
    try:
        from server.api.websocket import broadcast_script_progress

        stats = sbs.progress_stats(live)
        await broadcast_script_progress({
            "execution_id": int(live["id"]),
            "task_kind": "server_batch",
            "op": live.get("op") or "",
            "label": live.get("label") or "",
            "status": live.get("status") or "running",
            "long_task": False,
            "operator": live.get("operator") or "",
            **stats,
        })
    except Exception as e:
        logger.warning("server batch progress notify failed: %s", e)


async def _notify_complete(live: dict[str, Any]) -> None:
    try:
        from server.api.websocket import broadcast_script_complete

        stats = sbs.progress_stats(live)
        await broadcast_script_complete({
            "execution_id": int(live["id"]),
            "task_kind": "server_batch",
            "op": live.get("op") or "",
            "label": live.get("label") or "",
            "status": live.get("status") or "completed",
            "long_task": False,
            "operator": live.get("operator") or "",
            **stats,
        })
    except Exception as e:
        logger.warning("server batch complete notify failed: %s", e)


async def _reconcile_live_job(
    live: dict[str, Any],
    *,
    error: str,
    auto_stuck: bool,
) -> bool:
    """Fill pending servers as failed, finalize, persist, audit, notify. Returns True if reconciled."""
    if live.get("status") != "running":
        return False
    pending = sbs.pending_server_ids(live)
    if not pending and sbs.progress_stats(live)["remaining"] == 0:
        sbs.finalize_status(live)
        await sbs.save_live(live)
    else:
        added = sbs.apply_pending_failures(
            live,
            server_ids=pending,
            error=error,
            auto_stuck=auto_stuck,
        )
        if added == 0 and not pending:
            return False
        sbs.finalize_status(live)
        await sbs.save_live(live)
    await _persist_job_finalize(live)
    await _write_audit(live)
    await _notify_complete(live)
    job_id = int(live["id"])
    logger.info(
        "Server batch job reconciled job_id=%s op=%s status=%s auto_stuck=%s",
        job_id,
        live.get("op"),
        live.get("status"),
        auto_stuck,
    )
    return True


async def recover_orphaned_batch_jobs() -> int:
    """On API startup: finalize batch jobs left running after process restart."""
    from server.infrastructure.database.server_batch_job_repo import (
        ServerBatchJobRepositoryImpl,
    )

    reconciled_ids: set[int] = set()
    count = 0

    for live in await sbs.list_running_live_jobs():
        job_id = int(live["id"])
        if job_id in reconciled_ids:
            continue
        if await _reconcile_live_job(
            live,
            error=sbs.INTERRUPTED_MESSAGE,
            auto_stuck=False,
        ):
            reconciled_ids.add(job_id)
            count += 1

    async with AsyncSessionLocal() as session:
        repo = ServerBatchJobRepositoryImpl(session)
        for row in await repo.list_running():
            job_id = int(row.id)
            if job_id in reconciled_ids:
                continue
            live = await sbs.get_live(job_id)
            if live is None:
                live = sbs.live_from_mysql_row(row)
            elif live.get("status") != "running":
                continue
            if await _reconcile_live_job(
                live,
                error=sbs.INTERRUPTED_MESSAGE,
                auto_stuck=False,
            ):
                reconciled_ids.add(job_id)
                count += 1

    if count:
        logger.info("Recovered %s orphaned server batch job(s) after startup", count)
    return count


async def detect_stuck_batch_jobs() -> int:
    """Mark running batch jobs with no progress for STUCK_NO_PROGRESS_SECONDS."""
    count = 0
    for live in await sbs.list_running_live_jobs():
        if not sbs.is_stale_running(live):
            continue
        if await _reconcile_live_job(
            live,
            error=sbs.STUCK_MESSAGE,
            auto_stuck=True,
        ):
            count += 1
    if count:
        logger.info("Auto-reconciled %s stuck server batch job(s)", count)
    return count


async def start_batch_job(
    *,
    op: str,
    server_ids: list[int],
    operator: str,
    params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    if op not in OP_LABELS:
        raise ValueError(f"未知的批量操作: {op}")
    if not server_ids:
        raise ValueError("server_ids 不能为空")

    job_id = await sbs.next_job_id()
    label = OP_LABELS[op]
    if op == "category" and params and params.get("category"):
        label = f"{label} → {params['category']}"

    live = sbs.init_live(
        job_id=job_id,
        op=op,
        label=label,
        server_ids=server_ids,
        operator=operator,
        params=params,
    )
    await sbs.save_live(live)
    await _persist_job_start(live)
    await _notify_progress(live)

    task = asyncio.create_task(_run_background(job_id))
    _BG_TASKS.add(task)
    task.add_done_callback(_BG_TASKS.discard)

    stats = sbs.progress_stats(live)
    return {
        "job_id": job_id,
        "status": "running",
        "label": label,
        "op": op,
        **stats,
    }


async def get_batch_job(job_id: int) -> Optional[dict[str, Any]]:
    live = await sbs.get_live(job_id)
    if live:
        return sbs.live_to_api(live)
    return await _get_batch_job_from_mysql(job_id)


async def list_batch_jobs(
    *,
    limit: int = 50,
    offset: int = 0,
    op: Optional[str] = None,
) -> dict[str, Any]:
    from server.infrastructure.database.server_batch_job_repo import (
        ServerBatchJobRepositoryImpl,
        job_row_to_api,
    )

    async with AsyncSessionLocal() as session:
        repo = ServerBatchJobRepositoryImpl(session)
        rows, total = await repo.list_recent(limit=limit, offset=offset, op=op)
        items = [job_row_to_api(row) for row in rows]
    return {"items": items, "total": total, "limit": limit, "offset": offset}


async def _persist_job_start(live: dict[str, Any]) -> None:
    from server.infrastructure.database.server_batch_job_repo import ServerBatchJobRepositoryImpl

    try:
        async with AsyncSessionLocal() as session:
            repo = ServerBatchJobRepositoryImpl(session)
            await repo.upsert_running(
                job_id=int(live["id"]),
                op=str(live.get("op") or ""),
                label=str(live.get("label") or ""),
                server_ids=list(live.get("server_ids") or []),
                operator=str(live.get("operator") or ""),
                params=live.get("params") if isinstance(live.get("params"), dict) else {},
            )
    except Exception as e:
        logger.warning("Server batch MySQL create failed job_id=%s: %s", live.get("id"), e)


async def _persist_job_finalize(live: dict[str, Any]) -> None:
    from server.infrastructure.database.server_batch_job_repo import ServerBatchJobRepositoryImpl

    try:
        api = sbs.live_to_api(live)
        async with AsyncSessionLocal() as session:
            repo = ServerBatchJobRepositoryImpl(session)
            await repo.finalize(
                int(live["id"]),
                status=str(live.get("status") or "completed"),
                results=list(api.get("results") or []),
            )
    except Exception as e:
        logger.warning("Server batch MySQL finalize failed job_id=%s: %s", live.get("id"), e)


async def _get_batch_job_from_mysql(job_id: int) -> Optional[dict[str, Any]]:
    from server.infrastructure.database.server_batch_job_repo import (
        ServerBatchJobRepositoryImpl,
        job_row_to_api,
    )

    try:
        async with AsyncSessionLocal() as session:
            repo = ServerBatchJobRepositoryImpl(session)
            row = await repo.get_by_id(job_id)
            if not row:
                return None
            return job_row_to_api(row)
    except Exception as e:
        logger.warning("Server batch MySQL read failed job_id=%s: %s", job_id, e)
        return None


async def _run_background(job_id: int) -> None:
    live = await sbs.get_live(job_id)
    if not live:
        return
    op = live.get("op") or ""
    try:
        if op == "category":
            await _run_category(live)
        elif op == "health-check":
            await _run_health_check(live)
        elif op == "detect-path":
            await _run_detect_path(live)
        elif op == "onboard":
            await _run_onboard(live)
        elif op == "install-agent":
            await _run_install_agent(live)
        elif op == "upgrade-agent":
            await _run_upgrade_agent(live)
        elif op == "uninstall-agent":
            await _run_uninstall_agent(live)
        else:
            live["status"] = "failed"
            await sbs.save_live(live)
            return
        live = await sbs.get_live(job_id)
        if live:
            stats = sbs.progress_stats(live)
            if live.get("status") == "running" or stats["remaining"] == 0:
                sbs.finalize_status(live)
                await sbs.save_live(live)
    except Exception:
        logger.exception("Server batch job failed job_id=%s op=%s", job_id, op)
        live = await sbs.get_live(job_id)
        if live:
            live["status"] = "failed"
            await sbs.save_live(live)
    live = await sbs.get_live(job_id)
    if live:
        if live.get("status") == "running":
            sbs.finalize_status(live)
            await sbs.save_live(live)
        await _persist_job_finalize(live)
        await _write_audit(live)
        await _notify_complete(live)


async def _write_audit(live: dict[str, Any]) -> None:
    import json

    op = live.get("op") or ""
    action = AUDIT_ACTIONS.get(op, "batch_server_op")
    stats = sbs.progress_stats(live)
    api = sbs.live_to_api(live)
    failures = [
        {"server_name": (e.get("server_name") or "").strip() or "未知服务器", "error": (e.get("error") or "")[:300]}
        for e in (api.get("results") or [])
        if not e.get("success")
    ]
    summary_map = {
        "category": f"批量修改分类 更新{stats['success']}台",
        "health-check": f"批量健康检查: {stats['success']}/{stats['total']} 在线",
        "detect-path": f"批量检测路径: {stats['success']}/{stats['total']} 成功",
        "onboard": f"新服务器初始化: {stats['success']}/{stats['total']} 成功",
        "install-agent": f"批量安装Agent: {stats['success']}/{stats['total']} 成功",
        "upgrade-agent": f"批量升级Agent: {stats['success']}/{stats['total']} 成功",
        "uninstall-agent": f"批量卸载Agent: {stats['success']}/{stats['total']} 成功",
    }
    summary = summary_map.get(op, f"{live.get('label')} {stats['success']}/{stats['total']}")
    detail = json.dumps({
        "job_id": int(live["id"]),
        "summary": summary,
        "success": stats["success"],
        "failed": stats["failed"],
        "total": stats["total"],
        "failures": failures[:30],
    }, ensure_ascii=False)
    try:
        async with AsyncSessionLocal() as session:
            audit_repo = AuditLogRepositoryImpl(session)
            await audit_repo.create(AuditLog(
                admin_username=live.get("operator") or "admin",
                action=action,
                target_type="server_batch_job",
                target_id=int(live["id"]),
                detail=detail,
                ip_address="",
            ))
            await session.commit()
    except Exception as e:
        logger.warning("Server batch audit failed job_id=%s: %s", live.get("id"), e)


async def _finish_one(live: dict[str, Any], item: dict[str, Any]) -> None:
    sbs.record_result(live, item)
    await sbs.save_live(live)
    await _notify_progress(live)


async def _run_with_semaphore(live: dict[str, Any], handler) -> None:
    from server.application.services.server_service import ServerService
    from server.infrastructure.database.server_repo import ServerRepositoryImpl
    from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl

    server_ids = live.get("server_ids") or []
    sem = asyncio.Semaphore(CONCURRENCY)
    async with AsyncSessionLocal() as session:
        service = ServerService(
            server_repo=ServerRepositoryImpl(session),
            sync_log_repo=SyncLogRepositoryImpl(session),
        )
        server_map, labels = await prefetch_batch_context(service, server_ids)

        async def _one(sid: int) -> None:
            async with sem:
                item = await handler(sid, server_map, labels, live)
                await _finish_one(live, item)

        await asyncio.gather(*[_one(sid) for sid in server_ids])


async def _run_category(live: dict[str, Any]) -> None:
    params = live.get("params") or {}
    category = (params.get("category") or "").strip() or None
    server_ids = live.get("server_ids") or []

    async with AsyncSessionLocal() as session:
        repo = ServerRepositoryImpl(session)
        servers = await repo.get_by_ids(server_ids)
        found_ids = {s.id for s in servers}
        labels = {s.id: batch_server_display_name(s) for s in servers}
        for sid in server_ids:
            labels.setdefault(sid, "未知服务器")

        for sid in server_ids:
            if sid not in found_ids:
                await _finish_one(live, result_item_dict(
                    server_id=sid,
                    server_name=labels[sid],
                    success=False,
                    error="服务器不存在",
                ))

        if found_ids:
            await repo.update_category_batch(list(found_ids), category)
            await session.commit()
            cat_label = category or "(清除)"
            for sid in found_ids:
                await _finish_one(live, result_item_dict(
                    server_id=sid,
                    server_name=labels[sid],
                    success=True,
                    stdout=f"分类 → {cat_label}",
                ))


async def _run_health_check(live: dict[str, Any]) -> None:
    from server.infrastructure.ssh.remote_probe import ssh_health_probe

    async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
        server = server_map.get(sid)
        label = labels[sid]
        if not server:
            return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
        try:
            probe = await ssh_health_probe(server, timeout=15)
            online = probe.get("status") == "online"
            detail = probe.get("error") or probe.get("channel") or probe.get("status") or ""
            return result_item_dict(
                server_id=sid,
                server_name=label,
                success=online,
                stdout=str(detail)[:500] if online else "",
                error="" if online else str(probe.get("error") or "离线")[:300],
            )
        except Exception as e:
            return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])

    await _run_with_semaphore(live, handler)


async def _run_detect_path(live: dict[str, Any]) -> None:
    async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
        server = server_map.get(sid)
        label = labels[sid]
        if not server:
            return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
        try:
            outcome = await detect_and_save_target_path(server)
            if outcome.get("success"):
                return result_item_dict(
                    server_id=sid,
                    server_name=label,
                    success=True,
                    stdout=str(outcome.get("stdout") or ""),
                )
            return result_item_dict(
                server_id=sid,
                server_name=label,
                success=False,
                error=str(outcome.get("error") or "探测失败"),
            )
        except Exception as e:
            return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])

    await _run_with_semaphore(live, handler)


async def _run_onboard(live: dict[str, Any]) -> None:
    from server.application.services.files_sudoers_service import install_nexus_files_sudoers
    from server.utils.posix_paths import is_unset_target_path

    async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
        server = server_map.get(sid)
        label = labels[sid]
        if not server:
            return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
        steps: list[str] = []
        try:
            ssh_user = (server.username or "root").strip() or "root"
            if ssh_user == "root":
                steps.append("sudo: skip(root)")
            else:
                sudo_outcome = await install_nexus_files_sudoers(server)
                action = sudo_outcome.get("action") or "unknown"
                if not sudo_outcome.get("ok"):
                    detail = sudo_outcome.get("detail") or action
                    return result_item_dict(
                        server_id=sid,
                        server_name=label,
                        success=False,
                        error=f"sudo 配置失败: {detail}",
                        stdout="; ".join(steps),
                    )
                steps.append(f"sudo: {action}")

            if is_unset_target_path(server.target_path):
                detect_outcome = await detect_and_save_target_path(server)
                if detect_outcome.get("success"):
                    steps.append(str(detect_outcome.get("stdout") or "path: ok"))
                else:
                    steps.append(f"path: {detect_outcome.get('error') or 'fail'}（推送仍可用 /www/wwwroot）")
            else:
                steps.append("path: already_set")

            return result_item_dict(
                server_id=sid,
                server_name=label,
                success=True,
                stdout="; ".join(steps),
            )
        except Exception as e:
            return result_item_dict(
                server_id=sid,
                server_name=label,
                success=False,
                error=str(e)[:300],
                stdout="; ".join(steps),
            )

    await _run_with_semaphore(live, handler)


async def _run_install_agent(live: dict[str, Any]) -> None:
    base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
    if not base_url:
        for sid in live.get("server_ids") or []:
            await _finish_one(live, result_item_dict(
                server_id=sid, server_name=str(sid), success=False,
                error="NEXUS_API_BASE_URL 未配置",
            ))
        return

    central_version = get_central_agent_version()

    async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
        server = server_map.get(sid)
        label = labels[sid]
        if not server:
            return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
        try:
            skip = await agent_install_skip_result(
                server=server,
                server_id=sid,
                server_name=label,
                central_version=central_version,
            )
            if skip is not None:
                return skip
            api_key = await ensure_agent_api_key(server)
            ssh_user = (server.username or "root").strip()
            install_cmd = (
                f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
                f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
                f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
                f"; RC=$?; rm -f \"$TMP\"; exit $RC"
            )
            install_cmd = sudo_wrap(install_cmd, ssh_user)
            r = await exec_ssh_command(server, install_cmd, timeout=180)
            ok = r["exit_code"] == 0
            if ok and "Status: FAILED" in r.get("stdout", ""):
                ok = False
            return result_item_dict(
                server_id=sid, server_name=label, success=ok,
                stdout=r["stdout"][:2000] if ok else r["stdout"][:1000],
                error="" if ok else install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
            )
        except Exception as e:
            return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])

    await _run_with_semaphore(live, handler)


async def _run_upgrade_agent(live: dict[str, Any]) -> None:
    base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
    if not base_url:
        for sid in live.get("server_ids") or []:
            await _finish_one(live, result_item_dict(
                server_id=sid, server_name=str(sid), success=False,
                error="NEXUS_API_BASE_URL 未配置",
            ))
        return

    async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
        server = server_map.get(sid)
        label = labels[sid]
        if not server:
            return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
        try:
            from server.utils.agent_deploy import (
                AGENT_INSTALL_DIR,
                build_agent_upgrade_cmd,
                build_agent_upgrade_rollback_cmd,
            )

            install_dir = AGENT_INSTALL_DIR
            ssh_user = (server.username or "root").strip()
            agent_port = server.agent_port or 8601
            upgrade_cmd = build_agent_upgrade_cmd(
                base_url=base_url,
                ssh_user=ssh_user,
                install_dir=install_dir,
                agent_port=agent_port,
            )
            r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
            ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
            if not ok:
                try:
                    rollback = build_agent_upgrade_rollback_cmd(
                        ssh_user=ssh_user,
                        install_dir=install_dir,
                    )
                    await exec_ssh_command(server, rollback, timeout=30)
                except Exception as rb_err:
                    logger.warning("Rollback failed for server %s: %s", sid, rb_err)
            return result_item_dict(
                server_id=sid, server_name=label, success=ok,
                stdout=r["stdout"][:500] if ok else "",
                error=install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]) if not ok else "",
            )
        except Exception as e:
            return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:200])

    await _run_with_semaphore(live, handler)


async def _run_uninstall_agent(live: dict[str, Any]) -> None:
    base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
    if not base_url:
        for sid in live.get("server_ids") or []:
            await _finish_one(live, result_item_dict(
                server_id=sid, server_name=str(sid), success=False,
                error="NEXUS_API_BASE_URL 未配置",
            ))
        return

    async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
        server = server_map.get(sid)
        label = labels[sid]
        if not server:
            return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
        try:
            ssh_user = (server.username or "root").strip()
            uninstall_cmd = (
                f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
                f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
            )
            uninstall_cmd = sudo_wrap(uninstall_cmd, ssh_user)
            r = await exec_ssh_command(server, uninstall_cmd, timeout=60)
            ok = r["exit_code"] == 0
            if ok:
                try:
                    redis = get_redis()
                    await redis.delete(f"{HEARTBEAT_PREFIX}{sid}")
                except Exception as redis_err:
                    logger.warning("Failed to clear Redis heartbeat for server %s: %s", sid, redis_err)
                await mark_agent_uninstalled(sid)
            return result_item_dict(
                server_id=sid, server_name=label, success=ok,
                stdout=r["stdout"][:500] if ok else "",
                error="" if ok else install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
            )
        except Exception as e:
            return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])

    await _run_with_semaphore(live, handler)
