"""Purge assíncrono com polling + persistência SQLite (Spec 017 Fase 2b/3)."""

from __future__ import annotations

import json
import sqlite3
import threading
import time
import traceback
import uuid
from datetime import datetime, timezone
from typing import Any, Callable

from app import auth, vm112_domains

_lock = threading.Lock()
_schema_ready = False


class PurgeJobConflictError(Exception):
    """Já existe purge queued/running para o domínio."""


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


def init_purge_jobs_schema(conn) -> None:
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS vm112_purge_jobs (
            id TEXT PRIMARY KEY,
            domain TEXT NOT NULL,
            status TEXT NOT NULL,
            timeline_json TEXT NOT NULL DEFAULT '[]',
            elapsed_vm112 INTEGER NOT NULL DEFAULT 0,
            desk_json TEXT NOT NULL DEFAULT '{}',
            vm112_json TEXT NOT NULL DEFAULT '{}',
            error TEXT,
            by_user TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        )
        """
    )
    conn.execute(
        """
        CREATE INDEX IF NOT EXISTS idx_vm112_purge_jobs_domain_status
        ON vm112_purge_jobs(domain, status)
        """
    )
    conn.commit()


def _ensure_schema() -> None:
    global _schema_ready
    if _schema_ready:
        return
    conn = auth.db()
    try:
        init_purge_jobs_schema(conn)
        _schema_ready = True
    finally:
        conn.close()


def _sqlite_retry(fn: Callable[[], Any], *, attempts: int = 8) -> Any:
    last_err: Exception | None = None
    for attempt in range(attempts):
        try:
            return fn()
        except sqlite3.OperationalError as exc:
            last_err = exc
            if "locked" not in str(exc).lower() or attempt >= attempts - 1:
                raise
            time.sleep(min(0.05 * (2**attempt), 1.0))
    if last_err:
        raise last_err
    return None


def _row_to_job(row) -> dict[str, Any]:
    return {
        "id": row["id"],
        "job_id": row["id"],
        "domain": row["domain"],
        "status": row["status"],
        "timeline": json.loads(row["timeline_json"] or "[]"),
        "elapsed_vm112": int(row["elapsed_vm112"] or 0),
        "desk": json.loads(row["desk_json"] or "{}"),
        "vm112": json.loads(row["vm112_json"] or "{}"),
        "error": row["error"],
        "by": row["by_user"],
        "created_at": row["created_at"],
        "updated_at": row["updated_at"],
    }


def _persist_job(job: dict[str, Any]) -> None:
    _ensure_schema()

    def _write() -> None:
        conn = auth.db()
        try:
            job["updated_at"] = _now()
            conn.execute(
                """
                INSERT INTO vm112_purge_jobs (
                    id, domain, status, timeline_json, elapsed_vm112,
                    desk_json, vm112_json, error, by_user, created_at, updated_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(id) DO UPDATE SET
                    status = excluded.status,
                    timeline_json = excluded.timeline_json,
                    elapsed_vm112 = excluded.elapsed_vm112,
                    desk_json = excluded.desk_json,
                    vm112_json = excluded.vm112_json,
                    error = excluded.error,
                    by_user = excluded.by_user,
                    updated_at = excluded.updated_at
                """,
                (
                    job["id"],
                    job["domain"],
                    job["status"],
                    json.dumps(job.get("timeline") or [], ensure_ascii=False),
                    int(job.get("elapsed_vm112") or 0),
                    json.dumps(job.get("desk") or {}, ensure_ascii=False),
                    json.dumps(job.get("vm112") or {}, ensure_ascii=False),
                    job.get("error"),
                    job.get("by"),
                    job.get("created_at") or _now(),
                    job["updated_at"],
                ),
            )
            conn.commit()
        finally:
            conn.close()

    _sqlite_retry(_write)


def _load_job(job_id: str) -> dict[str, Any] | None:
    _ensure_schema()

    def _read() -> dict[str, Any] | None:
        conn = auth.db()
        try:
            row = conn.execute(
                "SELECT * FROM vm112_purge_jobs WHERE id = ?", (job_id,)
            ).fetchone()
            return _row_to_job(row) if row else None
        finally:
            conn.close()

    return _sqlite_retry(_read)


def _find_active_job_locked(conn: sqlite3.Connection, domain: str) -> sqlite3.Row | None:
    return conn.execute(
        """
        SELECT id, domain, status, created_at FROM vm112_purge_jobs
        WHERE domain = ? AND status IN ('queued', 'running')
        ORDER BY created_at DESC LIMIT 1
        """,
        (domain.lower().strip(),),
    ).fetchone()


def _mutate_job(job_id: str, fn: Callable[[dict[str, Any]], None]) -> dict[str, Any] | None:
    with _lock:
        def _mutate() -> dict[str, Any] | None:
            conn = auth.db()
            try:
                row = conn.execute(
                    "SELECT * FROM vm112_purge_jobs WHERE id = ?", (job_id,)
                ).fetchone()
                if not row:
                    return None
                job = _row_to_job(row)
                fn(job)
                job["updated_at"] = _now()
                conn.execute(
                    """
                    INSERT INTO vm112_purge_jobs (
                        id, domain, status, timeline_json, elapsed_vm112,
                        desk_json, vm112_json, error, by_user, created_at, updated_at
                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    ON CONFLICT(id) DO UPDATE SET
                        status = excluded.status,
                        timeline_json = excluded.timeline_json,
                        elapsed_vm112 = excluded.elapsed_vm112,
                        desk_json = excluded.desk_json,
                        vm112_json = excluded.vm112_json,
                        error = excluded.error,
                        by_user = excluded.by_user,
                        updated_at = excluded.updated_at
                    """,
                    (
                        job["id"],
                        job["domain"],
                        job["status"],
                        json.dumps(job.get("timeline") or [], ensure_ascii=False),
                        int(job.get("elapsed_vm112") or 0),
                        json.dumps(job.get("desk") or {}, ensure_ascii=False),
                        json.dumps(job.get("vm112") or {}, ensure_ascii=False),
                        job.get("error"),
                        job.get("by"),
                        job.get("created_at") or _now(),
                        job["updated_at"],
                    ),
                )
                conn.commit()
                return dict(job)
            finally:
                conn.close()

        return _sqlite_retry(_mutate)


def _upsert_step(job_id: str, step: dict[str, str]) -> None:
    def _apply(job: dict[str, Any]) -> None:
        timeline: list[dict[str, str]] = job.setdefault("timeline", [])
        for i, existing in enumerate(timeline):
            if existing.get("label") == step.get("label"):
                timeline[i] = step
                return
        timeline.append(step)

    _mutate_job(job_id, _apply)


def _set_job(job_id: str, **fields: Any) -> None:
    _mutate_job(job_id, lambda job: job.update(fields))


def create_job(domain: str, username: str) -> str:
    domain = domain.lower().strip()
    job_id = uuid.uuid4().hex[:16]
    now = _now()
    job = {
        "id": job_id,
        "job_id": job_id,
        "domain": domain,
        "status": "queued",
        "timeline": [],
        "elapsed_vm112": 0,
        "desk": {},
        "vm112": {},
        "error": None,
        "by": username,
        "created_at": now,
        "updated_at": now,
    }

    def _create() -> None:
        conn = auth.db()
        try:
            active = _find_active_job_locked(conn, domain)
            if active:
                raise PurgeJobConflictError(
                    f"Purge já em curso para {domain} (job {active['id']}, status {active['status']})"
                )
            conn.execute(
                """
                INSERT INTO vm112_purge_jobs (
                    id, domain, status, timeline_json, elapsed_vm112,
                    desk_json, vm112_json, error, by_user, created_at, updated_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    job["id"],
                    job["domain"],
                    job["status"],
                    "[]",
                    0,
                    "{}",
                    "{}",
                    None,
                    job["by"],
                    job["created_at"],
                    job["updated_at"],
                ),
            )
            conn.commit()
        finally:
            conn.close()

    with _lock:
        _ensure_schema()
        _sqlite_retry(_create)
    return job_id


def start_job(
    domain: str,
    root_password: str,
    username: str,
    purge_source: str = "desk.api.purge-jobs",
) -> str:
    job_id = create_job(domain, username)
    _set_job(job_id, desk={"purge_source": purge_source})
    thread = threading.Thread(
        target=_execute_job,
        args=(job_id, domain, root_password, username, purge_source),
        daemon=True,
    )
    thread.start()
    return job_id


def _desk_already_done(job: dict[str, Any]) -> bool:
    for step in job.get("timeline") or []:
        if str(step.get("label") or "") == "Purge concluído" and step.get("status") == "ok":
            return True
    return False


def _finish_desk_phase(job_id: str) -> dict[str, Any] | None:
    job = _load_job(job_id)
    if not job:
        return None
    if _desk_already_done(job):
        if job["status"] != "done":
            _set_job(job_id, status="done")
        return _load_job(job_id)

    domain = job["domain"]
    purge_source = (job.get("desk") or {}).get("purge_source") or "desk.api.purge-jobs"
    vm112_ok = (job.get("vm112") or {}).get("ok")
    vm112_error = job.get("error")
    conn = auth.db()
    try:
        desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(
            conn,
            domain,
            by_user=job.get("by") or "system",
            purge_source=purge_source,
            vm112_ok=vm112_ok,
            vm112_error=vm112_error,
            job_id=job_id,
        )
    finally:
        conn.close()

    for step in desk_timeline:
        _upsert_step(job_id, step)

    total_desk = sum(desk_counts.values())
    _upsert_step(
        job_id,
        vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"),
    )
    _set_job(job_id, status="done", desk=desk_counts)
    return _load_job(job_id)


def recover_job(job_id: str, domain: str | None = None) -> dict[str, Any] | None:
    """Finaliza job quando VM112 já removeu o domínio (ex.: timeout poll ou API reiniciada)."""
    job = _load_job(job_id)
    if not job:
        if not domain:
            return None
        domain = domain.lower().strip()
        if vm112_domains.domain_exists_on_vm112(domain):
            return None
        conn = auth.db()
        try:
            desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
        finally:
            conn.close()
        total_desk = sum(desk_counts.values())
        timeline = [
            vm112_domains._timeline_entry(
                "Purge recuperado",
                "ok",
                "Domínio já ausente na VM112",
            ),
            *desk_timeline,
            vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"),
        ]
        return {
            "id": job_id,
            "job_id": job_id,
            "domain": domain,
            "status": "done",
            "timeline": timeline,
            "elapsed_vm112": 0,
            "desk": desk_counts,
            "vm112": {"ok": True, "recovered": True},
            "error": None,
            "by": None,
        }

    if job["status"] == "done":
        return job

    domain = (domain or job["domain"]).lower().strip()
    if vm112_domains.domain_exists_on_vm112(domain):
        return job

    _upsert_step(
        job_id,
        vm112_domains._timeline_entry(
            "Purge VM112",
            "ok",
            "Domínio já removido na VM112 (recuperação)",
        ),
    )
    finished = _finish_desk_phase(job_id)
    if finished:
        _set_job(
            job_id,
            status="done",
            error=None,
            vm112={**(finished.get("vm112") or {}), "ok": True, "recovered": True},
        )
        return _load_job(job_id)
    return finished


def _execute_job(
    job_id: str,
    domain: str,
    root_password: str,
    username: str,
    purge_source: str = "desk.api.purge-jobs",
) -> None:
    domain = domain.lower().strip()
    try:
        _set_job(job_id, status="running")

        conn = auth.db()
        try:
            if not vm112_domains.verify_root_password(conn, root_password):
                step = vm112_domains._timeline_entry("Validação Root", "fail", "Senha Root incorrecta")
                _upsert_step(job_id, step)
                _set_job(job_id, status="error", error="Senha Root incorrecta")
                return
        finally:
            conn.close()

        _upsert_step(job_id, vm112_domains._timeline_entry("Validação Root + confirmação", "ok"))
        _upsert_step(
            job_id,
            vm112_domains._timeline_entry(
                "Purge VM112 — em execução",
                "running",
                "Carbonio, site, portal, Cloudflare, Traefik…",
            ),
        )

        vm112_result: dict[str, Any] = {"ok": False}
        vm112_banner_marked = False
        for kind, payload in vm112_domains.purge_vm112_with_poll(domain):
            if kind == "step":
                if not vm112_banner_marked:
                    _upsert_step(
                        job_id,
                        vm112_domains._timeline_entry(
                            "Purge VM112 — em execução", "ok", "Passos abaixo",
                        ),
                    )
                    vm112_banner_marked = True
                _upsert_step(job_id, payload)
            elif kind == "heartbeat":
                _set_job(job_id, elapsed_vm112=int(payload.get("elapsed") or 0))
            elif kind == "final":
                vm112_result = payload
                break

        if not vm112_result.get("ok", False):
            err = str(vm112_result.get("error") or "falhou")
            if not vm112_domains.domain_exists_on_vm112(domain):
                _upsert_step(
                    job_id,
                    vm112_domains._timeline_entry(
                        "Purge VM112",
                        "ok",
                        "Domínio removido na VM112 (recuperação automática)",
                    ),
                )
                _set_job(
                    job_id,
                    vm112={**vm112_result, "ok": True, "recovered": True, "previous_error": err},
                    error=None,
                )
                _finish_desk_phase(job_id)
                return
            step = vm112_domains._timeline_entry("Purge VM112", "fail", err)
            _upsert_step(job_id, step)
            _set_job(job_id, status="error", error=err, vm112=vm112_result)
            return

        _set_job(job_id, vm112=vm112_result)
        _finish_desk_phase(job_id)
    except Exception as exc:
        err = str(exc) or "erro inesperado"
        if not vm112_domains.domain_exists_on_vm112(domain):
            _upsert_step(
                job_id,
                vm112_domains._timeline_entry(
                    "Purge VM112",
                    "ok",
                    f"Recuperação após excepção: {err}",
                ),
            )
            _set_job(job_id, vm112={"ok": True, "recovered": True, "previous_error": err}, error=None)
            _finish_desk_phase(job_id)
            return
        _upsert_step(
            job_id,
            vm112_domains._timeline_entry("Purge VM112", "fail", err),
        )
        _set_job(job_id, status="error", error=err)
        traceback.print_exc()


def get_job_public(job_id: str) -> dict[str, Any] | None:
    job = _load_job(job_id)
    if not job:
        return None
    if job["status"] == "running":
        try:
            if not vm112_domains.domain_exists_on_vm112(job["domain"]):
                job = recover_job(job_id) or job
        except Exception:
            pass
    return job


def list_jobs(limit: int = 100, offset: int = 0) -> dict[str, Any]:
    _ensure_schema()
    limit = max(1, min(int(limit), 500))
    offset = max(0, int(offset))
    conn = auth.db()
    try:
        total = conn.execute("SELECT COUNT(*) FROM vm112_purge_jobs").fetchone()[0]
        rows = conn.execute(
            """
            SELECT id, domain, status, by_user, created_at, updated_at,
                   elapsed_vm112, desk_json, error
            FROM vm112_purge_jobs
            ORDER BY created_at DESC
            LIMIT ? OFFSET ?
            """,
            (limit, offset),
        ).fetchall()
        jobs = []
        for row in rows:
            desk = json.loads(row["desk_json"] or "{}")
            desk_total = sum(int(v or 0) for v in desk.values())
            jobs.append(
                {
                    "id": row["id"],
                    "job_id": row["id"],
                    "domain": row["domain"],
                    "status": row["status"],
                    "by": row["by_user"],
                    "created_at": row["created_at"],
                    "updated_at": row["updated_at"],
                    "elapsed_vm112": int(row["elapsed_vm112"] or 0),
                    "desk": desk,
                    "desk_removed_total": desk_total,
                    "error": row["error"],
                }
            )
        return {"jobs": jobs, "total": int(total), "limit": limit, "offset": offset}
    finally:
        conn.close()
