#!/usr/bin/env python3
"""
api.py — HTTP frontend over the same clone pipeline bot.py drives (rip, changes, agent).

This is a stateless execution service: it has no concept of users. The caller (a web app
backend that already owns auth + a users/jobs table) is trusted via a shared API key, checks
job ownership on its own side BEFORE calling here, and passes its own job id straight through
— this service never mints or looks up an owner.

Run:
    uvicorn api:app --host 0.0.0.0 --port 8090
"""

import os
import json
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel

import rip
import changes
import agent
import job_common

# ── Config ────────────────────────────────────────────────────────────────────

JOBS_DIR   = Path(os.environ.get("JOBS_DIR", "/srv/site-clones"))
SERVER_URL = os.environ.get("SERVER_URL", "").rstrip("/")
API_KEY    = os.environ["API_KEY"]   # shared secret with the calling web app backend only

app = FastAPI(title="site-clones execution API")

# One lock per job dir name so status.json reads/writes and "already running" checks are safe
# under concurrent requests; the actual clone/agent work still runs in its own thread.
_locks: dict[str, threading.Lock] = {}
_locks_guard = threading.Lock()


def _lock_for(job_id: str) -> threading.Lock:
    with _locks_guard:
        return _locks.setdefault(job_id, threading.Lock())


# ── Auth ──────────────────────────────────────────────────────────────────────

def _check_auth(authorization: Optional[str]):
    if not authorization or authorization != f"Bearer {API_KEY}":
        raise HTTPException(status_code=401, detail="invalid or missing API key")


# ── Job path / status helpers ─────────────────────────────────────────────────

def _job_dir(job_id: str) -> Path:
    """job_id is the caller's own primary key — sanitized for filesystem safety only, never
    re-derived or looked up here."""
    safe = job_common.sanitize_job_name(job_id)
    if not safe:
        raise HTTPException(status_code=400, detail="invalid job_id")
    return JOBS_DIR / safe


def _status_path(job_dir: Path) -> Path:
    return job_dir / "status.json"


def _write_status(job_dir: Path, state: str, message: str = "", preview_url: str = ""):
    _status_path(job_dir).write_text(json.dumps({
        "state": state,               # pending | running | done | error
        "message": message,
        "preview_url": preview_url,
        "updated_at": datetime.now(timezone.utc).isoformat(),
    }, indent=2))


def _read_status(job_dir: Path) -> dict:
    p = _status_path(job_dir)
    if not p.exists():
        raise HTTPException(status_code=404, detail="job not found")
    return json.loads(p.read_text())


def _preview_url(job_id: str) -> str:
    return f"{SERVER_URL}/{job_id}/" if SERVER_URL else ""


# ── Schemas ───────────────────────────────────────────────────────────────────

class JobCreate(BaseModel):
    job_id: str
    url: str
    orig_brand: str = ""
    our_brand: str = ""
    orig_product: str = ""
    our_product: str = ""
    cta_url: str = ""


class ChangeRequest(BaseModel):
    text: str
    uploads: list[str] = []   # filenames already saved via /jobs/{id}/uploads for this request


# ── POST /jobs — initial clone (deterministic: rip + changes, no LLM) ────────

@app.post("/jobs", status_code=202)
def create_job(body: JobCreate, authorization: Optional[str] = Header(None)):
    _check_auth(authorization)
    job_dir = _job_dir(body.job_id)
    if job_dir.exists():
        raise HTTPException(status_code=409, detail="job already exists")

    meta = {
        "url": body.url,
        "orig_brand": body.orig_brand, "our_brand": body.our_brand,
        "orig_product": body.orig_product, "our_product": body.our_product,
        "cta_url": body.cta_url,
    }
    (job_dir / "assets-new").mkdir(parents=True)
    (job_dir / "job.md").write_text(
        job_common.build_job_md(job_dir.name, body.url, body.orig_brand, body.our_brand,
                                 body.orig_product, body.our_product, body.cta_url)
    )
    _write_status(job_dir, "running", "Building your clone…")

    def _worker():
        try:
            rip.rip(job_dir, body.url, post=None)
        except Exception as exc:
            _write_status(job_dir, "error", f"Couldn't fetch the page: {exc}")
            return
        if (job_dir / "index.html").exists():
            try:
                changes.apply(job_dir / "index.html", meta)
            except Exception as exc:
                _write_status(job_dir, "error", f"Couldn't apply the changes: {exc}")
                return
        problems = [c[2:].strip() for c in job_common.verify_job_output(job_dir, meta)
                    if c.startswith("❌")]
        message = "Clone ready."
        if problems:
            message += " ⚠️ " + "; ".join(problems)
        _write_status(job_dir, "done", message, _preview_url(job_dir.name))

    threading.Thread(target=_worker, daemon=True).start()
    return {"job_id": job_dir.name, "status_url": f"/jobs/{job_dir.name}/status"}


# ── POST /jobs/{id}/uploads — save a file into brief.pdf or assets-new/ ──────

@app.post("/jobs/{job_id}/uploads")
async def upload_file(job_id: str, file: UploadFile = File(...),
                       authorization: Optional[str] = Header(None)):
    _check_auth(authorization)
    job_dir = _job_dir(job_id)
    if not job_dir.exists():
        raise HTTPException(status_code=404, detail="job not found")

    is_pdf = file.filename.lower().endswith(".pdf")
    dest = job_dir / "brief.pdf" if is_pdf else job_dir / "assets-new" / file.filename
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_bytes(await file.read())
    return {"saved_as": dest.name, "kind": "brief" if is_pdf else "asset"}


# ── POST /jobs/{id}/changes — free-form change request (LLM agent) ──────────

@app.post("/jobs/{job_id}/changes", status_code=202)
def request_change(job_id: str, body: ChangeRequest, authorization: Optional[str] = Header(None)):
    _check_auth(authorization)
    job_dir = _job_dir(job_id)
    if not job_dir.exists():
        raise HTTPException(status_code=404, detail="job not found")

    lock = _lock_for(job_dir.name)
    if not lock.acquire(blocking=False):
        raise HTTPException(status_code=409, detail="a change is already running for this job")

    _write_status(job_dir, "running", "Applying your change…")

    def _worker():
        try:
            summary = agent.run(job_dir, body.text, uploads=body.uploads or None,
                                 on_log=lambda m: print(f"[agent {job_dir.name}] {m}", flush=True))
            _write_status(job_dir, "done", summary, _preview_url(job_dir.name))
        except Exception as exc:
            _write_status(job_dir, "error", f"Couldn't apply that: {exc}")
        finally:
            lock.release()

    threading.Thread(target=_worker, daemon=True).start()
    return {"job_id": job_dir.name, "status_url": f"/jobs/{job_dir.name}/status"}


# ── POST /jobs/{id}/undo — revert to the previous saved version ─────────────

@app.post("/jobs/{job_id}/undo")
def undo_change(job_id: str, authorization: Optional[str] = Header(None)):
    _check_auth(authorization)
    job_dir = _job_dir(job_id)
    if not job_dir.exists():
        raise HTTPException(status_code=404, detail="job not found")
    message = agent.undo(job_dir)
    _write_status(job_dir, "done", message, _preview_url(job_dir.name))
    return {"message": message}


# ── GET /jobs/{id}/status — poll while a create/change is running ───────────

@app.get("/jobs/{job_id}/status")
def get_status(job_id: str, authorization: Optional[str] = Header(None)):
    _check_auth(authorization)
    job_dir = _job_dir(job_id)
    return _read_status(job_dir)


# ── GET /jobs/{id}/download — zip the deliverable (index.html + assets/) ────

@app.get("/jobs/{job_id}/download")
def download(job_id: str, authorization: Optional[str] = Header(None)):
    _check_auth(authorization)
    job_dir = _job_dir(job_id)
    if not (job_dir / "index.html").exists():
        raise HTTPException(status_code=404, detail="no index.html yet — nothing to package")
    zip_path = job_common.make_zip(job_dir)
    return FileResponse(zip_path, filename=zip_path.name, media_type="application/zip")
