"""FastAPI web application for Rappture tools."""

from __future__ import annotations

import asyncio
import dataclasses
import json
import os
import uuid
from pathlib import Path

import mimetypes

from fastapi import FastAPI, File, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from .xml_parser import ToolDef, parse_tool_xml
from .simulator import RunHistory, run_simulation, run_uq_simulation, build_driver_xml_string
from .encoding import to_data_uri, is_encoded

# ─── Global state ────────────────────────────────────────────────────────────

_tool_def: ToolDef | None = None
_tool_xml_path: str = ""

# Run history (in-memory + optional disk cache)
_history = RunHistory()

# Current simulation session (reset on each Simulate click)
_session: dict = {
    "job_id": None,
    "status": "idle",     # idle | running | done | error
    "inputs": {},
    "outputs": {},
    "log": "",
    "progress": {"percent": None, "message": ""},
    "run_id": None,
    "run_num": None,
    "cached": False,
}

# Active WebSocket connections for live streaming
_ws_clients: list[WebSocket] = []

# Currently running subprocess (set by simulator, used by /stop)
_running_process: asyncio.subprocess.Process | None = None

# Server's own URL (set by CLI for library mode)
_server_url: str = ""
_use_library_mode: bool = False
_use_cache: bool = True
_base_path: str = ""
_is_nanohub: bool = False
_nanohub_support_url: str = ""
_nanohub_terminate_url: str = ""
_nanohub_about_url: str = ""
_nanohub_questions_url: str = ""

APP_DIR = Path(__file__).parent

app = FastAPI(title="Rappture2Web")
app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static")

templates = Jinja2Templates(directory=str(APP_DIR / "templates"))
templates.env.globals["to_data_uri"] = to_data_uri
templates.env.globals["is_encoded"] = is_encoded


def _strip_units(value: str) -> str:
    """Strip trailing unit suffix from a Rappture value string.

    '300K' → '300', '2eV' → '2', '-5eV' → '-5', '300' → '300'.
    """
    import re
    if not value:
        return value
    m = re.match(r'^([+-]?\d+\.?\d*(?:[eE][+-]?\d+)?)', value.strip())
    return m.group(1) if m else value


templates.env.filters["strip_units"] = _strip_units


# ─── Setup helpers ────────────────────────────────────────────────────────────

def set_tool(xml_path: str, cache_dir: str | None = None,
             server_url: str = "", use_library_mode: bool = False,
             use_cache: bool = True, base_path: str = "",
             is_nanohub: bool = False,
             nanohub_support_url: str = "",
             nanohub_terminate_url: str = "",
             nanohub_about_url: str = "",
             nanohub_questions_url: str = ""):
    """Configure the tool and start-up options."""
    global _tool_def, _tool_xml_path, _history, _server_url
    global _use_library_mode, _use_cache, _base_path
    global _is_nanohub
    global _nanohub_support_url, _nanohub_terminate_url
    global _nanohub_about_url, _nanohub_questions_url

    _tool_xml_path = str(Path(xml_path).resolve())

    _use_library_mode = use_library_mode
    _use_cache = use_cache
    _base_path = base_path.rstrip("/")
    _is_nanohub = bool(is_nanohub)
    _nanohub_support_url = (nanohub_support_url or "").strip()
    _nanohub_terminate_url = (nanohub_terminate_url or "").strip()
    _nanohub_about_url = (nanohub_about_url or "").strip()
    _nanohub_questions_url = (nanohub_questions_url or "").strip()

    _tool_def = parse_tool_xml(_tool_xml_path, base_path=_base_path)
    _server_url = server_url

    _history = RunHistory(cache_dir=cache_dir)
    if cache_dir:
        _history.load_from_disk()


# ─── Broadcast helpers ────────────────────────────────────────────────────────

async def _broadcast(message: dict):
    dead = []
    for ws in _ws_clients:
        try:
            await ws.send_json(message)
        except Exception:
            dead.append(ws)
    for ws in dead:
        _ws_clients.remove(ws)


def _build_inputs_report(input_values: dict) -> dict:
    """Build a table output summarising the inputs used for a simulation run.

    Uses _tool_def to map Rappture paths to human-readable labels.
    Returns a dict in the rappture2web table format.
    """
    # Build path → label map from tool definition
    label_map: dict[str, str] = {}
    if _tool_def is not None:
        def _collect(nodes):
            for node in nodes:
                if node.path and node.label:
                    label_map[node.path] = node.label
                if node.children:
                    _collect(node.children)
        _collect(_tool_def.inputs)

    rows = []
    for path, value in sorted(input_values.items()):
        # Skip very long values (e.g. uploaded XML blobs)
        str_val = str(value) if value is not None else ""
        if len(str_val) > 500:
            str_val = str_val[:497] + "..."
        label = label_map.get(path, path)
        rows.append([label, str_val])

    return {
        "type": "table",
        "label": "Simulation Inputs",
        "about": {"label": "Simulation Inputs"},
        "columns": [
            {"label": "Parameter", "units": ""},
            {"label": "Value", "units": ""},
        ],
        "rows": rows,
    }


def _build_driver_xml_output(input_values: dict) -> dict | None:
    """Build a string output containing the driver XML for the given inputs."""
    if not _tool_xml_path:
        return None
    try:
        xml_str = build_driver_xml_string(_tool_xml_path, input_values)
        return {
            "type": "string",
            "label": "Driver XML",
            "about": {"label": "Driver XML"},
            "current": xml_str,
        }
    except Exception:
        return None


def _serialize(obj):
    if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
        return {k: _serialize(v) for k, v in dataclasses.asdict(obj).items()}
    if isinstance(obj, list):
        return [_serialize(v) for v in obj]
    if isinstance(obj, dict):
        return {k: _serialize(v) for k, v in obj.items()}
    return obj


# ─── Tool static files ───────────────────────────────────────────────────────

@app.get("/tool-files/{file_path:path}")
async def tool_static_file(file_path: str):
    """Serve static files from the tool directory (e.g. images in note HTML)."""
    if not _tool_xml_path:
        return Response(status_code=404)
    tool_dir = Path(_tool_xml_path).parent.resolve()
    # "__up__" keeps browser URL normalization from collapsing "/tool-files/../..."
    # while still mapping to a parent-relative filesystem path.
    raw_parts = [p for p in file_path.split("/") if p not in ("", ".")]
    up_count = sum(1 for p in raw_parts if p == "__up__")
    if up_count > 1:
        return Response(status_code=403)
    rel_parts = [".." if p == "__up__" else p for p in raw_parts]
    rel_path = "/".join(rel_parts)
    target = (tool_dir / rel_path).resolve()

    allowed_roots = [tool_dir]
    if up_count:
        allowed_roots.append(tool_dir.parent.resolve())

    allowed = False
    for root in allowed_roots:
        try:
            target.relative_to(root)
            allowed = True
            break
        except ValueError:
            continue
    if not allowed:
        return Response(status_code=403)
    if not target.exists() or not target.is_file():
        return Response(status_code=404)
    return FileResponse(str(target))


# ─── Page ────────────────────────────────────────────────────────────────────

@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
    return Response(status_code=204)


@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    if _tool_def is None:
        return HTMLResponse("<h1>No tool loaded</h1>")
    return templates.TemplateResponse("tool.html", {
        "request": request,
        "tool": _tool_def.tool,
        "inputs": _tool_def.inputs,
        "outputs": _tool_def.outputs,
        "tool_xml_path": _tool_xml_path,
        "base_path": _base_path,
        "is_nanohub": _is_nanohub,
        "nanohub_support_url": _nanohub_support_url,
        "nanohub_terminate_url": _nanohub_terminate_url,
        "nanohub_about_url": _nanohub_about_url,
        "nanohub_questions_url": _nanohub_questions_url,
    })


# ─── Simulate (browser → server) ─────────────────────────────────────────────

@app.post("/simulate")
async def simulate(request: Request):
    """Trigger a simulation from the browser Simulate button."""
    global _session, _running_process

    if _tool_def is None:
        return JSONResponse({"status": "error", "log": "No tool loaded"}, status_code=400)

    data = await request.json()
    input_values = data.get("inputs", {})
    uq_inputs = data.get("uq_inputs", {})  # UQ distribution specs keyed by Rappture path
    job_id = uuid.uuid4().hex[:8]

    _session = {
        "job_id": job_id,
        "status": "running",
        "inputs": input_values,
        "outputs": {},
        "log": "",
        "progress": {"percent": 0, "message": "Simulation started"},
        "run_id": None,
        "run_num": None,
        "cached": False,
    }
    _running_process = None
    await _broadcast({"type": "status", "status": "running", "job_id": job_id})
    await _broadcast({"type": "progress", "percent": 0, "message": "Simulation started"})

    async def _stream_log(text: str):
        _session["log"] += text
        await _broadcast({"type": "log", "text": text})

    async def _stream_output(oid: str, odata: dict):
        _session["outputs"][oid] = odata
        await _broadcast({"type": "output", "id": oid, "data": odata})

    async def _stream_progress(percent: float, message: str):
        _session["progress"] = {"percent": percent, "message": message}
        await _broadcast({"type": "progress", "percent": percent, "message": message})

    def _on_process(proc):
        global _running_process
        _running_process = proc

    try:
        if uq_inputs:
            # UQ mode: run with PUQ
            def _override_inputs(new_inputs: dict):
                """Update session inputs for each collocation point run (library mode)."""
                _session["inputs"] = new_inputs

            result = await run_uq_simulation(
                tool_xml_path=_tool_xml_path,
                input_values=input_values,
                uq_inputs=uq_inputs,
                server_url=_server_url,
                use_library_mode=_use_library_mode,
                history=_history,
                log_callback=_stream_log,
                process_callback=_on_process,
                inputs_override_callback=_override_inputs,
            )
        else:
            result = await run_simulation(
                tool_xml_path=_tool_xml_path,
                input_values=input_values,
                server_url=_server_url,
                use_library_mode=_use_library_mode,
                history=_history,
                use_cache=_use_cache,
                log_callback=_stream_log,
                process_callback=_on_process,
                output_callback=_stream_output,
                progress_callback=_stream_progress,
            )
    except Exception as exc:
        import traceback
        tb = traceback.format_exc()
        print(tb)
        _running_process = None
        _session.update({"status": "error", "log": tb})
        await _broadcast({"type": "status", "status": "error", "log": tb})
        return JSONResponse({"status": "error", "log": tb}, status_code=500)
    _running_process = None

    # In library mode, api_simulate_done already recorded the run with real
    # outputs. Pull run_id/run_num from _session (set by api_simulate_done).
    if _use_library_mode and not result.get("cached"):
        result["outputs"] = _session.get("outputs", {})
        result["log"] = _session.get("log", "")
        result["run_id"] = _session.get("run_id")
        result["run_num"] = _session.get("run_num")

    _session.update({
        "status": result["status"],
        "outputs": result.get("outputs", {}),
        "log": result.get("log", ""),
        "progress": {"percent": 100, "message": "Complete"} if result["status"] == "success"
                    else _session.get("progress", {"percent": None, "message": ""}),
        "run_id": result.get("run_id"),
        "run_num": result.get("run_num"),
        "cached": result.get("cached", False),
    })

    # Inject inputs report and driver XML (only for non-library, non-cached runs —
    # library mode injects them in api_simulate_done; cached runs already have them).
    if not _use_library_mode and not result.get("cached") and result["status"] == "success":
        report = _build_inputs_report(input_values)
        result.setdefault("outputs", {})["__inputs__"] = report
        _session["outputs"]["__inputs__"] = report
        driver_out = _build_driver_xml_output(input_values)
        if driver_out:
            result["outputs"]["__driver_xml__"] = driver_out
            _session["outputs"]["__driver_xml__"] = driver_out

    # In library mode, api_simulate_done already broadcast the done message
    # with the correct outputs.  For cache hits and classic mode, broadcast now.
    if not _use_library_mode or result.get("cached"):
        await _broadcast({
            "type": "done",
            "status": result["status"],
            "outputs": result.get("outputs", {}),
            "log": result.get("log", ""),
            "run_id": result.get("run_id"),
            "run_num": result.get("run_num"),
            "cached": result.get("cached", False),
        })

    return JSONResponse(result)


# ─── Stop (browser → server) ──────────────────────────────────────────────────

@app.post("/stop")
async def stop_simulation():
    """Kill the currently running simulation process."""
    global _running_process, _session
    proc = _running_process
    if proc is not None:
        try:
            proc.kill()
        except Exception:
            pass
        _running_process = None
    if _session.get("status") == "running":
        _session["status"] = "stopped"
        await _broadcast({"type": "status", "status": "stopped"})
        await _broadcast({"type": "done", "status": "stopped", "outputs": {}, "log": _session.get("log", ""),
                          "run_id": None, "run_num": None, "cached": False})
    return JSONResponse({"status": "stopped"})


# ─── Library mode API (used by rp_library in the tool script) ─────────────────

@app.post("/api/simulate/start")
async def api_simulate_start(request: Request):
    """Called by rp_library at the start of a run to obtain a job_id."""
    global _session
    data = await request.json()
    input_values = data.get("inputs", {})
    job_id = uuid.uuid4().hex[:8]

    _session = {
        "job_id": job_id,
        "status": "running",
        "inputs": input_values,
        "outputs": {},
        "log": "",
        "progress": {"percent": 0, "message": "Simulation started"},
        "run_id": None,
        "run_num": None,
        "cached": False,
    }
    await _broadcast({"type": "status", "status": "running", "job_id": job_id})
    await _broadcast({"type": "progress", "percent": 0, "message": "Simulation started"})
    return JSONResponse({"job_id": job_id})


@app.get("/api/inputs")
async def api_get_inputs():
    """Return current input values for the running tool script."""
    return JSONResponse(_session["inputs"])


@app.post("/api/output")
async def api_post_output(request: Request):
    """Receive and broadcast one output item from the tool script."""
    data = await request.json()
    output_id = data.get("id") or data.get("type", "output")
    _session["outputs"][output_id] = data
    await _broadcast({"type": "output", "id": output_id, "data": data})
    return JSONResponse({"ok": True})


@app.post("/api/log")
async def api_post_log(request: Request):
    """Append a log chunk from the tool script."""
    data = await request.json()
    text = data.get("text", "")
    _session["log"] += text
    await _broadcast({"type": "log", "text": text})
    return JSONResponse({"ok": True})


@app.post("/api/progress")
async def api_post_progress(request: Request):
    """Receive and broadcast simulation progress updates."""
    data = await request.json()
    percent_raw = data.get("percent", data.get("pct", 0))
    message = str(data.get("message", "")).strip()
    try:
        percent = float(percent_raw)
    except (TypeError, ValueError):
        percent = 0.0
    percent = max(0.0, min(100.0, percent))

    _session["progress"] = {"percent": percent, "message": message}
    if _session.get("status") != "running":
        _session["status"] = "running"

    await _broadcast({
        "type": "progress",
        "percent": percent,
        "message": message,
    })
    return JSONResponse({"ok": True})


@app.post("/api/simulate/done")
async def api_simulate_done(request: Request):
    """Called by rp_library when the simulation completes."""
    data = await request.json()
    status = data.get("status", "success")
    _session["status"] = status
    if status == "success":
        _session["progress"] = {"percent": 100.0, "message": "Complete"}

    # Inject inputs report and driver XML into outputs
    _session["outputs"]["__inputs__"] = _build_inputs_report(_session["inputs"])
    driver_out = _build_driver_xml_output(_session["inputs"])
    if driver_out:
        _session["outputs"]["__driver_xml__"] = driver_out

    # Record in history
    run_record = _history.add(
        input_values=_session["inputs"],
        outputs=_session["outputs"],
        log=_session["log"],
        status=status,
    )
    _session["run_id"] = run_record["run_id"]
    _session["run_num"] = run_record["run_num"]

    await _broadcast({
        "type": "done",
        "status": status,
        "outputs": _session["outputs"],
        "log": _session["log"],
        "run_id": run_record["run_id"],
        "run_num": run_record["run_num"],
        "cached": False,
    })
    return JSONResponse({"ok": True})


@app.get("/api/outputs")
async def api_get_outputs():
    """Return current session outputs (polling fallback)."""
    return JSONResponse({
        "status": _session["status"],
        "progress": _session.get("progress", {"percent": None, "message": ""}),
        "outputs": _session["outputs"],
        "log": _session["log"],
    })


# ─── Loader examples endpoint ────────────────────────────────────────────────

def _resolve_loader_examples(tool_dir: Path, pattern: str):
    """Glob for loader example files relative to tool_dir.

    Supports recursive patterns like 'examples/**/*.xml' or simple globs
    like '*.xml' or 'examples/asd*.xml'.  Returns a list of Path objects;
    each path is absolute but guaranteed to be under tool_dir.

    Search order:
      1. tool_dir / pattern  (treat pattern as relative glob from tool root)
      2. tool_dir / "examples" / pattern_name  (legacy flat examples/ dir)
      3. tool_dir / pattern_name  (fallback flat search in tool_dir)
    """
    tool_dir = tool_dir.resolve()
    results = []
    seen: set[Path] = set()

    def _add(p: Path):
        rp = p.resolve()
        if rp in seen or not str(rp).startswith(str(tool_dir)):
            return
        if rp.name == "tool.xml" or not rp.is_file():
            return
        seen.add(rp)
        results.append(rp)

    # 1. Glob from tool_dir using the full pattern (handles subdirs and **)
    #    If the pattern has no path separator, restrict to examples/ to avoid
    #    picking up files (e.g. tool.xml, driver.xml) sitting in tool_dir root.
    if "/" not in pattern and "\\" not in pattern:
        ex_dir = tool_dir / "examples"
        if ex_dir.is_dir():
            for p in sorted(ex_dir.glob("**/" + pattern)):
                _add(p)
        # Fallback: glob from tool_dir only if examples/ found nothing
        if not results:
            for p in sorted(tool_dir.glob(pattern)):
                _add(p)
    else:
        if not pattern.startswith("examples/"):
            pattern = "examples/" + pattern
        for p in sorted(tool_dir.glob(pattern)):
            _add(p)

    return results


@app.get("/api/loader-examples")
async def api_loader_examples(pattern: str = "*.xml"):
    """Return list of example XML files for loader widgets."""
    if _tool_xml_path is None:
        return JSONResponse([])
    tool_dir = Path(_tool_xml_path).parent
    from xml.etree import ElementTree as ET
    results = []
    for fpath in _resolve_loader_examples(tool_dir, pattern):
        # Use path relative to tool_dir as the filename key so subdirs are preserved
        try:
            rel = fpath.relative_to(tool_dir.resolve())
        except ValueError:
            rel = Path(fpath.name)
        label = fpath.stem
        try:
            tree = ET.parse(fpath)
            root = tree.getroot()
            about = root.find(".//about")
            if about is not None:
                lbl = about.findtext("label")
                if lbl:
                    label = lbl.strip()
        except Exception:
            pass
        results.append({"filename": str(rel), "label": label})
    return JSONResponse(results)


@app.get("/api/loader-examples/{filename:path}")
async def api_loader_example_file(filename: str, pattern: str = "*.xml"):
    """Return the content of a specific example XML file."""
    if _tool_xml_path is None:
        return JSONResponse({"error": "No tool loaded"}, status_code=404)
    tool_dir = Path(_tool_xml_path).parent.resolve()
    # filename may be a relative path like 'examples/asd/example1.xml'
    fpath = (tool_dir / filename).resolve()
    # Safety: ensure file is within tool_dir
    if not str(fpath).startswith(str(tool_dir)):
        return JSONResponse({"error": "Invalid path"}, status_code=403)
    if not fpath.exists():
        return JSONResponse({"error": "Not found"}, status_code=404)
    content = fpath.read_text()
    label = fpath.stem
    try:
        from xml.etree import ElementTree as ET
        root = ET.fromstring(content)
        about = root.find(".//about")
        if about is not None:
            lbl = about.findtext("label")
            if lbl:
                label = lbl.strip()
    except Exception:
        pass
    return JSONResponse({"content": content, "label": label})


# ─── Run history endpoints ────────────────────────────────────────────────────

@app.get("/api/runs")
async def api_get_runs():
    """Return list of all past runs (excluding raw outputs for brevity)."""
    summary = []
    for run in _history.runs:
        xml_path = run.get("run_xml")
        summary.append({
            "run_id": run["run_id"],
            "run_num": run["run_num"],
            "label": run["label"],
            "timestamp": run["timestamp"],
            "status": run["status"],
            "cached": False,
            "inputs": run["inputs"],
            "source": run.get("source", "simulated"),
            "has_xml": bool(xml_path and os.path.isfile(xml_path)),
        })
    return JSONResponse(summary)


@app.get("/api/runs/{run_id}")
async def api_get_run(run_id: str):
    """Return full data for a specific past run."""
    run = _history.get_by_id(run_id)
    if run is None:
        return JSONResponse({"error": "Run not found"}, status_code=404)
    return JSONResponse(run)


@app.delete("/api/runs/{run_id}")
async def api_delete_run(run_id: str):
    """Delete a run from history."""
    if _history.delete(run_id):
        return JSONResponse({"ok": True})
    return JSONResponse({"error": "Run not found"}, status_code=404)


@app.patch("/api/runs/{run_id}")
async def api_rename_run(run_id: str, request: Request):
    """Rename a run label."""
    data = await request.json()
    label = data.get("label", "").strip()
    if not label:
        return JSONResponse({"error": "Label cannot be empty"}, status_code=400)
    run = _history.get_by_id(run_id)
    if run is None:
        return JSONResponse({"error": "Run not found"}, status_code=404)
    _history.update_run(run_id, label=label)
    return JSONResponse({"ok": True})


@app.post("/api/runs/{run_id}/reload")
async def api_reload_run(run_id: str):
    """Re-parse the run XML and update cached outputs."""
    from .xml_parser import parse_run_xml
    run = _history.get_by_id(run_id)
    if run is None:
        return JSONResponse({"error": "Run not found"}, status_code=404)
    xml_path = run.get("run_xml")
    if not xml_path or not os.path.isfile(xml_path):
        return JSONResponse({"error": "Run XML file not found"}, status_code=404)
    try:
        outputs = parse_run_xml(xml_path)
    except Exception as exc:
        return JSONResponse({"error": f"Failed to parse XML: {exc}"}, status_code=400)
    _history.update_run(run_id, outputs=outputs)
    return JSONResponse({"ok": True})


@app.post("/api/runs/reload-all")
async def api_reload_all_runs():
    """Re-parse run XMLs for all runs that have a run_xml path."""
    from .xml_parser import parse_run_xml
    reloaded, skipped = 0, 0
    for run in _history.runs:
        xml_path = run.get("run_xml")
        if not xml_path or not os.path.isfile(xml_path):
            skipped += 1
            continue
        try:
            outputs = parse_run_xml(xml_path)
            _history.update_run(run["run_id"], outputs=outputs)
            reloaded += 1
        except Exception:
            skipped += 1
    return JSONResponse({"ok": True, "reloaded": reloaded, "skipped": skipped})


@app.post("/api/runs/reorder")
async def api_reorder_runs(request: Request):
    """Reorder runs. Body: {run_ids: [id, id, ...]} — first = top."""
    data = await request.json()
    run_ids = data.get("run_ids", [])
    _history.reorder(run_ids)
    return JSONResponse({"ok": True})


# ─── Upload run XML ───────────────────────────────────────────────────────────

@app.post("/api/upload-run")
async def api_upload_run(file: UploadFile = File(...)):
    """Accept an uploaded run.xml, parse its outputs, and add to history."""
    import tempfile
    from .xml_parser import parse_run_xml

    if _is_nanohub:
        return JSONResponse({"error": "Upload XML is disabled on nanoHUB"}, status_code=403)

    content = await file.read()
    with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp:
        tmp.write(content)
        tmp_path = tmp.name

    try:
        outputs = parse_run_xml(tmp_path)
    except Exception as exc:
        os.unlink(tmp_path)
        return JSONResponse({"error": f"Failed to parse XML: {exc}"}, status_code=400)

    # Persist the XML so it can be re-parsed on reload; store in cache_dir if available
    label = Path(file.filename).stem if file.filename else "uploaded"
    saved_xml_path = None
    if _history._cache_dir:
        os.makedirs(_history._cache_dir, exist_ok=True)
        saved_xml_path = os.path.join(_history._cache_dir, f"upload_{label}_{os.path.basename(tmp_path)}")
        try:
            os.rename(tmp_path, saved_xml_path)
        except OSError:
            os.unlink(tmp_path)
            saved_xml_path = None
    else:
        os.unlink(tmp_path)

    run_record = _history.add(
        input_values={},
        outputs=outputs,
        log="",
        status="success",
        run_xml=saved_xml_path,
    )
    _history.update_run(run_record["run_id"], label=label, source="upload")

    await _broadcast({
        "type": "done",
        "status": "success",
        "outputs": outputs,
        "log": "",
        "run_id": run_record["run_id"],
        "run_num": run_record["run_num"],
        "cached": False,
        "source": "upload",
    })
    return JSONResponse({"ok": True, "run_id": run_record["run_id"], "run_num": run_record["run_num"]})


# ─── Tool metadata ────────────────────────────────────────────────────────────

@app.get("/api/tool")
async def get_tool_info():
    if _tool_def is None:
        return JSONResponse({"error": "No tool loaded"}, status_code=400)
    return JSONResponse(_serialize({"tool": _tool_def.tool, "inputs": _tool_def.inputs}))


# ─── WebSocket ────────────────────────────────────────────────────────────────

@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()
    _ws_clients.append(ws)
    # Send current state on connect
    await ws.send_json({
        "type": "state",
        "status": _session["status"],
        "progress": _session.get("progress", {"percent": None, "message": ""}),
        "outputs": _session["outputs"],
        "log": _session["log"],
        "runs": [
            {"run_id": r["run_id"], "run_num": r["run_num"],
             "label": r["label"], "status": r["status"],
             "source": r.get("source", "simulated")}
            for r in _history.runs
        ],
    })
    try:
        while True:
            await ws.receive_text()  # keep alive; client pings
    except WebSocketDisconnect:
        if ws in _ws_clients:
            _ws_clients.remove(ws)
