from __future__ import annotations

import json
import re
import shutil
import uuid
import zipfile
from pathlib import Path

import numpy as np
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from flexiforge.geometry import (
    analyse_mesh,
    choose_cut_normal,
    export_forge_package,
    load_mesh,
    rank_joint_candidates,
    repair_mesh,
    suggest_joint_locations,
)
from flexiforge.schemas import (
    AnalyseRequest,
    ApprovalTokenRequest,
    ForgeRequest,
    ShireCreateJobRequest,
    ShireSelectSuggestionRequest,
)
from flexiforge.shire_bridge import ShireGateError, ShireJobStore

ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
MODELS = DATA / "models"
EXPORTS = DATA / "exports"
SHIRE_JOBS = DATA / "shire_jobs"
FRONTEND = ROOT / "frontend" / "dist"
for folder in (MODELS, EXPORTS, SHIRE_JOBS):
    folder.mkdir(parents=True, exist_ok=True)

app = FastAPI(title="FlexiForge", version="0.2.0")
shire_jobs = ShireJobStore(SHIRE_JOBS, MODELS, EXPORTS)


def safe_name(value: str) -> str:
    stem = Path(value).stem
    cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._")
    return cleaned[:80] or "model"


def model_path(model_id: str) -> Path:
    path = MODELS / f"{model_id}.stl"
    if not path.exists():
        raise HTTPException(status_code=404, detail="Model not found.")
    return path


@app.get("/api/health")
def health() -> dict:
    return {
        "ok": True,
        "name": "FlexiForge",
        "version": "0.2.0",
        "shire_bridge": True,
        "approval_gates": ["design", "export"],
        "default_port": 8876,
    }


@app.post("/api/upload")
async def upload_stl(file: UploadFile = File(...)) -> dict:
    if not file.filename or not file.filename.lower().endswith(".stl"):
        raise HTTPException(status_code=400, detail="Please upload an STL file.")
    raw = await file.read()
    if len(raw) > 150 * 1024 * 1024:
        raise HTTPException(status_code=413, detail="The MVP upload limit is 150 MB.")
    try:
        mesh = load_mesh(raw)
        mesh, notes = repair_mesh(mesh)
        original_centroid = mesh.centroid.copy()
        mesh.apply_translation(-original_centroid)
        notes.append(
            "Centred the model for consistent click-to-joint coordinates "
            f"(source offset: {original_centroid[0]:.3f}, {original_centroid[1]:.3f}, {original_centroid[2]:.3f} mm)."
        )
        analysis = analyse_mesh(mesh, notes)
    except Exception as exc:
        raise HTTPException(status_code=422, detail=f"Unable to process STL: {exc}") from exc

    model_id = uuid.uuid4().hex
    path = MODELS / f"{model_id}.stl"
    mesh.export(path)
    metadata = {"original_name": file.filename, "safe_name": safe_name(file.filename)}
    (MODELS / f"{model_id}.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
    return {
        "model_id": model_id,
        "model_url": f"/api/models/{model_id}",
        "original_name": file.filename,
        "analysis": analysis.__dict__,
    }


@app.get("/api/models/{model_id}")
def get_model(model_id: str):
    return FileResponse(model_path(model_id), media_type="model/stl", filename=f"{model_id}.stl")


@app.post("/api/analyse-joint")
def analyse_joint(payload: AnalyseRequest) -> dict:
    mesh = load_mesh(model_path(payload.model_id))
    point = np.asarray(payload.joint.point.as_list(), dtype=float)
    surface = None
    if payload.joint.surface_normal:
        surface = np.asarray(payload.joint.surface_normal.as_list(), dtype=float)
    normal = choose_cut_normal(mesh, point, surface)
    candidates = rank_joint_candidates(mesh, point, payload.joint.movement, payload.joint.clearance_mm)
    if payload.joint.requested_type != "auto":
        candidates.sort(key=lambda c: c["type"] != payload.joint.requested_type)
    return {
        "cut_normal": {"x": float(normal[0]), "y": float(normal[1]), "z": float(normal[2])},
        "candidates": candidates,
        "warning": None if mesh.is_watertight else "The repaired source remains non-watertight; boolean reliability may be reduced.",
    }


@app.post("/api/forge")
def forge(payload: ForgeRequest) -> dict:
    path = model_path(payload.model_id)
    mesh = load_mesh(path)
    mesh, _ = repair_mesh(mesh)
    point = np.asarray(payload.joint.point.as_list(), dtype=float)
    surface = None
    if payload.joint.surface_normal:
        surface = np.asarray(payload.joint.surface_normal.as_list(), dtype=float)
    normal = choose_cut_normal(mesh, point, surface)
    candidates = rank_joint_candidates(mesh, point, payload.joint.movement, payload.joint.clearance_mm)
    joint_type = payload.joint.requested_type
    if joint_type == "auto":
        joint_type = candidates[0]["type"]
    try:
        result = export_forge_package(
            output_dir=EXPORTS,
            source_name=safe_name(payload.output_name),
            mesh=mesh,
            point=point,
            normal=normal,
            joint_type=joint_type,
            clearance=payload.joint.clearance_mm,
            layout_gap=payload.layout_gap_mm,
        )
    except Exception as exc:
        raise HTTPException(status_code=422, detail=f"Forge failed: {exc}") from exc

    package_dir: Path = result.pop("directory")
    zip_path = EXPORTS / f"{result['package_id']}.zip"
    with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for file_path in package_dir.iterdir():
            archive.write(file_path, arcname=file_path.name)
    result["download_url"] = f"/api/download/{result['package_id']}"
    result["preview_url"] = f"/api/export-file/{result['package_id']}/{result['files']['assembled']}"
    return result


@app.get("/api/download/{package_id}")
def download_package(package_id: str):
    path = EXPORTS / f"{package_id}.zip"
    if not path.exists():
        raise HTTPException(status_code=404, detail="Export package not found.")
    return FileResponse(path, media_type="application/zip", filename=f"FlexiForge_{package_id}.zip")


@app.get("/api/export-file/{package_id}/{filename}")
def export_file(package_id: str, filename: str):
    if Path(filename).name != filename:
        raise HTTPException(status_code=400, detail="Invalid filename.")
    path = EXPORTS / package_id / filename
    if not path.exists():
        raise HTTPException(status_code=404, detail="Export file not found.")
    return FileResponse(path, media_type="model/stl", filename=filename)



@app.get("/api/suggest-joints/{model_id}")
def suggest_joints(model_id: str, movement: str = "auto", clearance_mm: float = 0.35, count: int = 5) -> dict:
    mesh = load_mesh(model_path(model_id))
    return {"suggestions": suggest_joint_locations(mesh, movement, clearance_mm, max(1, min(count, 9)))}


@app.post("/api/shire/jobs")
def shire_create_job(payload: ShireCreateJobRequest) -> dict:
    try:
        return shire_jobs.create_job(
            model_id=payload.model_id,
            project_name=payload.project_name,
            intent=payload.intent,
            movement=payload.movement,
            clearance_mm=payload.clearance_mm,
            max_suggestions=payload.max_suggestions,
        )
    except (FileNotFoundError, ShireGateError, ValueError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.get("/api/shire/jobs/{job_id}")
def shire_get_job(job_id: str) -> dict:
    try:
        return shire_jobs.get(job_id)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/select")
def shire_select_job(job_id: str, payload: ShireSelectSuggestionRequest) -> dict:
    try:
        return shire_jobs.select_suggestion(job_id, payload.suggestion_id, payload.requested_type)
    except (FileNotFoundError, ShireGateError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/request-design-approval")
def shire_request_design_approval(job_id: str) -> dict:
    try:
        return {"job_id": job_id, "approval_token": shire_jobs.approval_token(job_id, "design")}
    except (FileNotFoundError, ShireGateError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/approve-design")
def shire_approve_design(job_id: str, payload: ApprovalTokenRequest) -> dict:
    try:
        return shire_jobs.approve(job_id, "design", payload.token)
    except (FileNotFoundError, ShireGateError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/forge")
def shire_forge_job(job_id: str) -> dict:
    try:
        return shire_jobs.forge(job_id)
    except (FileNotFoundError, ShireGateError, ValueError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.get("/api/shire/jobs/{job_id}/preview")
def shire_preview_job(job_id: str):
    try:
        job = shire_jobs.get(job_id)
        if not job.get("forge"):
            raise ShireGateError("This job has not been forged yet.")
        filename = job["forge"]["files"]["assembled"]
        path = Path(job["forge"]["directory"]) / filename
        if not path.exists():
            raise ShireGateError("Forged preview file is missing.")
        return FileResponse(path, media_type="model/stl", filename=filename)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except ShireGateError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/validate")
def shire_validate_job(job_id: str) -> dict:
    try:
        return shire_jobs.validate(job_id)
    except (FileNotFoundError, ShireGateError, ValueError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/request-export-approval")
def shire_request_export_approval(job_id: str) -> dict:
    try:
        return {"job_id": job_id, "approval_token": shire_jobs.approval_token(job_id, "export")}
    except (FileNotFoundError, ShireGateError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/approve-export")
def shire_approve_export(job_id: str, payload: ApprovalTokenRequest) -> dict:
    try:
        return shire_jobs.approve(job_id, "export", payload.token)
    except (FileNotFoundError, ShireGateError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/shire/jobs/{job_id}/export")
def shire_export_job(job_id: str) -> dict:
    try:
        return shire_jobs.export_to_vault(job_id)
    except (FileNotFoundError, ShireGateError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc


if FRONTEND.exists():
    app.mount("/", StaticFiles(directory=FRONTEND, html=True), name="frontend")
else:
    @app.get("/")
    def frontend_missing() -> dict:
        return {"message": "Frontend has not been built. Run npm install && npm run build in frontend/."}
