"""Script execution live state in Redis (2h TTL); MySQL writes are batched only (no per-event flush)."""

from __future__ import annotations

import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional

from server.domain.models import ScriptExecution
from server.domain.repositories import ScriptExecutionRepository
from server.infrastructure.redis.client import get_redis

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

REDIS_KEY_PREFIX = "script_exec:"
REDIS_FLUSH_QUEUE = "script_exec:flush_queue"
REDIS_TTL_SECONDS = 7200  # 2 hours — live execution state max lifetime
FLUSH_INTERVAL_SECONDS = 60  # background batch flush Redis → MySQL
FLUSH_BATCH_LIMIT = 50  # max execution records written per flush cycle
STUCK_NO_PROGRESS_SECONDS = 3600  # auto mark stuck if running with no update for 1h

TERMINAL_STATUSES = frozenset({"completed", "failed", "partial", "cancelled"})


def execution_progress_summary(
    results: dict[str, Any],
    server_ids: Optional[list] = None,
) -> str:
    """Return 'done/total' for UI (pending servers count as not done)."""
    ids = server_ids
    if ids is None:
        ids = [int(k) for k in results if k != "_meta" and str(k).isdigit()]
    total = len(ids)
    if not total:
        return "—"
    done = sum(
        1
        for sid in ids
        if (results.get(str(sid)) or {}).get("phase") != "pending"
    )
    return f"{done}/{total}"


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _parse_server_ids_json(raw: Optional[str]) -> list:
    try:
        parsed = json.loads(raw or "[]")
    except json.JSONDecodeError:
        return []
    return parsed if isinstance(parsed, list) else []


def _redis_key(execution_id: int) -> str:
    return f"{REDIS_KEY_PREFIX}{execution_id}"


def unpack_results_blob(raw: Optional[str]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    if not raw:
        return {}, []
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return {}, []
    if not isinstance(data, dict):
        return {}, []
    meta = data.pop("_meta", None) or {}
    events = meta.get("events", []) if isinstance(meta, dict) else []
    return data, events if isinstance(events, list) else []


def pack_results_blob(results: dict[str, Any], events: list[dict[str, Any]]) -> str:
    payload = dict(results)
    payload["_meta"] = {
        "events": events,
        "updated_at": _now_iso(),
    }
    return json.dumps(payload, ensure_ascii=False)


async def load_live(execution_id: int) -> Optional[dict[str, Any]]:
    redis = get_redis()
    raw = await redis.get(_redis_key(execution_id))
    if not raw:
        return None
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return None


async def save_live(live: dict[str, Any]) -> None:
    redis = get_redis()
    eid = int(live["id"])
    live["updated_at"] = _now_iso()
    await redis.set(_redis_key(eid), json.dumps(live, ensure_ascii=False), ex=REDIS_TTL_SECONDS)
    await redis.sadd(REDIS_FLUSH_QUEUE, str(eid))


async def init_live_from_execution(
    execution: ScriptExecution,
    *,
    long_task: bool = False,
    results: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    server_ids = _parse_server_ids_json(execution.server_ids)

    live = {
        "id": execution.id,
        "script_id": execution.script_id,
        "command": execution.command,
        "server_ids": server_ids,
        "status": execution.status or "running",
        "results": results or {},
        "events": [
            {
                "at": _now_iso(),
                "action": "started",
                "detail": f"开始执行 {len(server_ids)} 台服务器"
                + (" (长任务)" if long_task else ""),
                "operator": execution.operator,
            }
        ],
        "operator": execution.operator,
        "long_task": long_task,
        "started_at": str(execution.started_at) if execution.started_at else _now_iso(),
        "completed_at": None,
    }
    if getattr(execution, "credential_id", None) is not None:
        live["credential_id"] = execution.credential_id
    await save_live(live)
    return live


async def append_event(
    live: dict[str, Any],
    action: str,
    detail: str,
    operator: Optional[str] = None,
) -> None:
    live.setdefault("events", []).append(
        {
            "at": _now_iso(),
            "action": action,
            "detail": detail,
            "operator": operator or live.get("operator"),
        }
    )


async def flush_to_mysql(
    execution_repo: ScriptExecutionRepository,
    live: dict[str, Any],
) -> Optional[ScriptExecution]:
    """Persist current live snapshot to MySQL (results + event timeline in _meta)."""
    eid = int(live["id"])
    status = live.get("status", "running")
    results = live.get("results") or {}
    events = live.get("events") or []
    blob = pack_results_blob(results, events)

    execution = await execution_repo.update_status(eid, status, blob)
    if not execution:
        logger.warning("Flush skipped: script execution %s not found in MySQL", eid)
        redis = get_redis()
        await redis.srem(REDIS_FLUSH_QUEUE, str(eid))
        return None

    if status in TERMINAL_STATUSES and execution:
        execution.completed_at = datetime.now(timezone.utc)
        # update_status already sets completed_at when not running — good

    redis = get_redis()
    await redis.srem(REDIS_FLUSH_QUEUE, str(eid))
    logger.debug("Flushed script execution %s to MySQL (status=%s)", eid, status)
    return execution


async def apply_update(
    execution_repo: ScriptExecutionRepository,
    execution_id: int,
    *,
    status: str,
    results: dict[str, Any],
    event_action: str,
    event_detail: str,
    operator: str,
    terminal: bool = False,
    long_task: Optional[bool] = None,
) -> dict[str, Any]:
    """Update Redis only; MySQL is flushed in batches by script_execution_flush (60s)."""
    live = await load_live(execution_id)
    if not live:
        execution = await execution_repo.get_by_id(execution_id)
        if not execution:
            raise ValueError(f"Execution {execution_id} not found")
        res, ev = unpack_results_blob(execution.results)
        live = {
            "id": execution_id,
            "script_id": execution.script_id,
            "command": execution.command,
            "server_ids": _parse_server_ids_json(execution.server_ids),
            "status": execution.status,
            "results": res,
            "events": ev,
            "operator": execution.operator,
            "long_task": (execution.command or "").startswith("[long_task] "),
            "started_at": str(execution.started_at) if execution.started_at else _now_iso(),
            "completed_at": str(execution.completed_at) if execution.completed_at else None,
        }

    live["status"] = status
    live["results"] = results
    if long_task is not None:
        live["long_task"] = long_task
    if terminal or status in TERMINAL_STATUSES:
        live["completed_at"] = _now_iso()

    await append_event(live, event_action, event_detail, operator)
    await save_live(live)
    return live


async def get_execution_view(
    execution_repo: ScriptExecutionRepository,
    execution_id: int,
) -> Optional[dict[str, Any]]:
    """Prefer Redis live view; fall back to MySQL."""
    live = await load_live(execution_id)
    if live:
        results = live.get("results") or {}
        return {
            "id": live["id"],
            "script_id": live.get("script_id"),
            "command": live.get("command"),
            "server_ids": json.dumps(live.get("server_ids", [])),
            "status": live.get("status"),
            "results": results,
            "events": live.get("events") or [],
            "operator": live.get("operator"),
            "started_at": live.get("started_at"),
            "completed_at": live.get("completed_at"),
            "long_task": live.get("long_task", False),
            "progress": execution_progress_summary(results, live.get("server_ids")),
            "credential_id": live.get("credential_id"),
            "source": "redis",
        }

    execution = await execution_repo.get_by_id(execution_id)
    if not execution:
        return None
    results, events = unpack_results_blob(execution.results)
    server_ids = _parse_server_ids_json(execution.server_ids)
    return {
        "id": execution.id,
        "script_id": execution.script_id,
        "command": execution.command,
        "server_ids": execution.server_ids,
        "status": execution.status,
        "results": results,
        "events": events,
        "operator": execution.operator,
        "started_at": str(execution.started_at) if execution.started_at else None,
        "completed_at": str(execution.completed_at) if execution.completed_at else None,
        "long_task": (execution.command or "").startswith("[long_task] "),
        "progress": execution_progress_summary(results, server_ids),
        "credential_id": getattr(execution, "credential_id", None),
        "source": "mysql",
    }


async def patch_live_server_result(
    execution_id: int,
    server_id: int,
    entry: dict[str, Any],
) -> None:
    """Merge one server result into Redis live state (for stop while short-task running)."""
    live = await load_live(execution_id)
    if not live:
        return
    merged = dict(entry)
    if merged.get("phase") == "pending" and "started_at" not in merged:
        merged["started_at"] = _now_iso()
    results = dict(live.get("results") or {})
    results[str(server_id)] = {**(results.get(str(server_id)) or {}), **merged}
    live["results"] = results
    await save_live(live)


def _parse_iso(ts: str) -> Optional[datetime]:
    try:
        return datetime.fromisoformat(ts)
    except (ValueError, TypeError):
        return None


def _pending_stuck_server_ids(results: dict[str, Any], now: datetime) -> list[str]:
    stuck: list[str] = []
    for sid, entry in results.items():
        if sid == "_meta" or not isinstance(entry, dict):
            continue
        if entry.get("phase") != "pending" or entry.get("auto_stuck"):
            continue
        started = _parse_iso(entry.get("started_at", ""))
        if not started:
            continue
        if (now - started).total_seconds() >= STUCK_NO_PROGRESS_SECONDS:
            stuck.append(str(sid))
    return stuck


async def detect_stuck_executions(
    execution_repo: ScriptExecutionRepository,
    audit_fn,
) -> int:
    """Auto-mark executions with no Redis update for STUCK_NO_PROGRESS_SECONDS."""
    redis = get_redis()
    marked = 0
    now = datetime.now(timezone.utc)
    cursor = 0
    while True:
        cursor, keys = await redis.scan(cursor, match=f"{REDIS_KEY_PREFIX}*", count=50)
        for key in keys:
            try:
                eid = int(key.replace(REDIS_KEY_PREFIX, ""))
            except ValueError:
                continue
            live = await load_live(eid)
            if not live:
                continue
            if live.get("status") not in ("running", "partial"):
                continue
            results = dict(live.get("results") or {})
            events = live.get("events") or []

            stuck_sids = _pending_stuck_server_ids(results, now)
            if stuck_sids:
                for sid in stuck_sids:
                    row = dict(results.get(sid) or {})
                    row["auto_stuck"] = True
                    row["stuck_at"] = _now_iso()
                    results[sid] = row
                await apply_update(
                    execution_repo,
                    eid,
                    status=live.get("status", "running"),
                    results=results,
                    event_action="server_auto_stuck",
                    event_detail=(
                        f"服务器 {', '.join('#' + s for s in stuck_sids)} "
                        f"pending 超过 {STUCK_NO_PROGRESS_SECONDS // 3600} 小时"
                    ),
                    operator="system",
                )
                await audit_fn(
                    "server_auto_stuck",
                    "script_execution",
                    eid,
                    live.get("operator", "system"),
                )
                marked += 1
                continue

            if live.get("status") != "running":
                continue
            if any(e.get("action") in ("stuck_marked", "auto_stuck") for e in events[-3:]):
                continue
            updated = _parse_iso(live.get("updated_at", ""))
            if not updated or (now - updated).total_seconds() < STUCK_NO_PROGRESS_SECONDS:
                continue
            await apply_update(
                execution_repo,
                eid,
                status="running",
                results=results,
                event_action="auto_stuck",
                event_detail=(
                    f"超过 {STUCK_NO_PROGRESS_SECONDS // 3600} 小时无状态更新，系统自动标记可能卡住"
                ),
                operator="system",
            )
            await audit_fn(
                "auto_stuck",
                "script_execution",
                eid,
                live.get("operator", "system"),
            )
            marked += 1
        if cursor == 0:
            break
    return marked


async def flush_pending_batch(execution_repo: ScriptExecutionRepository) -> int:
    """Batch-write queued execution snapshots from Redis to MySQL (cap per cycle)."""
    redis = get_redis()
    try:
        ids = await redis.smembers(REDIS_FLUSH_QUEUE)
    except Exception as e:
        logger.error("Redis smembers script_exec flush queue failed: %s", e)
        return 0

    id_list = list(ids or [])[:FLUSH_BATCH_LIMIT]
    flushed = 0
    for raw_id in id_list:
        try:
            eid = int(raw_id)
        except (TypeError, ValueError):
            continue
        live = await load_live(eid)
        if not live:
            await redis.srem(REDIS_FLUSH_QUEUE, raw_id)
            continue
        try:
            await flush_to_mysql(execution_repo, live)
            flushed += 1
        except Exception as e:
            logger.error("Flush script execution %s failed: %s", eid, e)
    return flushed
