from __future__ import annotations

import asyncio
import logging
import os
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Annotated, Any, Literal
from urllib.parse import urlparse
from uuid import uuid4

from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Scope

from resume_agent.agent import (
    build_profile,
    load_profile,
    revise_tailored_resume,
    save_json,
    tailor_resume,
)
from resume_agent.documents import DocumentError, read_document_bytes
from resume_agent.llm import LLMError, OpenAILLM
from resume_agent.models import CareerProfile, TailoringPackage
from resume_agent.render import render_ohmycv_resume, render_report, render_resume
from resume_agent.web import JobPageError, fetch_job_page

PACKAGE_DIR = Path(__file__).resolve().parent
PROJECT_DIR = PACKAGE_DIR.parents[1]
STATIC_DIR = PACKAGE_DIR / "static"
DEFAULT_CV_DIST = PROJECT_DIR / "vendor/oh-my-cv/site/.output/public"
DEFAULT_PROFILE = Path(".resume-agent/profile.json")
DEFAULT_OUTPUT = Path("output")
DOWNLOADS = {
    "resume.md": "text/markdown",
    "resume-ohmycv.md": "text/markdown",
    "resume-audited.md": "text/markdown",
    "report.md": "text/markdown",
    "tailoring.json": "application/json",
}
TASK_LOGGER = logging.getLogger("resume_agent.tasks")


class TailorRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    job_url: str | None = None
    job_text: str | None = Field(default=None, max_length=200_000)
    tailoring_strength: int = Field(default=50, ge=0, le=100)
    evidence_mode: Literal["strict", "profile"] = "strict"


class MarkdownProfileRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    markdown: str = Field(min_length=20, max_length=500_000)
    about: str = Field(default="", max_length=100_000)


class MarkdownTailorRequest(MarkdownProfileRequest):
    job_url: str | None = None
    job_text: str | None = Field(default=None, max_length=200_000)
    tailoring_strength: int = Field(default=50, ge=0, le=100)
    evidence_mode: Literal["strict", "profile"] = "strict"


class MarkdownTailorResponse(BaseModel):
    model_config = ConfigDict(extra="forbid")

    profile: CareerProfile
    package: TailoringPackage
    markdown: str


class RevisionRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    message: str = Field(min_length=3, max_length=4_000)


class RevisionResponse(BaseModel):
    model_config = ConfigDict(extra="forbid")

    package: TailoringPackage
    reply: str


class TaskStartResponse(BaseModel):
    model_config = ConfigDict(extra="forbid")

    task_id: str


class TaskStatusResponse(BaseModel):
    model_config = ConfigDict(extra="forbid")

    status: Literal["running", "succeeded", "failed"]
    result: Any = None
    error: str | None = None


class AppSettings(BaseModel):
    model_config = ConfigDict(extra="forbid")

    evidence_guard: bool = True


class SPAStaticFiles(StaticFiles):
    """Serve Nuxt's client fallback for extensionless editor routes."""

    async def get_response(self, path: str, scope: Scope):
        try:
            response = await super().get_response(path, scope)
        except StarletteHTTPException as exc:
            if exc.status_code != 404 or Path(path).suffix:
                raise
            return await super().get_response("200.html", scope)
        if response.status_code == 404 and not Path(path).suffix:
            return await super().get_response("200.html", scope)
        return response


def _provider_name() -> str:
    base_url = os.getenv("RESUME_AGENT_BASE_URL") or os.getenv("OPENAI_BASE_URL")
    if not base_url:
        return "OpenAI"
    return urlparse(base_url).hostname or "Custom provider"


def _save_outputs(
    package: TailoringPackage,
    output_dir: Path,
    evidence_mode: str = "strict",
) -> None:
    output_dir.mkdir(parents=True, exist_ok=True)
    save_json(package, output_dir / "tailoring.json")
    (output_dir / "resume.md").write_text(render_resume(package), encoding="utf-8")
    (output_dir / "resume-audited.md").write_text(
        render_resume(package, include_evidence=True), encoding="utf-8"
    )
    (output_dir / "report.md").write_text(render_report(package), encoding="utf-8")
    (output_dir / "resume-ohmycv.md").write_text(
        render_ohmycv_resume(package, ""),
        encoding="utf-8",
    )
    (output_dir / "evidence-mode.txt").write_text(evidence_mode, encoding="utf-8")


def _load_evidence_mode(output_dir: Path) -> str:
    path = output_dir / "evidence-mode.txt"
    if not path.is_file():
        return "strict"
    mode = path.read_text(encoding="utf-8").strip()
    return mode if mode in {"strict", "profile"} else "strict"


def _load_app_settings(settings_path: Path) -> AppSettings:
    if not settings_path.is_file():
        return AppSettings()
    try:
        return AppSettings.model_validate_json(settings_path.read_text(encoding="utf-8"))
    except Exception:
        TASK_LOGGER.exception("event=settings_load_failed path=%s", settings_path)
        return AppSettings()


def _save_app_settings(settings_path: Path, settings: AppSettings) -> None:
    settings_path.parent.mkdir(parents=True, exist_ok=True)
    settings_path.write_text(settings.model_dump_json(indent=2), encoding="utf-8")


def create_app(
    *,
    profile_path: Path = DEFAULT_PROFILE,
    output_dir: Path = DEFAULT_OUTPUT,
    llm_factory: Callable[[], OpenAILLM] = OpenAILLM,
    cv_dist: Path = DEFAULT_CV_DIST,
    settings_path: Path | None = None,
) -> FastAPI:
    web_app = FastAPI(title="Resume Agent", version="0.1.0")
    web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
    editor_available = cv_dist.is_dir()
    if editor_available:
        web_app.mount("/cv", SPAStaticFiles(directory=cv_dist, html=True), name="cv-editor")
    resolved_settings_path = settings_path or profile_path.parent / "settings.json"
    task_records: dict[str, TaskStatusResponse] = {}
    active_tasks: set[asyncio.Task[None]] = set()

    async def run_task(task_id: str, operation: Awaitable[BaseModel]) -> None:
        TASK_LOGGER.info("task=%s event=start", task_id)
        try:
            result = await operation
            task_records[task_id] = TaskStatusResponse(
                status="succeeded",
                result=result.model_dump(mode="json"),
            )
            TASK_LOGGER.info("task=%s event=success", task_id)
        except HTTPException as exc:
            task_records[task_id] = TaskStatusResponse(
                status="failed",
                error=str(exc.detail),
            )
            TASK_LOGGER.exception("task=%s event=failed", task_id)
        except Exception as exc:
            task_records[task_id] = TaskStatusResponse(
                status="failed",
                error=str(_http_error(exc).detail),
            )
            TASK_LOGGER.exception("task=%s event=failed", task_id)

    def start_task(operation: Awaitable[BaseModel]) -> TaskStartResponse:
        task_id = uuid4().hex
        task_records[task_id] = TaskStatusResponse(status="running")
        task = asyncio.create_task(run_task(task_id, operation))
        active_tasks.add(task)
        task.add_done_callback(active_tasks.discard)
        return TaskStartResponse(task_id=task_id)

    async def build_and_save_profile(
        resume_text: str,
        note_text: str,
    ) -> CareerProfile:
        profile = await run_in_threadpool(
            build_profile,
            llm_factory(),
            resume_text,
            note_text,
        )
        save_json(profile, profile_path)
        return profile

    async def read_profile_sources(
        resume: UploadFile,
        about: str,
        notes: UploadFile | None,
    ) -> tuple[str, str]:
        resume_text = read_document_bytes(resume.filename or "resume.txt", await resume.read())
        note_text = about
        if notes:
            parsed_notes = read_document_bytes(
                notes.filename or "notes.txt",
                await notes.read(),
            )
            note_text = f"{about}\n{parsed_notes}".strip()
        return resume_text, note_text

    @web_app.get("/", include_in_schema=False)
    async def index() -> FileResponse:
        return FileResponse(STATIC_DIR / "index.html")

    @web_app.get("/favicon.ico", include_in_schema=False)
    async def favicon() -> FileResponse:
        return FileResponse(STATIC_DIR / "favicon.ico", media_type="image/x-icon")

    @web_app.get("/api/status")
    async def status() -> dict[str, object]:
        api_key = os.getenv("RESUME_AGENT_API_KEY") or os.getenv("OPENAI_API_KEY")
        base_url = os.getenv("RESUME_AGENT_BASE_URL") or os.getenv("OPENAI_BASE_URL")
        model = os.getenv("RESUME_AGENT_MODEL") or (None if base_url else "gpt-5.6-terra")
        configured = bool(
            api_key and model and not api_key.lower().startswith("replace-with-")
        )
        return {
            "configured": configured,
            "provider": _provider_name(),
            "model": model,
            "api_style": os.getenv("RESUME_AGENT_API_STYLE", "auto"),
            "profile_exists": profile_path.is_file(),
            "editor_available": editor_available,
            "evidence_guard": _load_app_settings(resolved_settings_path).evidence_guard,
        }

    @web_app.get("/api/settings", response_model=AppSettings)
    async def get_settings() -> AppSettings:
        return _load_app_settings(resolved_settings_path)

    @web_app.put("/api/settings", response_model=AppSettings)
    async def update_settings(settings: AppSettings) -> AppSettings:
        _save_app_settings(resolved_settings_path, settings)
        TASK_LOGGER.info(
            "event=settings_updated evidence_guard=%s",
            settings.evidence_guard,
        )
        return settings

    @web_app.get("/api/models")
    async def models() -> dict[str, list[str]]:
        try:
            model_ids = await run_in_threadpool(llm_factory().list_models)
            return {"models": model_ids}
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.get("/api/tasks/{task_id}", response_model=TaskStatusResponse)
    async def task_status(task_id: str) -> TaskStatusResponse:
        record = task_records.get(task_id)
        if not record:
            raise HTTPException(status_code=404, detail="Background task not found.")
        return record

    @web_app.get("/api/profile", response_model=CareerProfile)
    async def get_profile() -> CareerProfile:
        if not profile_path.is_file():
            raise HTTPException(status_code=404, detail="Build your career profile first.")
        try:
            return load_profile(profile_path)
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.post("/api/profile", response_model=CareerProfile)
    async def create_profile(
        resume: Annotated[UploadFile, File()],
        about: Annotated[str, Form()] = "",
        notes: Annotated[UploadFile | None, File()] = None,
    ) -> CareerProfile:
        try:
            resume_text, note_text = await read_profile_sources(resume, about, notes)
            return await build_and_save_profile(resume_text, note_text)
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.post("/api/profile/start", response_model=TaskStartResponse)
    async def start_profile(
        resume: Annotated[UploadFile, File()],
        about: Annotated[str, Form()] = "",
        notes: Annotated[UploadFile | None, File()] = None,
    ) -> TaskStartResponse:
        try:
            resume_text, note_text = await read_profile_sources(resume, about, notes)
            return start_task(build_and_save_profile(resume_text, note_text))
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.post("/api/markdown/profile", response_model=CareerProfile)
    async def create_profile_from_markdown(
        request: MarkdownProfileRequest,
    ) -> CareerProfile:
        try:
            profile = await run_in_threadpool(
                build_profile, llm_factory(), request.markdown, request.about
            )
            save_json(profile, profile_path)
            return profile
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.post("/api/markdown/profile/start", response_model=TaskStartResponse)
    async def start_profile_from_markdown(
        request: MarkdownProfileRequest,
    ) -> TaskStartResponse:
        return start_task(create_profile_from_markdown(request))

    @web_app.post("/api/tailor", response_model=TailoringPackage)
    async def tailor(request: TailorRequest) -> TailoringPackage:
        if not profile_path.is_file():
            raise HTTPException(status_code=409, detail="Build your career profile first.")
        if bool(request.job_url) == bool(request.job_text):
            raise HTTPException(
                status_code=422,
                detail="Provide exactly one job URL or pasted job description.",
            )
        try:
            app_settings = _load_app_settings(resolved_settings_path)
            evidence_mode = (
                request.evidence_mode if app_settings.evidence_guard else "profile"
            )
            profile = load_profile(profile_path)
            if request.job_url:
                job_text = await run_in_threadpool(fetch_job_page, request.job_url)
            else:
                job_text = (request.job_text or "").strip()
                if len(job_text) < 80:
                    raise ValueError("The pasted job description is too short.")
            package = await run_in_threadpool(
                tailor_resume,
                llm_factory(),
                profile,
                job_text,
                request.tailoring_strength,
                evidence_mode,
            )
            _save_outputs(package, output_dir, evidence_mode)
            return package
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.post("/api/tailor/start", response_model=TaskStartResponse)
    async def start_tailor(request: TailorRequest) -> TaskStartResponse:
        return start_task(tailor(request))

    @web_app.post("/api/markdown/tailor", response_model=MarkdownTailorResponse)
    async def tailor_markdown(request: MarkdownTailorRequest) -> MarkdownTailorResponse:
        if bool(request.job_url) == bool(request.job_text):
            raise HTTPException(
                status_code=422,
                detail="Provide exactly one job URL or pasted job description.",
            )
        try:
            app_settings = _load_app_settings(resolved_settings_path)
            evidence_mode = (
                request.evidence_mode if app_settings.evidence_guard else "profile"
            )
            llm = llm_factory()
            profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about)
            save_json(profile, profile_path)

            if request.job_url:
                job_text = await run_in_threadpool(fetch_job_page, request.job_url)
            else:
                job_text = (request.job_text or "").strip()
                if len(job_text) < 80:
                    raise ValueError("The pasted job description is too short.")

            package = await run_in_threadpool(
                tailor_resume,
                llm,
                profile,
                job_text,
                request.tailoring_strength,
                evidence_mode,
            )
            _save_outputs(package, output_dir, evidence_mode)
            markdown = render_ohmycv_resume(package, request.markdown)
            (output_dir / "resume-ohmycv.md").write_text(markdown, encoding="utf-8")
            return MarkdownTailorResponse(
                profile=profile,
                package=package,
                markdown=markdown,
            )
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.post("/api/markdown/tailor/start", response_model=TaskStartResponse)
    async def start_tailor_markdown(
        request: MarkdownTailorRequest,
    ) -> TaskStartResponse:
        return start_task(tailor_markdown(request))

    @web_app.post("/api/revise", response_model=RevisionResponse)
    async def revise(request: RevisionRequest) -> RevisionResponse:
        package_path = output_dir / "tailoring.json"
        if not profile_path.is_file() or not package_path.is_file():
            raise HTTPException(
                status_code=409,
                detail="Create a tailored resume before using revision chat.",
            )
        try:
            profile = load_profile(profile_path)
            current = TailoringPackage.model_validate_json(
                package_path.read_text(encoding="utf-8")
            )
            evidence_mode = _load_evidence_mode(output_dir)
            if not _load_app_settings(resolved_settings_path).evidence_guard:
                evidence_mode = "profile"
            package = await run_in_threadpool(
                revise_tailored_resume,
                llm_factory(),
                profile,
                current,
                request.message,
                evidence_mode,
            )
            _save_outputs(package, output_dir, evidence_mode)
            reply = _revision_reply(package)
            return RevisionResponse(package=package, reply=reply)
        except Exception as exc:
            raise _http_error(exc) from exc

    @web_app.post("/api/revise/start", response_model=TaskStartResponse)
    async def start_revise(request: RevisionRequest) -> TaskStartResponse:
        return start_task(revise(request))

    @web_app.get("/api/download/{filename}")
    async def download(filename: str) -> FileResponse:
        media_type = DOWNLOADS.get(filename)
        if not media_type:
            raise HTTPException(status_code=404, detail="Unknown download.")
        path = output_dir / filename
        if not path.is_file():
            raise HTTPException(status_code=404, detail="Create a tailored resume first.")
        return FileResponse(path, media_type=media_type, filename=filename)

    @web_app.get("/health", response_class=PlainTextResponse)
    async def health() -> str:
        return "ok"

    return web_app


def _revision_reply(package: TailoringPackage) -> str:
    changes = package.changes_made[-2:]
    if changes:
        reply = "Updated and re-audited the resume. " + " ".join(changes)
    else:
        reply = "Updated and re-audited the resume using your request."
    if package.warnings:
        reply += f" I kept {len(package.warnings)} unsupported item(s) out of the resume."
    return reply


def _http_error(exc: Exception) -> HTTPException:
    if isinstance(exc, LLMError):
        return HTTPException(status_code=502, detail=str(exc))
    if isinstance(exc, (DocumentError, JobPageError, ValueError)):
        return HTTPException(status_code=400, detail=str(exc))
    return HTTPException(
        status_code=502,
        detail="The configured LLM provider could not complete the request.",
    )


app = create_app()
