"""FastAPI app for the MedSegAgent chat-based segmentation demo.

Run from the repo root (so OAI_CONFIG_LIST/dataset/ paths in config.py and
plain package imports resolve correctly):

    cd /home/nlab/MedSegAgent
    source .venv/bin/activate
    uvicorn webapp.backend.main:app --host 0.0.0.0 --port 8000 --reload
"""

import logging
import shutil
import time
import uuid

from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from . import config, jobs
from .label_matcher import resolve_target
from .mask_extractor import extract_single_label
from .modality_detector import detect_modality
from .query_parser import parse_query
from .segmentation_runner import GPU_LOCK, run_totalsegmentator

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("medsegagent_webapp")

app = FastAPI(title="MedSegAgent Demo")

config.DATA_DIR.mkdir(parents=True, exist_ok=True)


class NoCacheStaticFiles(StaticFiles):
    """StaticFiles with caching disabled.

    Without this, browsers can skip revalidating index.html/app.js/style.css
    on a normal reload (heuristic caching kicks in when no Cache-Control
    header is set) and silently keep serving a stale build after an edit --
    a real footgun during active development of this demo. /static (the
    frontend source) always uses this; /data (per-job NIfTI results) does
    not need it since a given job_id's files never change after creation.
    """

    def file_response(self, *args, **kwargs):
        response = super().file_response(*args, **kwargs)
        response.headers["Cache-Control"] = "no-store"
        return response


app.mount("/data", StaticFiles(directory=str(config.DATA_DIR)), name="data")
app.mount("/static", NoCacheStaticFiles(directory=str(config.FRONTEND_DIR)), name="static")


@app.get("/")
def index():
    return FileResponse(str(config.FRONTEND_DIR / "index.html"), headers={"Cache-Control": "no-store"})


def _cleanup_old_job_dirs():
    """Delete job directories older than config.JOB_MAX_AGE_SECONDS.
    Called on every new /api/chat request; cheap for a handful of jobs."""
    now = time.time()
    if not config.DATA_DIR.exists():
        return
    for entry in config.DATA_DIR.iterdir():
        if entry.name == "samples" or not entry.is_dir():
            continue
        try:
            if now - entry.stat().st_mtime > config.JOB_MAX_AGE_SECONDS:
                shutil.rmtree(entry, ignore_errors=True)
        except FileNotFoundError:
            pass


def _run_segmentation_job(job_id, input_path, seg_path, mask_path, modality, label_id):
    """Background task: waits for the GPU lock, runs inference, extracts just
    the target label into its own file, and updates job status.

    Status transitions: queued -> running -> done | error. The "queued"
    state persists (accurately) while waiting on GPU_LOCK for a second
    concurrent request, not just while waiting for FastAPI to schedule the
    background task.

    seg_path ends up holding TotalSegmentator's full multi-label output
    (every structure it found); mask_path holds a derived single-label mask
    containing only `label_id` -- see mask_extractor.py for why the frontend
    is served mask_path, not seg_path, as the overlay.
    """
    try:
        jobs.update_job(job_id, status="queued", message="GPUの空き待ちです...")
        with GPU_LOCK:
            jobs.update_job(job_id, status="running", message="セグメンテーション実行中...")
            run_totalsegmentator(input_path, seg_path, modality)
        extract_single_label(seg_path, label_id, mask_path)
        jobs.update_job(job_id, status="done", message="完了しました。")
    except Exception as e:  # noqa: BLE001 - surface any failure to the UI
        logger.exception("segmentation job %s failed", job_id)
        jobs.update_job(job_id, status="error", message=f"推論に失敗しました: {e}")


@app.post("/api/chat")
async def chat(
    background_tasks: BackgroundTasks,
    query: str = Form(...),
    file: UploadFile = File(...),
):
    _cleanup_old_job_dirs()

    filename = file.filename or ""
    if not filename.lower().endswith(config.ALLOWED_UPLOAD_EXTENSIONS):
        raise HTTPException(
            status_code=400,
            detail="対応していないファイル形式です（.nii.gz または .nii のみアップロードできます）。",
        )

    job_id = uuid.uuid4().hex
    job_dir = config.DATA_DIR / job_id
    job_dir.mkdir(parents=True, exist_ok=True)
    input_path = job_dir / "input.nii.gz"

    size = 0
    try:
        with open(input_path, "wb") as f:
            while True:
                chunk = await file.read(1024 * 1024)
                if not chunk:
                    break
                size += len(chunk)
                if size > config.MAX_UPLOAD_BYTES:
                    raise HTTPException(
                        status_code=413,
                        detail="ファイルサイズが大きすぎます（上限1GB）。",
                    )
                f.write(chunk)
    except HTTPException:
        shutil.rmtree(job_dir, ignore_errors=True)
        raise

    try:
        parsed = parse_query(query)
    except Exception as e:
        shutil.rmtree(job_dir, ignore_errors=True)
        raise HTTPException(status_code=400, detail=f"クエリを解析できませんでした: {e}")

    target = parsed["target"]

    # Modality routing is decided from the uploaded volume's own voxel
    # intensities, NOT from the LLM's parsed query text -- a query like
    # "segment the liver" very often won't mention the modality at all even
    # though the file itself is unambiguously a CT or MRI. See
    # modality_detector.py for why. parsed["modality"] (e.g. "not specified",
    # or whatever the user's wording implied) is kept only for display.
    try:
        modality = detect_modality(input_path)
    except Exception as e:
        shutil.rmtree(job_dir, ignore_errors=True)
        raise HTTPException(status_code=400, detail=f"画像のモダリティを判定できませんでした: {e}")

    try:
        resolved_label = resolve_target(target, modality, model=config.MODEL_NAME)
    except Exception as e:
        shutil.rmtree(job_dir, ignore_errors=True)
        raise HTTPException(status_code=400, detail=str(e))

    seg_path = job_dir / "segmentation.nii.gz"
    mask_path = job_dir / "target_mask.nii.gz"
    jobs.create_job(
        job_id,
        status="queued",
        message="GPUの空き待ちです...",
        modality=modality,
        modality_from_query=parsed.get("modality"),
        target=target,
        resolved_label=resolved_label,
    )
    background_tasks.add_task(
        _run_segmentation_job,
        job_id,
        input_path,
        seg_path,
        mask_path,
        modality,
        resolved_label["id"],
    )

    return {
        "job_id": job_id,
        "modality": modality,
        "modality_from_query": parsed.get("modality"),
        "target": target,
        "resolved_label": resolved_label,
        "status": "queued",
    }


@app.get("/api/jobs/{job_id}")
def get_job_status(job_id: str):
    job = jobs.get_job(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="ジョブが見つかりません。")

    result = None
    if job["status"] == "done":
        result = {
            "input_url": f"/data/{job_id}/input.nii.gz",
            # target_mask.nii.gz (not the raw multi-label segmentation.nii.gz)
            # -- contains only 0 and label_id, see mask_extractor.py.
            "seg_url": f"/data/{job_id}/target_mask.nii.gz",
            "label_id": job["resolved_label"]["id"],
            "label_name": job["resolved_label"]["name"],
        }

    return {
        "status": job["status"],
        "message": job.get("message", ""),
        "modality": job.get("modality"),
        "target": job.get("target"),
        "resolved_label": job.get("resolved_label"),
        "result": result,
    }
