"""
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 v3.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("v3/distributed/jobs.db")
_CLIENT_TTL: int = 120  # seconds before a client is considered gone
_MAX_ATTEMPTS: int = 3  # max times a job is retried before being left as failed

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


def _reap_stale_clients():
    """Background thread: remove silent clients and re-queue their running jobs."""
    while True:
        time.sleep(30)
        cutoff = datetime.now(timezone.utc).timestamp() - _CLIENT_TTL

        # Step 1: evict timed-out clients from registry
        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())

        # Step 2: reset any running job whose assigned client is no longer known
        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 'v3/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)")
        # Add attempts column to existing DBs that predate this field
        try:
            conn.execute(
                "ALTER TABLE jobs ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0"
            )
        except Exception:
            pass  # column already exists
        # Note: running jobs are NOT reset on startup — active clients will re-register
        # via poll/status and the reaper will clean up any that don't reconnect within TTL.


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) so the caller
    can ask the client to re-register with full info."""
    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()

    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"]
        # Reset any other running jobs for this client — client can only work on one at a time.
        # This cleans up orphans left over from server restarts.
        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):
    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)

            # Auto-clear run if all jobs for this run_name are now done
            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):
    job_id = str(uuid.uuid4())[:12]
    with _db() as conn:
        conn.execute(
            "INSERT INTO jobs "
            "(job_id, run_name, module, args, output_dir, priority, created_at) "
            "VALUES (?,?,?,?,?,?,?)",
            (
                job_id,
                job.run_name,
                job.module,
                json.dumps(job.args),
                job.output_dir,
                job.priority,
                _now(),
            ),
        )
    print(f"[server] queued {job_id} ({job.run_name})", flush=True)
    return {"job_id": job_id}


@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="v3/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)",
    )
    args = ap.parse_args()

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

    global _TOKEN, _DB_PATH, _CLIENT_TTL, _MAX_ATTEMPTS
    _TOKEN = args.token
    _DB_PATH = Path(args.db)
    _CLIENT_TTL = args.client_ttl
    _MAX_ATTEMPTS = args.max_attempts
    _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()
