from __future__ import annotations

import asyncio
import json
import logging
import shutil
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from app import __version__, cleanup, media, store, worker, youtube
from app.config import ROOT, get_settings
from app.eligibility import assess
from app.errors import VaaniError
from app.gates import ensure_gates_file, gate_status
from app.languages import is_known, public_list
from app.links import classify_link, is_video_id
from app.media import ALLOWED_SUFFIXES
from app.text import safe_download_name
from app.transcribe import whisper_installed
from app.util import free_gb

log = logging.getLogger("vaani")
STATIC = ROOT / "static"
_IMAGE_TYPES = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}


class ResolveIn(BaseModel):
    url: str


class JobIn(BaseModel):
    url: str
    language: str = "auto"
    speakers: int = 2
    english: bool = False
    rights: bool = False
    title: str = ""
    channel: str = ""


def _as_bool(value: str | bool) -> bool:
    if isinstance(value, bool):
        return value
    return str(value).strip().lower() in {"1", "true", "yes", "on"}


def _options(language: str, speakers: int, english: bool, rights: bool) -> dict:
    if not rights:
        raise VaaniError("Confirm that you have the right to transcribe this recording.", "rights", 400)
    if not is_known(language):
        raise VaaniError("Choose a language from the list.", "language", 400)
    if speakers < 1 or speakers > 4:
        raise VaaniError("Choose between one and four voices.", "speakers", 400)
    return {
        "language": language,
        "speakers": speakers,
        "english": bool(english),
        "rights": True,
    }


def _ensure_room() -> None:
    settings = get_settings()
    if free_gb(settings.data_dir) < settings.min_free_gb:
        raise VaaniError(
            "This computer is low on free disk space, so a new recording cannot be saved.",
            "disk_low",
            400,
        )


def _blank_step() -> dict:
    return {"state": "pending", "detail": "", "started_at": None, "ended_at": None}


def json_ok(data, status: int = 200) -> JSONResponse:
    return JSONResponse(data, status_code=status, headers={"Cache-Control": "no-store"})


def public_job(job: dict) -> dict:
    payload = dict(job)
    turns_path = store.job_dir(job["id"]) / "turns.json"
    payload["turns"] = []
    payload["english_turns"] = []
    if turns_path.is_file():
        try:
            extra = json.loads(turns_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            extra = {}
        payload["turns"] = extra.get("turns") or []
        payload["english_turns"] = extra.get("english_turns") or []
        payload["language_name"] = extra.get("language_name")
        payload["script"] = extra.get("script")
    return payload


async def save_stream(upload: UploadFile, dest: Path, limit: int) -> None:
    size = 0
    with dest.open("wb") as handle:
        while True:
            chunk = await upload.read(1024 * 1024)
            if not chunk:
                break
            size += len(chunk)
            if size > limit:
                handle.close()
                dest.unlink(missing_ok=True)
                raise VaaniError(
                    f"That file is larger than {limit // (1024 * 1024)} MB.",
                    "too_large",
                    400,
                )
            handle.write(chunk)
    if size < 1000:
        dest.unlink(missing_ok=True)
        raise VaaniError("That file is empty.", "audio_unreadable", 400)


def _suffix(upload: UploadFile) -> str:
    name = Path(upload.filename or "").name
    suffix = Path(name).suffix.lower()
    if suffix not in ALLOWED_SUFFIXES:
        raise VaaniError("Upload an audio or video file.", "audio_unreadable", 400)
    return suffix


def _clear_outputs(directory: Path) -> None:
    for name in (
        "audio.mp3",
        "audio.wav",
        "audio.partial.mp3",
        "audio.partial.wav",
        "transcript.txt",
        "transcript.en.txt",
        "turns.json",
    ):
        path = directory / name
        if path.exists():
            path.unlink()


@asynccontextmanager
async def lifespan(_app: FastAPI):
    settings = get_settings()
    settings.data_dir.mkdir(parents=True, exist_ok=True)
    store.init(settings.data_dir)
    ensure_gates_file(settings)
    if not settings.testing:
        removed = cleanup.purge(settings.retention_hours)
        if removed:
            log.info("removed %s expired recording(s)", removed)
        worker.start()
        worker.recover()
        log.info("Vaani desk at http://%s:%s", settings.host, settings.port)
        if not media.ffmpeg_ready():
            log.warning("ffmpeg was not found. MP3 and transcript need it.")
    try:
        yield
    finally:
        if not settings.testing:
            worker.stop()


app = FastAPI(title="Vaani", version=__version__, lifespan=lifespan)


@app.middleware("http")
async def production_gate(request: Request, call_next):
    settings = get_settings()
    path = request.url.path
    if settings.production and path.startswith("/api/") and path != "/api/health":
        status = gate_status(settings)
        if not status["ok"]:
            return JSONResponse(
                status_code=503,
                content={
                    "error": {
                        "code": "gates_closed",
                        "message": "This desk is not open for shared use yet. The launch checks are still unfinished.",
                        "recovery": None,
                    }
                },
            )
    return await call_next(request)


@app.exception_handler(VaaniError)
async def vaani_error(_request: Request, exc: VaaniError):
    return JSONResponse(
        status_code=exc.status,
        content={"error": {"code": exc.code, "message": exc.message, "recovery": exc.recovery}},
    )


@app.exception_handler(RequestValidationError)
async def bad_request(_request: Request, exc: RequestValidationError):
    log.info("rejected request: %s", exc.errors())
    return JSONResponse(
        status_code=400,
        content={
            "error": {
                "code": "invalid",
                "message": "The request was missing something the desk needs.",
                "recovery": None,
            }
        },
    )


@app.get("/")
def index():
    return FileResponse(STATIC / "index.html")


@app.get("/api/health")
def health():
    settings = get_settings()
    gates = gate_status(settings)
    ytdlp = True
    try:
        import yt_dlp  # noqa: F401
    except ImportError:
        ytdlp = False
    return json_ok(
        {
            "version": __version__,
            "ffmpeg": media.ffmpeg_ready(),
            "ffprobe": shutil.which("ffprobe") is not None,
            "ytdlp": ytdlp,
            "whisper": whisper_installed(),
            "whisper_model": settings.whisper_model,
            "max_duration_seconds": settings.max_duration_seconds,
            "retention_hours": settings.retention_hours,
            "max_upload_mb": settings.max_upload_mb,
            "production": settings.production,
            "exposed": settings.exposed,
            "gates_open": gates["ok"],
            "gate_problems": gates["problems"],
            "min_free_gb": settings.min_free_gb,
            "free_gb": round(free_gb(settings.data_dir), 1),
            "languages": public_list(),
        }
    )


@app.post("/api/resolve")
async def resolve(body: ResolveIn):
    classified = classify_link(body.url)
    settings = get_settings()
    if classified["kind"] == "video":
        info = await asyncio.to_thread(youtube.fetch_video, classified["url"])
        allowed, code, message = assess(info, settings.max_duration_seconds)
        video = {
            **info,
            "url": classified["url"],
            "eligible": allowed,
            "reject_code": code,
            "reject_message": message,
            "source": "youtube",
        }
        return json_ok({"kind": "video", "video": video})
    collection = await asyncio.to_thread(youtube.fetch_collection, classified["url"])
    return json_ok(
        {
            "kind": "collection",
            "collection": classified["collection"],
            "title": collection["title"],
            "url": collection["url"],
            "videos": collection["videos"],
        }
    )


@app.get("/api/thumbnail/{video_id}")
def thumbnail(video_id: str, download: int = 0):
    if not is_video_id(video_id):
        raise VaaniError("That thumbnail is not on this desk.", "not_found", 404)
    cache = get_settings().data_dir / "thumbs"
    cache.mkdir(parents=True, exist_ok=True)
    dest = cache / f"{video_id}.jpg"
    if not dest.exists() and not youtube.save_thumbnail(video_id, dest):
        raise VaaniError("This video has no thumbnail.", "not_found", 404)
    return FileResponse(
        dest,
        media_type="image/jpeg",
        filename=f"{video_id}.jpg" if download else None,
        content_disposition_type="attachment",
    )


@app.post("/api/jobs")
async def create_job(body: JobIn):
    _ensure_room()
    classified = classify_link(body.url)
    if classified["kind"] != "video":
        raise VaaniError("Choose one video from the list first.", "need_video", 400)
    options = _options(body.language, body.speakers, body.english, body.rights)
    source = {
        "type": "youtube",
        "url": classified["url"],
        "video_id": classified["video_id"],
        "title": (body.title or "").strip()[:300] or None,
        "channel": (body.channel or "").strip()[:200] or None,
        "duration": None,
        "upload_date": None,
    }
    job = store.create(store.new_job(source, options))
    if not get_settings().testing:
        worker.submit(job["id"])
    return json_ok(public_job(job))


@app.post("/api/jobs/from-upload")
async def create_from_upload(
    file: UploadFile = File(...),
    language: str = Form("auto"),
    speakers: int = Form(2),
    english: str = Form("false"),
    rights: str = Form("false"),
    title: str = Form(""),
):
    _ensure_room()
    options = _options(language, int(speakers), _as_bool(english), _as_bool(rights))
    suffix = _suffix(file)
    stem = Path(file.filename or "recording").stem[:180] or "recording"
    source = {
        "type": "upload",
        "url": None,
        "video_id": None,
        "title": (title or stem).strip()[:300],
        "channel": "",
        "duration": None,
        "upload_date": None,
        "original_name": Path(file.filename or "").name[:200],
    }
    job = store.create(store.new_job(source, options))
    directory = store.job_dir(job["id"])
    dest = directory / f"upload{suffix}"
    try:
        await save_stream(file, dest, get_settings().max_upload_mb * 1024 * 1024)
        duration = int(round(await asyncio.to_thread(media.probe_duration, dest)))
        allowed, code, message = assess(
            {"duration": duration, "live_status": "not_live"},
            get_settings().max_duration_seconds,
        )
        if not allowed:
            raise VaaniError(message or "This file cannot be used.", code or "too_long", 400)

        def mutate(current: dict, duration=duration) -> None:
            current["source"]["duration"] = duration

        job = store.update(job["id"], mutate)
    except Exception:
        store.delete(job["id"])
        raise
    if not get_settings().testing:
        worker.submit(job["id"])
    return json_ok(public_job(job))


@app.get("/api/jobs")
def list_jobs():
    return json_ok([store.summary(job) for job in store.list_jobs()[:40]])


@app.get("/api/jobs/{job_id}")
def get_job(job_id: str):
    return json_ok(public_job(store.load(job_id)))


@app.post("/api/jobs/{job_id}/cancel")
def cancel_job(job_id: str):
    job = store.load(job_id)
    if job["status"] in {"complete", "failed"}:
        raise VaaniError("This recording is already finished.", "finished", 400)

    def mutate(current: dict) -> None:
        current["cancel_requested"] = True

    return json_ok(public_job(store.update(job_id, mutate)))


@app.post("/api/jobs/{job_id}/retry")
def retry_job(job_id: str):
    job = store.load(job_id)
    if job["status"] != "failed":
        raise VaaniError("This recording is not waiting to be tried again.", "busy", 409)
    if (job.get("error") or {}).get("code") == "too_long":
        raise VaaniError(job["error"]["message"], "too_long", 400)

    def mutate(current: dict) -> None:
        current["cancel_requested"] = False
        current["error"] = None
        current["status"] = "queued"
        for name in ("preparing", "audio", "transcript", "ready"):
            if current["steps"][name]["state"] == "failed":
                current["steps"][name] = _blank_step()

    updated = store.update(job_id, mutate)
    if not get_settings().testing:
        worker.submit(job_id)
    return json_ok(public_job(updated))


@app.post("/api/jobs/{job_id}/audio")
async def attach_audio(job_id: str, file: UploadFile = File(...)):
    job = store.load(job_id)
    if job["status"] in {"preparing", "audio", "transcript"}:
        raise VaaniError("Stop the job before uploading a file.", "busy", 409)
    if job["steps"]["audio"]["state"] == "done":
        raise VaaniError(
            "This job already has an MP3. Try the transcript again, or start a new recording.",
            "audio_exists",
            400,
        )
    suffix = _suffix(file)
    directory = store.job_dir(job_id)
    for old in directory.glob("upload.*"):
        old.unlink()
    dest = directory / f"upload{suffix}"
    await save_stream(file, dest, get_settings().max_upload_mb * 1024 * 1024)
    try:
        duration = int(round(await asyncio.to_thread(media.probe_duration, dest)))
        allowed, code, message = assess(
            {"duration": duration, "live_status": "not_live"},
            get_settings().max_duration_seconds,
        )
        if not allowed:
            raise VaaniError(message or "This file cannot be used.", code or "too_long", 400)
    except Exception:
        dest.unlink(missing_ok=True)
        raise
    _clear_outputs(directory)
    stem = Path(file.filename or "recording").stem[:180]

    def mutate(current: dict, duration=duration, stem=stem) -> None:
        current["source"]["duration"] = duration
        if not current["source"].get("title"):
            current["source"]["title"] = stem
        for name in ("audio", "transcript", "ready"):
            current["steps"][name] = _blank_step()
        if current["steps"]["preparing"]["state"] != "done":
            current["steps"]["preparing"] = {
                "state": "done",
                "detail": "Using the uploaded recording",
                "started_at": current["steps"]["preparing"].get("started_at"),
                "ended_at": current["steps"]["preparing"].get("ended_at"),
            }
        current["status"] = "queued"
        current["error"] = None
        current["cancel_requested"] = False
        for key in ("audio", "transcript", "transcript_en", "turns"):
            current["artifacts"].pop(key, None)

    updated = store.update(job_id, mutate)
    if not get_settings().testing:
        worker.submit(job_id)
    return json_ok(public_job(updated))


@app.delete("/api/jobs/{job_id}")
def remove_job(job_id: str):
    store.delete(job_id)
    return json_ok({"ok": True})


def _artifact(job_id: str, key: str) -> tuple[dict, Path]:
    job = store.load(job_id)
    name = (job.get("artifacts") or {}).get(key)
    if not name:
        raise VaaniError("That file is not ready.", "not_ready", 404)
    path = store.job_dir(job_id) / name
    if not path.is_file():
        raise VaaniError("That file is not ready.", "not_ready", 404)
    return job, path


@app.get("/api/jobs/{job_id}/thumbnail")
def job_thumbnail(job_id: str, download: int = 0):
    job, path = _artifact(job_id, "thumbnail")
    title = (job.get("source") or {}).get("title") or "thumbnail"
    media_type = _IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
    return FileResponse(
        path,
        media_type=media_type,
        filename=safe_download_name(title, path.suffix or ".jpg") if download else None,
        content_disposition_type="attachment",
    )


@app.get("/api/jobs/{job_id}/audio")
def job_audio(job_id: str, download: int = 0):
    job, path = _artifact(job_id, "audio")
    title = (job.get("source") or {}).get("title") or "audio"
    return FileResponse(
        path,
        media_type="audio/mpeg",
        filename=safe_download_name(title, ".mp3"),
        content_disposition_type="attachment" if download else "inline",
    )


@app.get("/api/jobs/{job_id}/transcript")
def job_transcript(job_id: str, version: str = "spoken", download: int = 1):
    key = "transcript_en" if version == "en" else "transcript"
    job, path = _artifact(job_id, key)
    title = (job.get("source") or {}).get("title") or "transcript"
    filename = safe_download_name(f"{title} transcript", "txt")
    if version == "en":
        filename = safe_download_name(f"{title} english", "txt")
    return FileResponse(
        path,
        media_type="text/plain; charset=utf-8",
        filename=filename if download else None,
        content_disposition_type="attachment" if download else "inline",
    )


app.mount("/static", StaticFiles(directory=STATIC), name="static")
