from __future__ import annotations

import asyncio
import bz2
import contextlib
import gzip
import json
import os
import secrets
import shutil
import sqlite3
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, quote, urlparse

import lzma
import pandas as pd
import zstandard as zstd
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response, status
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse

from ..metrics import eval_table
from ..table_decoder import decode_to_csv
from ..table_encoder import EncodeConfig, encode_csv
from .models import (
    ArtifactLink,
    ArtifactMeta,
    JobCreateRequest,
    JobCreateResponse,
    JobConfig,
    JobStatus,
    JobSummary,
    UsageResponse,
    UsageWindow,
    new_job_id,
)


BASE_URL = os.getenv("SEMPRESS_API_BASE_URL", "http://127.0.0.1:8000")
STORAGE_ROOT = Path(os.getenv("SEMPRESS_API_STORAGE", "var/api_jobs")).resolve()
ARTIFACT_TTL_DAYS = int(os.getenv("SEMPRESS_API_ARTIFACT_TTL_DAYS", "7"))
CLEANUP_INTERVAL_SECONDS = int(os.getenv("SEMPRESS_API_CLEANUP_INTERVAL", "60"))
WEBHOOK_TIMEOUT_SECONDS = int(os.getenv("SEMPRESS_API_WEBHOOK_TIMEOUT", "5"))
WEBHOOK_USER_AGENT = os.getenv("SEMPRESS_API_WEBHOOK_USER_AGENT", "sempress-stub/0.1")
_MAX_UPLOAD_MB_RAW = os.getenv("SEMPRESS_API_MAX_UPLOAD_MB")
MAX_UPLOAD_BYTES = (
    max(int(float(_MAX_UPLOAD_MB_RAW) * 1024 * 1024), 0) if _MAX_UPLOAD_MB_RAW else None
)
STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
DB_PATH = STORAGE_ROOT / "jobs.sqlite3"

# API key registry (key → tenant identifier).
def _parse_api_keys(raw_keys: str) -> Dict[str, str]:
    mapping: Dict[str, str] = {}
    for chunk in raw_keys.split(","):
        chunk = chunk.strip()
        if not chunk:
            continue
        if ":" in chunk:
            tenant, key = chunk.split(":", 1)
        else:
            tenant, key = "default", chunk
        tenant = tenant.strip() or "default"
        key = key.strip()
        if key:
            mapping[key] = tenant
    return mapping or {"demo-key": "demo"}


_API_KEYS = _parse_api_keys(os.getenv("SEMPRESS_API_KEYS", "demo:demo-key"))


@dataclass(frozen=True)
class TenantContext:
    tenant_id: str
    api_key: str


# In-memory key → job mapping for the stub API.
_JOB_STORE: Dict[str, "JobRecord"] = {}
_STORE_LOCK = asyncio.Lock()


@dataclass
class JobRecord:
    request: JobCreateRequest
    summary: JobSummary
    upload_token: Optional[str]
    tenant_id: str
    artifact_tokens: Dict[str, str] = field(default_factory=dict)
    storage_path: Path = field(default_factory=Path)
    input_path: Optional[Path] = None


def _format_bytes(num_bytes: int) -> str:
    units = ["B", "KB", "MB", "GB", "TB"]
    size = float(max(num_bytes, 0))
    idx = 0
    while size >= 1024 and idx < len(units) - 1:
        size /= 1024
        idx += 1
    precision = 0 if size >= 10 or idx == 0 else 1
    return f"{size:.{precision}f} {units[idx]}"


def _ensure_within_upload_limit(num_bytes: int) -> None:
    if MAX_UPLOAD_BYTES and num_bytes > MAX_UPLOAD_BYTES:
        human_limit = _format_bytes(MAX_UPLOAD_BYTES)
        human_size = _format_bytes(num_bytes)
        raise HTTPException(
            status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
            detail=f"Upload size {human_size} exceeds configured limit of {human_limit}.",
        )


def _db_connect() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH, check_same_thread=False)
    conn.row_factory = sqlite3.Row
    return conn


def _init_db() -> None:
    with _db_connect() as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS jobs (
                job_id TEXT PRIMARY KEY,
                tenant_id TEXT NOT NULL,
                request_json TEXT NOT NULL,
                summary_json TEXT NOT NULL,
                upload_token TEXT
            )
            """
        )
        conn.commit()


def _load_jobs_from_db() -> None:
    with _db_connect() as conn:
        rows = conn.execute(
            "SELECT job_id, tenant_id, request_json, summary_json, upload_token FROM jobs"
        ).fetchall()

    _JOB_STORE.clear()
    for row in rows:
        job_id = row["job_id"]
        try:
            request = JobCreateRequest.model_validate_json(row["request_json"])
            summary = JobSummary.model_validate_json(row["summary_json"])
        except Exception:
            # Skip malformed rows to keep stub resilient.
            continue

        storage_path = STORAGE_ROOT / job_id
        storage_path.mkdir(parents=True, exist_ok=True)
        artifact_tokens = _extract_tokens(summary.artifacts)
        if summary.artifacts:
            refreshed_links = []
            for artifact in summary.artifacts:
                token = artifact_tokens.get(artifact.type)
                if not token:
                    token = secrets.token_urlsafe(24)
                    artifact_tokens[artifact.type] = token
                refreshed_links.append(
                    ArtifactLink(
                        type=artifact.type,
                        url=_artifact_url(job_id, artifact.type, token),
                        expires_at=artifact.expires_at,
                    )
                )
            summary.artifacts = refreshed_links

        input_path = None
        source_meta = summary.artifact_meta.get("source")
        if source_meta:
            candidate = Path(source_meta.path)
            if candidate.exists():
                input_path = candidate

        record = JobRecord(
            request=request,
            summary=summary,
            upload_token=row["upload_token"],
            tenant_id=row["tenant_id"],
            artifact_tokens=artifact_tokens,
            storage_path=storage_path,
            input_path=input_path,
        )
        _JOB_STORE[job_id] = record


def _persist_job(record: JobRecord) -> None:
    payload = (
        record.summary.job_id,
        record.tenant_id,
        record.request.json(),
        record.summary.json(),
        record.upload_token,
    )
    with _db_connect() as conn:
        conn.execute(
            """
            INSERT INTO jobs (job_id, tenant_id, request_json, summary_json, upload_token)
            VALUES (?, ?, ?, ?, ?)
            ON CONFLICT(job_id) DO UPDATE SET
                tenant_id=excluded.tenant_id,
                request_json=excluded.request_json,
                summary_json=excluded.summary_json,
                upload_token=excluded.upload_token
            """,
            payload,
        )
        conn.commit()


async def _save_job(record: JobRecord) -> None:
    await asyncio.to_thread(_persist_job, record)


async def _require_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> TenantContext:
    tenant = _API_KEYS.get(x_api_key)
    if not tenant:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
    return TenantContext(tenant_id=tenant, api_key=x_api_key)


def _delete_job_from_db(job_id: str) -> None:
    with _db_connect() as conn:
        conn.execute("DELETE FROM jobs WHERE job_id=?", (job_id,))
        conn.commit()


async def _purge_job(job_id: str) -> None:
    async with _STORE_LOCK:
        record = _JOB_STORE.pop(job_id, None)

    def _cleanup() -> None:
        storage_dir = STORAGE_ROOT / job_id
        target_dir = record.storage_path if record else storage_dir
        shutil.rmtree(target_dir, ignore_errors=True)
        _delete_job_from_db(job_id)

    await asyncio.to_thread(_cleanup)


def create_app() -> FastAPI:
    app = FastAPI(
        title="Sempress Managed API (Stub)",
        version="0.1.0",
        description="Skeleton control plane exercising the managed semantic compression API surface.",
    )

    @app.on_event("startup")
    async def start_cleanup() -> None:
        await _cleanup_expired_artifacts()
        if CLEANUP_INTERVAL_SECONDS > 0:
            app.state.cleanup_task = asyncio.create_task(_cleanup_loop(CLEANUP_INTERVAL_SECONDS))
        else:
            app.state.cleanup_task = None

    @app.on_event("shutdown")
    async def stop_cleanup() -> None:
        task = getattr(app.state, "cleanup_task", None)
        if task:
            task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await task

    @app.post("/v1/jobs", response_model=JobCreateResponse, status_code=status.HTTP_201_CREATED)
    async def create_job(req: JobCreateRequest, tenant: TenantContext = Depends(_require_api_key)) -> JobCreateResponse:
        job_id = new_job_id()
        now = _utc_now()
        job_dir = STORAGE_ROOT / job_id
        job_dir.mkdir(parents=True, exist_ok=True)

        upload_token: Optional[str] = None
        upload_url: Optional[str] = None

        if req.source.mode == "upload":
            upload_token = secrets.token_urlsafe(24)
            upload_url = _make_upload_url(job_id, upload_token)

        summary = JobSummary(
            job_id=job_id,
            status=JobStatus.pending,
            created_at=now,
            updated_at=now,
            metrics={},
            artifacts=[],
        )

        record = JobRecord(
            request=req,
            summary=summary,
            upload_token=upload_token,
            tenant_id=tenant.tenant_id,
            artifact_tokens={},
            storage_path=job_dir,
            input_path=None,
        )

        async with _STORE_LOCK:
            _JOB_STORE[job_id] = record

        await _save_job(record)
        _trigger_webhook(job_id)

        if req.source.mode == "s3_pull":
            asyncio.create_task(_fail_job(job_id, "s3_pull not implemented in stub"))

        return JobCreateResponse(
            job_id=job_id,
            status=summary.status,
            upload_url=upload_url,
            expires_at=summary.expires_at,
        )

    @app.put("/v1/jobs/{job_id}/upload")
    async def upload_payload(
        job_id: str,
        request: Request,
        token: Optional[str] = Query(default=None),
        tenant: TenantContext = Depends(_require_api_key),
    ) -> Response:
        record = await _get_job_for_tenant(job_id, tenant)
        if record.request.source.mode != "upload":
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Job does not accept uploads")
        if record.upload_token and token != record.upload_token:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid upload token")
        if record.summary.status not in {JobStatus.pending, JobStatus.uploaded}:
            raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Job no longer accepts uploads")

        data = await request.body()
        if not data:
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty payload")
        _ensure_within_upload_limit(len(data))

        dest = record.storage_path / _safe_filename(record.request.source.filename)
        dest.write_bytes(data)

        async with _STORE_LOCK:
            stored = _JOB_STORE.get(job_id)
            if not stored or stored.tenant_id != tenant.tenant_id:
                raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown job")
            record = stored
            record.input_path = dest
            record.summary.status = JobStatus.uploaded
            now = _utc_now()
            record.summary.updated_at = now
            record.summary.artifact_meta["source"] = ArtifactMeta(path=str(dest), created_at=now)

        await _save_job(record)
        _trigger_webhook(job_id)

        asyncio.create_task(_process_job(job_id))
        return Response(status_code=status.HTTP_204_NO_CONTENT)

    @app.get("/v1/jobs/{job_id}", response_model=JobSummary)
    async def get_job(job_id: str, tenant: TenantContext = Depends(_require_api_key)) -> JobSummary:
        record = await _get_job_for_tenant(job_id, tenant)
        return record.summary

    @app.get("/v1/jobs", response_model=List[JobSummary])
    async def list_jobs(
        limit: int = Query(50, ge=1, le=500),
        tenant: TenantContext = Depends(_require_api_key),
    ) -> List[JobSummary]:
        async with _STORE_LOCK:
            summaries = [
                record.summary
                for record in _JOB_STORE.values()
                if record.tenant_id == tenant.tenant_id
            ]
        ordered = sorted(summaries, key=lambda item: item.created_at, reverse=True)
        return ordered[:limit]

    @app.delete("/v1/jobs")
    async def purge_jobs(tenant: TenantContext = Depends(_require_api_key)) -> Dict[str, int]:
        async with _STORE_LOCK:
            job_ids = [
                job_id for job_id, record in _JOB_STORE.items() if record.tenant_id == tenant.tenant_id
            ]
        purged = 0
        for job_id in job_ids:
            await _purge_job(job_id)
            purged += 1
        return {"purged": purged}

    @app.post("/v1/jobs/{job_id}/cancel", response_model=JobSummary)
    async def cancel_job(job_id: str, tenant: TenantContext = Depends(_require_api_key)) -> JobSummary:
        async with _STORE_LOCK:
            record = _JOB_STORE.get(job_id)
            if not record or record.tenant_id != tenant.tenant_id:
                raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown job")
            if record.summary.status in {JobStatus.succeeded, JobStatus.failed, JobStatus.cancelled}:
                raise HTTPException(
                    status_code=status.HTTP_409_CONFLICT,
                    detail=f"Cannot cancel job in status '{record.summary.status.value}'",
                )
            record.summary.status = JobStatus.cancelled
            record.summary.updated_at = _utc_now()
        await _save_job(record)
        _trigger_webhook(job_id)
        return record.summary

    @app.get("/v1/jobs/{job_id}/artifacts")
    async def get_artifacts(
        job_id: str,
        type: str = Query("smp"),
        tenant: TenantContext = Depends(_require_api_key),
    ) -> Dict[str, ArtifactLink]:
        record = await _get_job_for_tenant(job_id, tenant)
        if record.summary.status != JobStatus.succeeded:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Artifacts available only for succeeded jobs",
            )
        link = next((a for a in record.summary.artifacts if a.type == type), None)
        if not link:
            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No artifact for type '{type}'")
        return {type: link}

    @app.get("/v1/jobs/{job_id}/artifacts/{artifact_type}/download")
    async def download_artifact(job_id: str, artifact_type: str, token: str = Query(...)) -> FileResponse:
        record = await _get_job(job_id)
        expected = record.artifact_tokens.get(artifact_type)
        if not expected or token != expected:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired token")
        if record.summary.status != JobStatus.succeeded:
            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact unavailable")
        link = next((a for a in record.summary.artifacts if a.type == artifact_type), None)
        if link and link.expires_at < _utc_now():
            raise HTTPException(status_code=status.HTTP_410_GONE, detail="Artifact link expired")
        meta = record.summary.artifact_meta.get(artifact_type)
        if not meta:
            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact metadata missing")
        path = Path(meta.path)
        if not path.exists():
            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact not found")
        return FileResponse(
            path,
            filename=path.name,
            media_type="application/octet-stream",
        )

    @app.get("/v1/usage", response_model=UsageResponse)
    async def get_usage(
        cursor: Optional[str] = None,
        tenant: TenantContext = Depends(_require_api_key),
    ) -> UsageResponse:
        now = _utc_now()
        window_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
        compressed_bytes = 0
        job_count = 0
        async with _STORE_LOCK:
            for record in _JOB_STORE.values():
                if record.tenant_id != tenant.tenant_id:
                    continue
                job_count += 1
                meta = record.summary.artifact_meta.get("smp")
                if meta:
                    compressed_bytes += Path(meta.path).stat().st_size if Path(meta.path).exists() else 0
        window = UsageWindow(
            start=window_start,
            end=now,
            compressed_gb=round(compressed_bytes / (1024 ** 3), 6),
            jobs=job_count,
            quota_gb=100.0,
        )
        return UsageResponse(windows=[window], next_cursor=None)

    @app.post("/v1/webhooks/test")
    async def test_webhook(tenant: TenantContext = Depends(_require_api_key)) -> JSONResponse:
        payload = {"status": "ok", "message": "Webhook test event enqueued."}
        return JSONResponse(payload, status_code=status.HTTP_202_ACCEPTED)

    @app.get("/", response_class=HTMLResponse)
    @app.get("/ui", response_class=HTMLResponse)
    async def ui_shell() -> HTMLResponse:
        return HTMLResponse(_UI_HTML)

    return app

async def _get_job(job_id: str) -> JobRecord:
    async with _STORE_LOCK:
        record = _JOB_STORE.get(job_id)
    if not record:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown job")
    return record


async def _get_job_for_tenant(job_id: str, tenant: TenantContext) -> JobRecord:
    record = await _get_job(job_id)
    if record.tenant_id != tenant.tenant_id:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown job")
    return record


async def _fail_job(job_id: str, message: str) -> None:
    async with _STORE_LOCK:
        record = _JOB_STORE.get(job_id)
        if not record:
            return
        record.summary.status = JobStatus.failed
        record.summary.updated_at = _utc_now()
        record.summary.error_message = message
    await _save_job(record)
    _trigger_webhook(job_id)


async def _process_job(job_id: str) -> None:
    async with _STORE_LOCK:
        record = _JOB_STORE.get(job_id)
        if not record:
            return
        if record.summary.status == JobStatus.cancelled:
            return
        if not record.input_path:
            record.summary.status = JobStatus.failed
            record.summary.error_message = "No uploaded payload"
            record.summary.updated_at = _utc_now()
            to_process = None
        else:
            record.summary.status = JobStatus.processing
            record.summary.updated_at = _utc_now()
            to_process = (record.input_path, record.request.config, record.storage_path)

    if not record:
        return

    await _save_job(record)
    _trigger_webhook(job_id)

    if not to_process:
        return

    input_path, config, storage_path = to_process

    loop = asyncio.get_running_loop()
    try:
        result = await loop.run_in_executor(
            None,
            _run_encoding_pipeline,
            job_id,
            input_path,
            config,
            storage_path,
        )
    except Exception as exc:  # pragma: no cover - debugging aid
        async with _STORE_LOCK:
            record = _JOB_STORE.get(job_id)
            if not record:
                return
            record.summary.status = JobStatus.failed
            record.summary.error_message = str(exc)
            record.summary.updated_at = _utc_now()
        if record:
            await _save_job(record)
            _trigger_webhook(job_id)
        return

    async with _STORE_LOCK:
        record = _JOB_STORE.get(job_id)
        if not record:
            return
        if record.summary.status == JobStatus.cancelled:
            return
        record.summary.status = JobStatus.succeeded
        record.summary.updated_at = _utc_now()
        record.summary.compression_ratio = result["compression_ratio"]
        record.summary.metrics = result["metrics"]
        record.summary.uncertain_cells = result["uncertain"]
        record.summary.expires_at = result["expires_at"]
        record.summary.artifacts = result["artifact_links"]
        record.summary.artifact_meta.update(result["artifact_meta"])
        record.summary.artifact_sizes = result.get("artifact_sizes", {})
        record.summary.original_size_bytes = result.get("original_size_bytes")
        record.artifact_tokens = result["artifact_tokens"]
    await _save_job(record)
    _trigger_webhook(job_id)


def _run_encoding_pipeline(job_id: str, input_path: Path, config: JobConfig, storage_path: Path) -> Dict[str, object]:
    encode_cfg = EncodeConfig(
        lock_cols=config.lock_cols,
        residual_cols=config.residual_cols,
        k=config.k,
        uncertainty_thresh=config.uncertainty_threshold,
        random_state=42,
    )

    raw_bytes = input_path.read_bytes()
    original_size = len(raw_bytes)
    if MAX_UPLOAD_BYTES and original_size > MAX_UPLOAD_BYTES:
        raise ValueError(
            f"Input file size {_format_bytes(original_size)} exceeds limit {_format_bytes(MAX_UPLOAD_BYTES)}"
        )
    smp_path = storage_path / "package.smp"
    recon_path = storage_path / "reconstruction.csv"
    report_path = storage_path / "report.json"

    blob = encode_csv(str(input_path), encode_cfg)
    smp_path.write_bytes(blob)

    decode_meta = decode_to_csv(str(smp_path), str(recon_path))

    original_df = pd.read_csv(input_path)
    recon_df = pd.read_csv(recon_path)
    metrics_nested = eval_table(original_df, recon_df, encode_cfg.lock_cols)
    metrics = _flatten_metrics(metrics_nested)

    uncertain = decode_meta.get("uncertain", {})
    decode_meta_info = decode_meta.get("meta", {}) if isinstance(decode_meta, dict) else {}
    auto_locked_cols = decode_meta_info.get("auto_locked_cols", [])
    manual_locked_cols = decode_meta_info.get("manual_locked_cols", encode_cfg.lock_cols)

    expires_at = _utc_now() + timedelta(days=ARTIFACT_TTL_DAYS)

    compression_ratio = _compression_ratio(input_path, smp_path)
    baseline_ratios = _baseline_compression_ratios(raw_bytes)
    baseline_metric_payload = {f"baseline.{name}_ratio": value for name, value in baseline_ratios.items()}
    metrics.update(baseline_metric_payload)

    report_payload = {
        "job_id": job_id,
        "generated_at": _utc_now().isoformat(),
        "compression_ratio": compression_ratio,
        "metrics": metrics,
        "uncertain_cells": uncertain,
        "baseline_compression": baseline_ratios,
        "config": {
            "lock_cols": manual_locked_cols,
            "auto_locked_cols": auto_locked_cols,
            "residual_cols": encode_cfg.residual_cols,
            "k": encode_cfg.k,
            "uncertainty_threshold": encode_cfg.uncertainty_thresh,
        },
    }

    artifact_sizes = {
        "smp": smp_path.stat().st_size,
        "reconstruction": recon_path.stat().st_size,
        "report": 0,
    }
    report_payload["artifact_sizes"] = artifact_sizes
    report_payload["original_size_bytes"] = original_size

    report_content = ""
    while True:
        report_payload["artifact_sizes"]["report"] = artifact_sizes["report"]
        report_content = json.dumps(report_payload, indent=2)
        report_size = len(report_content.encode("utf-8"))
        if report_size == artifact_sizes["report"]:
            break
        artifact_sizes["report"] = report_size
    report_path.write_text(report_content)

    created_at = _utc_now()
    artifact_meta = {
        "smp": ArtifactMeta(path=str(smp_path), created_at=created_at),
        "reconstruction": ArtifactMeta(path=str(recon_path), created_at=created_at),
        "report": ArtifactMeta(path=str(report_path), created_at=created_at),
    }

    artifact_files = {
        "smp": smp_path,
        "reconstruction": recon_path,
        "report": report_path,
    }

    artifact_tokens: Dict[str, str] = {}
    artifact_links = []
    for art_type, path in artifact_files.items():
        token = secrets.token_urlsafe(24)
        artifact_tokens[art_type] = token
        artifact_links.append(
            ArtifactLink(
                type=art_type,
                url=_artifact_url(job_id, art_type, token),
                expires_at=expires_at,
            )
        )

    return {
        "compression_ratio": compression_ratio,
        "metrics": metrics,
        "uncertain": uncertain,
        "expires_at": expires_at,
        "baseline": baseline_ratios,
        "artifact_links": artifact_links,
        "artifact_meta": artifact_meta,
        "artifact_tokens": artifact_tokens,
        "artifact_sizes": artifact_sizes,
        "original_size_bytes": original_size,
    }


def _make_upload_url(job_id: str, token: str) -> str:
    base_path = f"/v1/jobs/{job_id}/upload"
    return f"{BASE_URL}{base_path}?token={quote(token)}"


def _artifact_url(job_id: str, artifact_type: str, token: str) -> str:
    base_path = f"/v1/jobs/{job_id}/artifacts/{artifact_type}/download"
    return f"{BASE_URL}{base_path}?token={quote(token)}"


def _extract_tokens(artifacts: List[ArtifactLink]) -> Dict[str, str]:
    tokens: Dict[str, str] = {}
    for artifact in artifacts:
        token = _extract_token_from_url(artifact.url)
        if token:
            tokens[artifact.type] = token
    return tokens


def _extract_token_from_url(url: str) -> Optional[str]:
    try:
        parsed = urlparse(url)
    except Exception:
        return None
    params = parse_qs(parsed.query)
    values = params.get("token")
    if not values:
        return None
    return values[0]


def _flatten_metrics(metrics_nested: Dict[str, Dict[str, object]]) -> Dict[str, float]:
    flat: Dict[str, float] = {}
    for col, measures in metrics_nested.items():
        for name, value in measures.items():
            key = f"{col}.{name}"
            if isinstance(value, bool):
                flat[key] = float(value)
            elif isinstance(value, (int, float)):
                flat[key] = float(value)
    return flat


def _compression_ratio(original_path: Path, smp_path: Path) -> float:
    orig_size = max(original_path.stat().st_size, 1)
    smp_size = max(smp_path.stat().st_size, 1)
    return round(orig_size / smp_size, 6)


def _baseline_compression_ratios(data: bytes) -> Dict[str, float]:
    original_size = max(len(data), 1)
    compressed_sizes: Dict[str, int] = {}

    try:
        compressed_sizes["gzip"] = len(gzip.compress(data))
    except Exception:
        pass

    try:
        compressed_sizes["bz2"] = len(bz2.compress(data))
    except Exception:
        pass

    try:
        compressed_sizes["lzma"] = len(lzma.compress(data))
    except Exception:
        pass

    try:
        zstd_compressor = zstd.ZstdCompressor(level=3)
        compressed_sizes["zstd"] = len(zstd_compressor.compress(data))
    except Exception:
        pass

    ratios: Dict[str, float] = {}
    for name, size in compressed_sizes.items():
        ratios[name] = round(original_size / max(size, 1), 6)
    return ratios


def _safe_filename(name: str) -> str:
    return Path(name).name or "input.csv"


def _utc_now() -> datetime:
    return datetime.now(tz=timezone.utc)


def _send_webhook_request(url: str, payload: Dict[str, Any]) -> None:
    data = json.dumps(payload).encode("utf-8")
    headers = {
        "Content-Type": "application/json",
        "User-Agent": WEBHOOK_USER_AGENT,
    }
    request = urllib.request.Request(url, data=data, headers=headers, method="POST")
    with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT_SECONDS) as response:
        response.read()


_WEBHOOK_SENDER = _send_webhook_request


def _trigger_webhook(job_id: str) -> None:
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        return
    loop.create_task(_dispatch_webhook(job_id))


async def _dispatch_webhook(job_id: str) -> None:
    async with _STORE_LOCK:
        record = _JOB_STORE.get(job_id)
        if not record:
            return
        webhook_url = record.request.webhook_url
        if not webhook_url:
            return
        tenant_id = record.tenant_id
        metadata = dict(record.request.metadata)
        config_payload = record.request.config.model_dump()
        status_value = record.summary.status.value
        summary_payload = record.summary.model_dump()

    payload: Dict[str, Any] = {
        "event": "job.status",
        "version": "stub.v1",
        "job_id": job_id,
        "tenant_id": tenant_id,
        "status": status_value,
        "sent_at": _utc_now().isoformat(),
        "job": summary_payload,
        "metadata": metadata,
        "config": config_payload,
    }
    try:
        await asyncio.to_thread(_WEBHOOK_SENDER, webhook_url, payload)
    except Exception:
        return


async def _cleanup_loop(interval: int) -> None:
    try:
        while True:
            await asyncio.sleep(interval)
            await _cleanup_expired_artifacts()
    except asyncio.CancelledError:
        pass


_UI_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sempress Compression Studio</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root { color-scheme: dark; }
body { margin: 0; font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; background: #020617; color: #e2e8f0; }
a { color: inherit; }
.btn { display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; padding: 0.55rem 1.25rem; font-weight: 600; text-decoration: none; transition: transform 0.15s ease, box-shadow 0.15s ease; border: none; cursor: pointer; }
.btn.primary { background: linear-gradient(135deg, #0ea5e9, #0d9488); color: #071524; box-shadow: 0 10px 30px rgba(14, 165, 233, 0.3); }
.btn.secondary { border: 1px solid rgba(148, 163, 184, 0.4); color: #cbd5f5; background: rgba(15, 23, 42, 0.6); }
.btn.tertiary { background: #1e293b; color: #cbd5f5; border: 1px solid rgba(148, 163, 184, 0.35); }
.btn:hover { transform: translateY(-1px); box-shadow: 0 12px 30px rgba(59, 130, 246, 0.2); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; box-shadow: none; transform: none; }
.hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2.5rem; padding: 4rem 6vw 3rem; background: radial-gradient(circle at top left, rgba(14, 165, 233, 0.25), transparent 45%), radial-gradient(circle at top right, rgba(8, 145, 178, 0.2), transparent 55%), #020617; border-bottom: 1px solid rgba(148, 163, 184, 0.08); position: relative; overflow: hidden; }
.hero::after { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at bottom right, rgba(56, 189, 248, 0.08), transparent 40%); pointer-events: none; }
.hero-content { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 1.5rem; }
.hero-badge { display: inline-flex; align-items: center; gap: 0.4rem; background: rgba(59, 130, 246, 0.16); color: #bae6fd; padding: 0.35rem 0.75rem; border-radius: 999px; font-size: 0.8rem; width: fit-content; letter-spacing: 0.04em; text-transform: uppercase; }
.hero h1 { margin: 0; font-size: clamp(2.1rem, 4vw, 3rem); line-height: 1.1; color: #f8fafc; }
.hero p { margin: 0; font-size: 1.05rem; color: #cbd5f5; max-width: 560px; }
.cta-buttons { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.hero-metric { position: relative; z-index: 1; background: rgba(15, 23, 42, 0.65); border: 1px solid rgba(148, 163, 184, 0.12); border-radius: 20px; padding: 2rem; display: flex; flex-direction: column; gap: 0.75rem; box-shadow: 0 25px 45px rgba(15, 23, 42, 0.45); backdrop-filter: blur(20px); align-self: center; }
.metric-label { text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.75rem; color: #94a3b8; margin: 0; }
.metric-value { margin: 0; font-size: clamp(2rem, 5vw, 2.8rem); font-weight: 700; color: #f8fafc; }
.metric-note { margin: 0; font-size: 0.9rem; color: #94a3b8; }
main { padding: 3rem 6vw 5rem; display: flex; flex-direction: column; gap: 2.5rem; }
section { background: rgba(15, 23, 42, 0.85); border: 1px solid rgba(148, 163, 184, 0.1); border-radius: 18px; padding: 2rem; box-shadow: 0 25px 45px rgba(15, 23, 42, 0.4); }
.section-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.section-header h2 { margin: 0; font-size: 1.6rem; }
.section-subtitle { margin: 0; font-size: 0.95rem; color: #94a3b8; max-width: 640px; }
.summary { display: flex; flex-direction: column; gap: 1.5rem; }
.stat-grid { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.stat-card { background: rgba(2, 6, 23, 0.6); border: 1px solid rgba(148, 163, 184, 0.12); border-radius: 16px; padding: 1.25rem; display: flex; flex-direction: column; gap: 0.5rem; box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.12); }
.stat-card h3 { margin: 0; font-size: 1.75rem; color: #f8fafc; }
.stat-card p { margin: 0; font-size: 0.95rem; color: #94a3b8; }
.form-card form { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
.form-card label { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.9rem; color: #cbd5f5; }
.form-card input[type="text"], .form-card input[type="number"], .form-card textarea { padding: 0.65rem 0.75rem; border-radius: 12px; border: 1px solid rgba(148, 163, 184, 0.22); background: rgba(15, 23, 42, 0.9); color: #f1f5f9; font-size: 0.95rem; }
.form-card input[type="file"] { color: #cbd5f5; }
.form-footer { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
#job-status { min-height: 1.25rem; font-size: 0.9rem; color: #38bdf8; }
.chart-wrapper { height: 360px; background: rgba(2, 6, 23, 0.6); border-radius: 16px; padding: 1.5rem; border: 1px solid rgba(148, 163, 184, 0.08); }
.chart-wrapper canvas { height: 100% !important; width: 100% !important; }
table { width: 100%; border-collapse: collapse; font-size: 0.92rem; border-radius: 12px; overflow: hidden; }
th, td { padding: 0.65rem 0.85rem; border-bottom: 1px solid rgba(148, 163, 184, 0.08); text-align: left; }
thead th { font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; color: #94a3b8; background: rgba(15, 23, 42, 0.65); }
tbody tr:hover { background: rgba(148, 163, 184, 0.08); }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 999px; font-size: 0.75rem; background: rgba(14, 165, 233, 0.2); color: #38bdf8; text-transform: uppercase; letter-spacing: 0.04em; }
.downloads a { color: #38bdf8; text-decoration: none; margin-right: 0.75rem; font-size: 0.85rem; }
.features-grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin-top: 1.5rem; }
.feature-card { background: rgba(2, 6, 23, 0.6); border: 1px solid rgba(148, 163, 184, 0.1); border-radius: 16px; padding: 1.25rem; display: flex; flex-direction: column; gap: 0.6rem; }
.feature-card h3 { margin: 0; font-size: 1.1rem; }
.feature-card p { margin: 0; font-size: 0.95rem; color: #94a3b8; }
@media (max-width: 640px) {
  .hero { padding: 3rem 1.5rem 2.5rem; }
  main { padding: 3rem 1.5rem 4rem; }
  section { padding: 1.75rem; }
  .form-card form { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header class="hero">
  <div class="hero-content">
    <span class="hero-badge">Semantic Compression • Preview</span>
    <h1>Ship data faster with <span style="color:#38bdf8;">Sempress</span></h1>
    <p>Sempress learns structure inside tabular datasets, squeezing CSVs dramatically further than traditional codecs without sacrificing fidelity. Test a dataset, share a link, and explore how we compare.</p>
    <div class="cta-buttons">
      <a class="btn primary" href="#try-sempress">Try the demo</a>
      <a class="btn secondary" href="#insights">See the benchmarks</a>
    </div>
  </div>
  <div class="hero-metric">
    <p class="metric-label">Latest dataset</p>
    <p class="metric-value" id="headline-ratio">—</p>
    <p class="metric-note" id="summary-note">Upload a CSV to view live metrics.</p>
  </div>
</header>
<main>
  <section id="insights" class="summary">
    <div class="section-header">
      <div>
        <h2>Performance at a Glance</h2>
        <p class="section-subtitle">Every upload is benchmarked against gzip, bzip2, LZMA, and Zstandard so you can see exactly how many bytes Sempress saves.</p>
      </div>
    </div>
    <div class="stat-grid">
      <div class="stat-card">
        <h3 id="ratio-improvement">—</h3>
        <p>Sempress vs the strongest baseline codec.</p>
      </div>
      <div class="stat-card">
        <h3 id="best-baseline">—</h3>
        <p>Best baseline size and ratio on the latest run.</p>
      </div>
      <div class="stat-card">
        <h3 id="jobs-count">0 jobs</h3>
        <p>Datasets processed this session.</p>
      </div>
    </div>
  </section>
  <section id="try-sempress" class="form-card">
    <div class="section-header">
      <div>
        <h2>Try Sempress on Your Data</h2>
        <p class="section-subtitle">Upload a CSV, pick your settings, and we’ll generate a semantic package plus reconstruction report in seconds.</p>
      </div>
    </div>
    <form id="job-form">
      <label>API Key
        <input id="api-key" name="api-key" type="text" placeholder="demo-key" required />
      </label>
      <label>Lock Columns (comma separated)
        <input id="lock-cols" name="lock-cols" type="text" placeholder="id,timestamp" />
      </label>
      <label>Residual Columns (comma separated)
        <input id="residual-cols" name="residual-cols" type="text" placeholder="amount" />
      </label>
      <label>Codebook Size (k)
        <input id="k-value" name="k-value" type="number" min="2" max="1024" value="64" />
      </label>
      <label>Uncertainty Threshold
        <input id="uncertainty" name="uncertainty" type="number" step="0.01" min="0" max="1" value="0.2" />
      </label>
      <label>Upload CSV
        <input id="input-file" name="input-file" type="file" accept=".csv" required />
      </label>
      <label>Webhook URL (optional)
        <input id="webhook-url" name="webhook-url" type="text" placeholder="https://example.com/hooks/sempress" />
      </label>
      <label>Metadata (JSON, optional)
        <textarea id="metadata" name="metadata" rows="3" placeholder='{"pipeline": "demo"}'></textarea>
      </label>
      <div class="form-footer">
        <button class="btn primary" type="submit">Create &amp; Run Job</button>
        <span id="job-status"></span>
      </div>
    </form>
  </section>
  <section class="dashboard-card">
    <div class="section-header">
      <div>
        <h2>Visualize Compression</h2>
        <p class="section-subtitle">Smaller bars mean smaller files—compare the Sempress package against baseline codecs and the original CSV.</p>
      </div>
      <button id="refresh-btn" type="button" class="btn tertiary">Refresh Jobs</button>
    </div>
    <div class="chart-wrapper">
      <canvas id="ratio-chart"></canvas>
    </div>
  </section>
  <section class="jobs-card">
    <div class="section-header">
      <div>
        <h2>Job Explorer</h2>
        <p class="section-subtitle">Recent runs, their file sizes, and one-click downloads for `.smp`, reconstruction CSV, and JSON report.</p>
      </div>
      <button id="purge-btn" type="button" class="btn secondary">Purge Jobs</button>
    </div>
    <table id="job-table">
      <thead>
        <tr>
          <th>Job ID</th>
          <th>Status</th>
          <th>Sempress Size</th>
          <th>Baselines</th>
          <th>Updated</th>
          <th>Artifacts</th>
        </tr>
      </thead>
      <tbody></tbody>
    </table>
  </section>
  <section>
    <div class="section-header">
      <div>
        <h2>Why Teams Pick Sempress</h2>
        <p class="section-subtitle">Purpose-built compression unlocks downstream workflows that general-purpose codecs can’t touch.</p>
      </div>
    </div>
    <div class="features-grid">
      <div class="feature-card">
        <h3>Semantic Fidelity</h3>
        <p>Auto-locking preserves identifiers and timestamps exactly while keeping quantitative columns compact and query-ready.</p>
      </div>
      <div class="feature-card">
        <h3>Performance Insights</h3>
        <p>Every job ships with metrics, uncertainty masks, and baseline comparisons so data teams can enforce QA guardrails.</p>
      </div>
      <div class="feature-card">
        <h3>Future-Proof Delivery</h3>
        <p>Sempress packages `.smp` embeds reconstruction instructions and model metadata—ideal for streaming, warehousing, or review.</p>
      </div>
    </div>
  </section>
</main>
<script>
const state = { apiKey: "", chart: null, pollers: new Map() };
const statusEl = document.getElementById("job-status");
const form = document.getElementById("job-form");
const refreshBtn = document.getElementById("refresh-btn");
const purgeBtn = document.getElementById("purge-btn");
const tableBody = document.querySelector("#job-table tbody");
const headlineRatioEl = document.getElementById("headline-ratio");
const ratioImprovementEl = document.getElementById("ratio-improvement");
const bestBaselineEl = document.getElementById("best-baseline");
const jobsCountEl = document.getElementById("jobs-count");
const summaryNoteEl = document.getElementById("summary-note");

const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"];

function setStatus(message, type = "info") {
  statusEl.textContent = message || "";
  statusEl.style.color = type === "error" ? "#f87171" : "#38bdf8";
}

function collectColumns(raw) {
  return raw.split(",").map((value) => value.trim()).filter(Boolean);
}

function formatBytes(value) {
  if (value == null || Number.isNaN(value)) {
    return "—";
  }
  if (value === 0) {
    return "0 B";
  }
  let size = value;
  let unitIndex = 0;
  while (size >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
    size /= 1024;
    unitIndex += 1;
  }
  const precision = size >= 10 || unitIndex === 0 ? 0 : 1;
  return `${size.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`;
}

function bytesToKB(value) {
  if (value == null || Number.isNaN(value)) {
    return null;
  }
  return value / 1024;
}

async function createJob(payload, apiKey) {
  const response = await fetch("/v1/jobs", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": apiKey
    },
    body: JSON.stringify(payload)
  });
  if (!response.ok) {
    const detail = await response.json().catch(() => ({}));
    throw new Error(detail.detail || `Failed to create job (${response.status})`);
  }
  return response.json();
}

async function uploadFile(url, file, apiKey) {
  const response = await fetch(url, {
    method: "PUT",
    headers: {
      "Content-Type": "application/octet-stream",
      "X-API-Key": apiKey
    },
    body: file
  });
  if (!response.ok) {
    throw new Error(`Upload failed (${response.status})`);
  }
}

async function fetchJob(jobId, apiKey) {
  const response = await fetch(`/v1/jobs/${jobId}`, {
    headers: { "X-API-Key": apiKey }
  });
  if (!response.ok) {
    const detail = await response.json().catch(() => ({}));
    throw new Error(detail.detail || `Failed to fetch job ${jobId}`);
  }
  return response.json();
}

async function fetchJobs(apiKey) {
  const response = await fetch("/v1/jobs?limit=100", {
    headers: { "X-API-Key": apiKey }
  });
  if (!response.ok) {
    const detail = await response.json().catch(() => ({}));
    throw new Error(detail.detail || `Failed to list jobs (${response.status})`);
  }
  return response.json();
}

function formatTimestamp(value) {
  if (!value) {
    return "";
  }
  const dt = new Date(value);
  if (Number.isNaN(dt.getTime())) {
    return value;
  }
  return dt.toLocaleString();
}

function computeBaselineSize(job, name) {
  const originalBytes = job.original_size_bytes;
  const ratio = job.metrics?.[`baseline.${name}_ratio`];
  if (!originalBytes || !ratio || ratio <= 0) {
    return null;
  }
  return originalBytes / ratio;
}

function updateSummary(jobs) {
  const jobCount = jobs.length;
  jobsCountEl.textContent = `${jobCount} job${jobCount === 1 ? "" : "s"}`;
  if (!jobCount) {
    headlineRatioEl.textContent = "—";
    ratioImprovementEl.textContent = "—";
    bestBaselineEl.textContent = "—";
    summaryNoteEl.textContent = "Upload a CSV to view live metrics.";
    return;
  }

  const ordered = [...jobs].sort((a, b) => {
    const aTime = new Date(a.updated_at || a.created_at || 0).getTime();
    const bTime = new Date(b.updated_at || b.created_at || 0).getTime();
    return bTime - aTime;
  });
  const latest = ordered.find((job) => typeof job.compression_ratio === "number") || ordered[0];
  if (!latest || typeof latest.compression_ratio !== "number") {
    headlineRatioEl.textContent = "—";
    ratioImprovementEl.textContent = "—";
    bestBaselineEl.textContent = "—";
    summaryNoteEl.textContent = "Awaiting Sempress results.";
    return;
  }

  const originalBytes = latest.original_size_bytes;
  const sempressBytes = latest.artifact_sizes?.smp;
  const sempressRatio = latest.compression_ratio;

  if (sempressBytes != null) {
    headlineRatioEl.textContent = formatBytes(sempressBytes);
    if (originalBytes) {
      summaryNoteEl.textContent = `From ${formatBytes(originalBytes)} → ${formatBytes(sempressBytes)} (${sempressRatio.toFixed(2)}×)`;
    } else {
      summaryNoteEl.textContent = `Sempress ratio ${sempressRatio.toFixed(2)}×`;
    }
  } else {
    headlineRatioEl.textContent = `${sempressRatio.toFixed(2)}×`;
    summaryNoteEl.textContent = "Sempress ratio available; upload again for size stats.";
  }

  const baselines = Object.entries(latest.metrics || {}).filter(([key, value]) => key.startsWith("baseline.") && typeof value === "number");
  if (!baselines.length) {
    ratioImprovementEl.textContent = "—";
    bestBaselineEl.textContent = "No baselines";
    return;
  }

  const mapped = baselines.map(([key, value]) => {
    const label = key.replace("baseline.", "").replace("_ratio", "").toUpperCase();
    return { label, value };
  }).sort((a, b) => b.value - a.value);

  const best = mapped[0];
  const bestSize = computeBaselineSize(latest, best.label.toLowerCase());
  if (bestSize != null) {
    bestBaselineEl.textContent = `${best.label} ${formatBytes(bestSize)} (${best.value.toFixed(2)}×)`;
  } else {
    bestBaselineEl.textContent = `${best.label} ${best.value.toFixed(2)}×`;
  }

  if (sempressBytes != null && bestSize != null && bestSize > 0) {
    const savings = 1 - (sempressBytes / bestSize);
    if (savings >= 0) {
      ratioImprovementEl.textContent = `${(savings * 100).toFixed(1)}% smaller than ${best.label}`;
    } else {
      ratioImprovementEl.textContent = `${Math.abs(savings * 100).toFixed(1)}% larger than ${best.label}`;
    }
  } else {
    const lift = ((sempressRatio - best.value) / best.value) * 100;
    ratioImprovementEl.textContent = `${lift >= 0 ? "+" : ""}${lift.toFixed(1)}% vs ${best.label}`;
  }
}

function renderTable(jobs) {
  tableBody.innerHTML = "";
  for (const job of jobs) {
    const tr = document.createElement("tr");
    const artifacts = (job.artifacts || []).map((artifact) => {
      return `<a href="${artifact.url}" target="_blank" rel="noopener">${artifact.type}</a>`;
    }).join("");

    const originalBytes = job.original_size_bytes;
    const sempressBytes = job.artifact_sizes?.smp;
    const sempressRatio = job.compression_ratio;
    let sempressDisplay = "—";
    if (sempressBytes != null) {
      const ratioText = typeof sempressRatio === "number" ? ` (${sempressRatio.toFixed(3)}×)` : "";
      sempressDisplay = `${formatBytes(sempressBytes)}${ratioText}`;
    } else if (typeof sempressRatio === "number") {
      sempressDisplay = `${sempressRatio.toFixed(3)}×`;
    }

    const baselineLines = [];
    if (originalBytes != null) {
      baselineLines.push(`Original: ${formatBytes(originalBytes)}`);
    }
    Object.entries(job.metrics || {})
      .filter(([key, value]) => key.startsWith("baseline.") && typeof value === "number")
      .forEach(([key, value]) => {
        const name = key.replace("baseline.", "").replace("_ratio", "").toUpperCase();
        const sizeBytes = computeBaselineSize(job, name.toLowerCase());
        const sizeText = sizeBytes != null ? formatBytes(sizeBytes) : "—";
        baselineLines.push(`${name}: ${sizeText} (${value.toFixed(3)}×)`);
      });

    tr.innerHTML = `
      <td>${job.job_id}</td>
      <td><span class="badge">${job.status}</span></td>
      <td>${sempressDisplay}</td>
      <td>${baselineLines.join("<br />") || "—"}</td>
      <td>${formatTimestamp(job.updated_at)}</td>
      <td class="downloads">${artifacts || "—"}</td>
    `;
    tableBody.appendChild(tr);
  }
}

function renderChart(jobs) {
  const labels = jobs.map((job) => job.job_id);
  const datasetDefs = [
    { key: "original", label: "Original CSV", color: "#475569" },
    { key: "sempress", label: "Sempress", color: "#0d9488" },
    { key: "gzip", label: "GZIP", color: "#0ea5e9" },
    { key: "bz2", label: "BZIP2", color: "#ef4444" },
    { key: "lzma", label: "LZMA", color: "#f59e0b" },
    { key: "zstd", label: "Zstandard", color: "#a855f7" }
  ];

  const datasets = datasetDefs
    .map((def) => {
      const data = jobs.map((job) => {
        if (def.key === "original") {
          return bytesToKB(job.original_size_bytes);
        }
        if (def.key === "sempress") {
          return bytesToKB(job.artifact_sizes?.smp);
        }
        const sizeBytes = computeBaselineSize(job, def.key);
        return bytesToKB(sizeBytes);
      });
      const hasData = data.some((value) => typeof value === "number" && !Number.isNaN(value));
      if (!hasData) {
        return null;
      }
      return {
        label: def.label,
        data,
        backgroundColor: def.color,
      };
    })
    .filter(Boolean);

  const chartOptions = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: { labels: { color: "#cbd5f5" } },
      tooltip: {
        callbacks: {
          label(context) {
            const valueKB = context.parsed.y ?? context.parsed;
            const bytes = typeof valueKB === "number" ? valueKB * 1024 : null;
            return `${context.dataset.label}: ${formatBytes(bytes)}`;
          }
        }
      }
    },
    scales: {
      x: { ticks: { color: "#cbd5f5" }, grid: { color: "#1e293b" } },
      y: {
        ticks: {
          color: "#cbd5f5",
          callback(value) {
            if (value >= 1024) {
              return `${(value / 1024).toFixed(1)} MB`;
            }
            return `${value.toFixed(0)} KB`;
          }
        },
        grid: { color: "#1e293b" },
        title: { display: true, text: "Compressed size (KB)", color: "#94a3b8" }
      }
    }
  };

  const ctx = document.getElementById("ratio-chart").getContext("2d");
  if (state.chart) {
    state.chart.data.labels = labels;
    state.chart.data.datasets = datasets;
    state.chart.options = chartOptions;
    state.chart.update();
  } else {
    state.chart = new Chart(ctx, {
      type: "bar",
      data: { labels, datasets },
      options: chartOptions
    });
  }
}

async function purgeJobs(apiKey) {
  if (!apiKey) {
    setStatus("Enter an API key to purge jobs.", "error");
    return;
  }
  try {
    setStatus("Purging jobs…");
    const response = await fetch("/v1/jobs", {
      method: "DELETE",
      headers: { "X-API-Key": apiKey }
    });
    if (!response.ok) {
      const detail = await response.json().catch(() => ({}));
      throw new Error(detail.detail || `Failed to purge jobs (${response.status})`);
    }
    const data = await response.json();
    setStatus(`Purged ${data.purged} job(s).`, "info");
    await refreshJobs(apiKey);
  } catch (error) {
    console.error(error);
    setStatus(error.message, "error");
  }
}

async function refreshJobs(apiKey) {
  if (!apiKey) {
    setStatus("Enter an API key to refresh jobs.", "error");
    return;
  }
  try {
    setStatus("Loading jobs…");
    const jobs = await fetchJobs(apiKey);
    updateSummary(jobs);
    renderTable(jobs);
    renderChart(jobs);
    setStatus(`Loaded ${jobs.length} job(s).`);
  } catch (error) {
    console.error(error);
    setStatus(error.message, "error");
  }
}

async function pollJobUntilDone(jobId, apiKey) {
  if (state.pollers.has(jobId)) {
    return state.pollers.get(jobId);
  }
  const promise = (async () => {
    while (true) {
      const summary = await fetchJob(jobId, apiKey);
      if (["succeeded", "failed", "cancelled"].includes(summary.status)) {
        return summary;
      }
      await new Promise((resolve) => setTimeout(resolve, 1500));
    }
  })();
  state.pollers.set(jobId, promise);
  const result = await promise;
  state.pollers.delete(jobId);
  return result;
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const apiKeyInput = document.getElementById("api-key");
  const fileInput = document.getElementById("input-file");
  const lockInput = document.getElementById("lock-cols");
  const residualInput = document.getElementById("residual-cols");
  const kInput = document.getElementById("k-value");
  const uncertaintyInput = document.getElementById("uncertainty");
  const webhookInput = document.getElementById("webhook-url");
  const metadataInput = document.getElementById("metadata");

  const apiKey = apiKeyInput.value.trim();
  if (!apiKey) {
    setStatus("API key is required.", "error");
    return;
  }
  if (!fileInput.files.length) {
    setStatus("Select a CSV file to upload.", "error");
    return;
  }

  state.apiKey = apiKey;
  const file = fileInput.files[0];
  const payload = {
    source: { mode: "upload", filename: file.name },
    config: {
      lock_cols: collectColumns(lockInput.value),
      residual_cols: collectColumns(residualInput.value),
      k: Number(kInput.value) || 64,
      uncertainty_threshold: Number(uncertaintyInput.value) || 0.2
    },
    metadata: {},
    webhook_url: webhookInput.value.trim() || null
  };

  if (metadataInput.value.trim()) {
    try {
      payload.metadata = JSON.parse(metadataInput.value);
    } catch (error) {
      setStatus("Metadata must be valid JSON.", "error");
      return;
    }
  }

  try {
    setStatus("Creating job…");
    const job = await createJob(payload, apiKey);
    setStatus(`Job ${job.job_id} created. Uploading file…`);

    if (job.upload_url) {
      await uploadFile(job.upload_url, file, apiKey);
      setStatus(`Upload complete. Processing job ${job.job_id}…`);
    } else {
      setStatus(`Processing job ${job.job_id}…`);
    }

    const result = await pollJobUntilDone(job.job_id, apiKey);
    if (result.status === "succeeded") {
      setStatus(`Job ${job.job_id} completed successfully.`, "info");
    } else {
      setStatus(`Job ${job.job_id} finished with status ${result.status}.`, "error");
    }
    await refreshJobs(apiKey);
  } catch (error) {
    console.error(error);
    setStatus(error.message, "error");
  }
});

purgeBtn.addEventListener("click", async () => {
  const apiKey = document.getElementById("api-key").value.trim() || state.apiKey;
  state.apiKey = apiKey;
  purgeBtn.disabled = true;
  try {
    await purgeJobs(apiKey);
  } finally {
    purgeBtn.disabled = false;
  }
});

refreshBtn.addEventListener("click", () => {
  const apiKey = document.getElementById("api-key").value.trim() || state.apiKey;
  state.apiKey = apiKey;
  refreshJobs(apiKey);
});
</script>
</body>
</html>
"""

async def _cleanup_expired_artifacts() -> None:
    now = _utc_now()
    cleanup_targets: List[Tuple[JobRecord, List[Path]]] = []

    async with _STORE_LOCK:
        for record in _JOB_STORE.values():
            if record.summary.status != JobStatus.succeeded:
                continue
            if record.summary.expires_at >= now:
                continue
            if not record.summary.artifacts and not record.artifact_tokens:
                continue

            file_paths: List[Path] = []
            for key in ("smp", "reconstruction", "report"):
                meta = record.summary.artifact_meta.pop(key, None)
                if meta:
                    file_paths.append(Path(meta.path))
            record.summary.artifacts = []
            record.artifact_tokens = {}
            cleanup_targets.append((record, file_paths))

    for record, paths in cleanup_targets:
        for path in paths:
            try:
                path.unlink()
            except FileNotFoundError:
                continue
        await _save_job(record)


_init_db()
_load_jobs_from_db()
