"""
HyperTower distributed job server.

Manages a SQLite job queue and a registry of connected clients.
Clients poll for work, push status updates, and report completion.

Usage:
    python -m v4.distributed.server --port 8765 --token <secret>

Environment:
    HT_TOKEN  — fallback if --token is not passed
"""

from __future__ import annotations

import argparse
import json
import os
import sqlite3
import threading
import time
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from fastapi import Depends, FastAPI, Header, HTTPException
import uvicorn

from .protocol import (
    ClientInfo,
    JobResult,
    JobSpec,
    JobSubmit,
    PollResponse,
    RegisterRequest,
    RegisterResponse,
    StatusPush,
)

# ──────────────────────────────────────────────────────────────
# Global state
# ──────────────────────────────────────────────────────────────

_TOKEN: str = ""
_DB_PATH: Path = Path("v4/distributed/jobs.db")
_REPO_ROOT: Path = Path.cwd()
_CLIENT_TTL: int = 120  # seconds before a client is considered gone
_MAX_ATTEMPTS: int = 4  # max times a job is retried (cumulatively, across clients)
                        # before being marked permanently failed
_CLIENT_FAIL_LIMIT: int = 3  # consecutive job failures from a single client before
                             # that client is quarantined (no more poll dispatches
                             # until it re-registers). A successful job resets it.
_SERVER_START_TS: float = 0.0  # set in main(); used as a reaper grace window

_clients: dict[str, ClientInfo] = {}
_clients_lock = threading.Lock()


def _reap_stale_clients():
    """Background thread: remove silent clients and re-queue their running jobs.

    On startup, the in-memory `_clients` dict is empty until clients re-register
    via the `please_reregister` mechanism.  We skip the running-job re-queue pass
    for the first `_CLIENT_TTL` seconds after startup so still-alive clients have
    time to come back; otherwise the reaper would orphan their jobs.
    """
    while True:
        time.sleep(30)
        cutoff = datetime.now(timezone.utc).timestamp() - _CLIENT_TTL

        with _clients_lock:
            stale = [
                cid
                for cid, c in _clients.items()
                if datetime.fromisoformat(c.last_seen).timestamp() < cutoff
            ]
            for cid in stale:
                print(
                    f"[server] reaped stale client {cid} ({_clients[cid].hostname})",
                    flush=True,
                )
                del _clients[cid]
            known_ids = set(_clients.keys())

        in_grace = (time.time() - _SERVER_START_TS) < _CLIENT_TTL
        if in_grace:
            continue

        with _db() as conn:
            rows = conn.execute(
                "SELECT job_id, assigned_to FROM jobs WHERE state='running'"
            ).fetchall()
            for row in rows:
                if row["assigned_to"] not in known_ids:
                    conn.execute(
                        "UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL "
                        "WHERE job_id=?",
                        (row["job_id"],),
                    )
                    print(
                        f"[server] re-queued job {row['job_id']} "
                        f"(client {row['assigned_to']} unknown)",
                        flush=True,
                    )


# ──────────────────────────────────────────────────────────────
# Database helpers
# ──────────────────────────────────────────────────────────────


@contextmanager
def _db():
    conn = sqlite3.connect(str(_DB_PATH))
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    finally:
        conn.close()


def _init_db():
    _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    with _db() as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS jobs (
                job_id       TEXT PRIMARY KEY,
                run_name     TEXT NOT NULL,
                module       TEXT NOT NULL,
                args         TEXT NOT NULL,   -- JSON list
                output_dir   TEXT NOT NULL DEFAULT 'v4/results',
                state        TEXT NOT NULL DEFAULT 'pending',
                priority     INTEGER NOT NULL DEFAULT 0,
                assigned_to  TEXT,
                created_at   TEXT NOT NULL,
                started_at   TEXT,
                completed_at TEXT,
                error_msg    TEXT,
                attempts     INTEGER NOT NULL DEFAULT 0
            )
        """
        )
        conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state)")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_priority ON jobs(priority)")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_run_name ON jobs(run_name)")
        conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_args ON jobs(args)")
        try:
            conn.execute(
                "ALTER TABLE jobs ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0"
            )
        except Exception:
            pass  # column already exists


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


def _ensure_client(client_id: str, hostname: str = "", gpu_info: str = "") -> bool:
    """Re-register a client that survived a server restart.
    Returns True if the client was unknown (placeholder created)."""
    if client_id not in _clients:
        _clients[client_id] = ClientInfo(
            client_id=client_id,
            hostname=hostname or client_id,
            gpu_info=gpu_info or "unknown",
            status=StatusPush(state="idle"),
            last_seen=_now(),
        )
        print(f"[server] re-registered {client_id} (survived restart)", flush=True)
        return True
    return False


# ──────────────────────────────────────────────────────────────
# FastAPI app
# ──────────────────────────────────────────────────────────────

app = FastAPI(title="HyperTower Job Server")


def _check_token(x_token: str = Header(...)):
    if x_token != _TOKEN:
        raise HTTPException(status_code=403, detail="Invalid token")


# ── Registration ──────────────────────────────────────────────


@app.post(
    "/register", response_model=RegisterResponse, dependencies=[Depends(_check_token)]
)
def register(req: RegisterRequest, reuse_id: Optional[str] = None):
    with _clients_lock:
        client_id = (
            reuse_id if (reuse_id and reuse_id in _clients) else str(uuid.uuid4())[:8]
        )
        existing_status = (
            _clients[client_id].status
            if client_id in _clients
            else StatusPush(state="idle")
        )
        _clients[client_id] = ClientInfo(
            client_id=client_id,
            hostname=req.hostname,
            gpu_info=req.gpu_info,
            status=existing_status,
            last_seen=_now(),
        )
    action = "re-registered" if reuse_id else "registered"
    print(
        f"[server] {action} {client_id} ({req.hostname} | {req.gpu_info})", flush=True
    )
    return RegisterResponse(client_id=client_id)


# ── Job polling ───────────────────────────────────────────────


@app.post("/poll", response_model=PollResponse, dependencies=[Depends(_check_token)])
def poll(client_id: str):
    with _clients_lock:
        needs_reregister = _ensure_client(client_id)
        _clients[client_id].last_seen = _now()
        # Quarantine: a client that has failed _CLIENT_FAIL_LIMIT jobs in a row
        # is cut off from new dispatches until it re-registers. The client's
        # poll() helper auto-calls _reregister() when please_reregister=True
        # arrives, which clears the streak. Successful completions also reset
        # the streak, so a healthy client never trips this check.
        if _clients[client_id].fail_streak >= _CLIENT_FAIL_LIMIT:
            print(
                f"[server] {client_id} quarantined "
                f"(fail_streak={_clients[client_id].fail_streak}); "
                f"requesting re-register before next dispatch",
                flush=True,
            )
            return PollResponse(job=None, please_reregister=True)

    with _db() as conn:
        row = conn.execute(
            "SELECT * FROM jobs WHERE state='pending' "
            "ORDER BY priority DESC, created_at ASC LIMIT 1"
        ).fetchone()

        if row is None:
            return PollResponse(job=None, please_reregister=needs_reregister)

        job_id = row["job_id"]
        cur = conn.execute(
            "UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL "
            "WHERE assigned_to=? AND state='running' AND job_id!=?",
            (client_id, job_id),
        )
        if cur.rowcount:
            print(
                f"[server] reset {cur.rowcount} orphaned running job(s) for {client_id}",
                flush=True,
            )
        conn.execute(
            "UPDATE jobs SET state='running', assigned_to=?, started_at=? WHERE job_id=?",
            (client_id, _now(), job_id),
        )

    job = JobSpec(
        job_id=job_id,
        run_name=row["run_name"],
        module=row["module"],
        args=json.loads(row["args"]),
        output_dir=row["output_dir"],
    )

    with _clients_lock:
        _clients[client_id].status = StatusPush(
            state="syncing", job_id=job_id, run_name=row["run_name"]
        )

    print(f"[server] dispatched {job_id} ({row['run_name']}) → {client_id}", flush=True)
    return PollResponse(job=job, please_reregister=needs_reregister)


# ── Status ────────────────────────────────────────────────────


@app.post("/status/{client_id}", dependencies=[Depends(_check_token)])
def push_status(client_id: str, status: StatusPush):
    with _clients_lock:
        needs_reregister = _ensure_client(client_id)
        _clients[client_id].status = status
        _clients[client_id].last_seen = _now()
    return {"ok": True, "please_reregister": needs_reregister}


@app.get("/clients", dependencies=[Depends(_check_token)])
def list_clients():
    with _clients_lock:
        return list(_clients.values())


@app.get("/clients/{client_id}", dependencies=[Depends(_check_token)])
def get_client(client_id: str):
    with _clients_lock:
        if client_id not in _clients:
            raise HTTPException(status_code=404, detail="Unknown client")
        return _clients[client_id]


# ── Job completion ────────────────────────────────────────────


@app.post("/complete", dependencies=[Depends(_check_token)])
def complete(result: JobResult):
    # Pull the worker that ran this job before touching the row, so we can
    # update its streak regardless of which branch we take below.
    with _db() as conn:
        assigned_row = conn.execute(
            "SELECT assigned_to FROM jobs WHERE job_id=?", (result.job_id,)
        ).fetchone()
    assigned_to = assigned_row["assigned_to"] if assigned_row else None

    with _clients_lock:
        if assigned_to and assigned_to in _clients:
            if result.success:
                _clients[assigned_to].fail_streak = 0
            else:
                _clients[assigned_to].fail_streak += 1
                if _clients[assigned_to].fail_streak >= _CLIENT_FAIL_LIMIT:
                    print(
                        f"[server] {assigned_to} hit fail_streak="
                        f"{_clients[assigned_to].fail_streak}; will quarantine "
                        f"on next poll",
                        flush=True,
                    )

    with _db() as conn:
        if result.success:
            conn.execute(
                "UPDATE jobs SET state='done', completed_at=?, error_msg=NULL WHERE job_id=?",
                (_now(), result.job_id),
            )
            print(f"[server] job {result.job_id} → done", flush=True)

            run_row = conn.execute(
                "SELECT run_name FROM jobs WHERE job_id=?", (result.job_id,)
            ).fetchone()
            if run_row:
                run_name = run_row["run_name"]
                remaining = conn.execute(
                    "SELECT COUNT(*) FROM jobs WHERE run_name=? AND state != 'done'",
                    (run_name,),
                ).fetchone()[0]
                if remaining == 0:
                    total = conn.execute(
                        "SELECT COUNT(*) FROM jobs WHERE run_name=?", (run_name,)
                    ).fetchone()[0]
                    conn.execute("DELETE FROM jobs WHERE run_name=?", (run_name,))
                    print(
                        f"[server] run '{run_name}' complete ({total} jobs) — cleared",
                        flush=True,
                    )
        else:
            row = conn.execute(
                "SELECT attempts FROM jobs WHERE job_id=?", (result.job_id,)
            ).fetchone()
            attempts = (row["attempts"] if row else 0) + 1
            if attempts < _MAX_ATTEMPTS:
                conn.execute(
                    "UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL, "
                    "attempts=?, error_msg=? WHERE job_id=?",
                    (attempts, result.error_msg, result.job_id),
                )
                print(
                    f"[server] job {result.job_id} failed (attempt {attempts}/{_MAX_ATTEMPTS}), "
                    f"re-queuing",
                    flush=True,
                )
            else:
                conn.execute(
                    "UPDATE jobs SET state='failed', completed_at=?, attempts=?, error_msg=? "
                    "WHERE job_id=?",
                    (_now(), attempts, result.error_msg, result.job_id),
                )
                print(
                    f"[server] job {result.job_id} failed permanently after "
                    f"{attempts} attempts",
                    flush=True,
                )
    return {"ok": True}


# ── Job queue management ──────────────────────────────────────


@app.post("/jobs", dependencies=[Depends(_check_token)])
def submit_job(job: JobSubmit):
    result_dir = _REPO_ROOT / job.output_dir / job.run_name
    if result_dir.exists() and any(result_dir.rglob("summary.json")):
        print(f"[server] skipped {job.run_name} (results exist on disk)", flush=True)
        return {"job_id": "", "duplicate": False, "skipped": True}

    args_json = json.dumps(job.args)
    job_id    = str(uuid.uuid4())[:12]
    with _db() as conn:
        cur = conn.execute(
            "INSERT OR IGNORE INTO jobs "
            "(job_id, run_name, module, args, output_dir, priority, created_at) "
            "VALUES (?,?,?,?,?,?,?)",
            (job_id, job.run_name, job.module, args_json,
             job.output_dir, job.priority, _now()),
        )
        if cur.rowcount == 0:
            existing = conn.execute(
                "SELECT job_id, state FROM jobs WHERE args=?", (args_json,)
            ).fetchone()
            # If the existing duplicate is a terminal failure, drop it and
            # take the new submission — saves an explicit /jobs/clear round-
            # trip when re-deploying after a fix.
            if existing["state"] == "failed":
                conn.execute(
                    "DELETE FROM jobs WHERE job_id=?", (existing["job_id"],)
                )
                conn.execute(
                    "INSERT INTO jobs "
                    "(job_id, run_name, module, args, output_dir, priority, created_at) "
                    "VALUES (?,?,?,?,?,?,?)",
                    (job_id, job.run_name, job.module, args_json,
                     job.output_dir, job.priority, _now()),
                )
                print(
                    f"[server] requeued failed {existing['job_id']} → {job_id} "
                    f"({job.run_name})",
                    flush=True,
                )
                return {
                    "job_id":          job_id,
                    "duplicate":       False,
                    "requeued":        True,
                    "previous_job_id": existing["job_id"],
                }
            job_id = existing["job_id"]
            print(f"[server] duplicate ignored ({job.run_name}) → {job_id}", flush=True)
            return {"job_id": job_id, "duplicate": True}
    print(f"[server] queued {job_id} ({job.run_name})", flush=True)
    return {"job_id": job_id, "duplicate": False}


@app.get("/jobs", dependencies=[Depends(_check_token)])
def list_jobs(state: Optional[str] = None):
    with _db() as conn:
        if state:
            rows = conn.execute(
                "SELECT * FROM jobs WHERE state=? ORDER BY created_at DESC", (state,)
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT * FROM jobs ORDER BY created_at DESC"
            ).fetchall()
    return [dict(r) for r in rows]


@app.post("/jobs/clear", dependencies=[Depends(_check_token)])
def clear_jobs(body: dict):
    with _db() as conn:
        if body.get("all"):
            cur = conn.execute("DELETE FROM jobs")
        elif body.get("run_name"):
            cur = conn.execute("DELETE FROM jobs WHERE run_name=?", (body["run_name"],))
        else:
            states = body.get("states", ["done", "failed", "cancelled"])
            placeholders = ",".join("?" * len(states))
            cur = conn.execute(
                f"DELETE FROM jobs WHERE state IN ({placeholders})", states
            )
    print(f"[server] cleared {cur.rowcount} jobs", flush=True)
    return {"cleared": cur.rowcount}


@app.delete("/jobs/{job_id}", dependencies=[Depends(_check_token)])
def cancel_job(job_id: str):
    with _db() as conn:
        conn.execute(
            "UPDATE jobs SET state='cancelled' WHERE job_id=? AND state='pending'",
            (job_id,),
        )
    return {"ok": True}


# ──────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────


def main():
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    ap.add_argument("--port", type=int, default=8765)
    ap.add_argument("--host", default="0.0.0.0")
    ap.add_argument(
        "--token",
        default=os.environ.get("HT_TOKEN", ""),
        help="Shared secret (or set HT_TOKEN env var)",
    )
    ap.add_argument(
        "--db", default="v4/distributed/jobs.db", help="Path to SQLite job database"
    )
    ap.add_argument(
        "--client-ttl",
        type=int,
        default=120,
        help="Seconds of silence before a client is reaped (default: 120)",
    )
    ap.add_argument(
        "--max-attempts",
        type=int,
        default=3,
        help="Max times a failed job is retried before being left as failed (default: 3)",
    )
    ap.add_argument(
        "--root",
        default="",
        help="Repo root for results-existence checks (default: cwd)",
    )
    args = ap.parse_args()

    if not args.token:
        ap.error("--token is required (or set HT_TOKEN)")

    global _TOKEN, _DB_PATH, _REPO_ROOT, _CLIENT_TTL, _MAX_ATTEMPTS, _SERVER_START_TS
    _TOKEN = args.token
    _DB_PATH = Path(args.db)
    _REPO_ROOT = Path(args.root).resolve() if args.root else Path.cwd()
    _CLIENT_TTL = args.client_ttl
    _MAX_ATTEMPTS = args.max_attempts
    _SERVER_START_TS = time.time()
    _init_db()

    reaper = threading.Thread(target=_reap_stale_clients, daemon=True)
    reaper.start()

    print(
        f"[server] listening on {args.host}:{args.port}  client_ttl={_CLIENT_TTL}s",
        flush=True,
    )
    uvicorn.run(app, host=args.host, port=args.port, log_level="warning")


if __name__ == "__main__":
    main()
