#!/usr/bin/env python3
"""
UHT Discovery - NiceGUI web interface.
"""

import os
import shutil
import tempfile
import threading
import zipfile
from contextlib import contextmanager
from pathlib import Path
from typing import Callable, List, Optional

# NiceGUI runs workflow code in worker threads; force a headless Matplotlib backend
# to avoid macOS AppKit crashes ("NSWindow should only be instantiated on main thread").
os.environ.setdefault("MPLBACKEND", "Agg")

import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import numpy as np
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from nicegui import app, run, ui

from uht_discovery.run_isolation import RunIsolationContext, generate_run_id, get_workdir_base

APPLE_CSS = """
:root {
    --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Helvetica, Arial, sans-serif;
    --bg-primary: #ffffff;
    --bg-secondary: #f5f5f7;
    --bg-glass: rgba(255, 255, 255, 0.72);
    --bg-glass-heavy: rgba(255, 255, 255, 0.85);
    --text-primary: #1d1d1f;
    --text-secondary: #6e6e73;
    --accent: #0071e3;
    --accent-hover: #0077ed;
    --border: rgba(0, 0, 0, 0.08);
    --border-input: rgba(0, 0, 0, 0.12);
    --shadow-md: 0 4px 14px rgba(0, 0, 0, 0.06);
    --radius-input: 8px;
    --radius-card: 12px;
    --sidebar-width: 260px;
    --content-max-width: 900px;
}
[data-theme="dark"] {
    --bg-primary: #1d1d1f;
    --bg-secondary: #2d2d2f;
    --bg-glass: rgba(29, 29, 31, 0.72);
    --bg-glass-heavy: rgba(29, 29, 31, 0.85);
    --text-primary: #f5f5f7;
    --text-secondary: #a1a1a6;
    --accent: #2997ff;
    --accent-hover: #40a9ff;
    --border: rgba(255, 255, 255, 0.08);
    --border-input: rgba(255, 255, 255, 0.12);
}
body, .nicegui-content {
    font-family: var(--font-sans) !important;
    background: var(--bg-secondary) !important;
    color: var(--text-primary) !important;
    margin: 0;
}
.apple-sidebar {
    position: fixed;
    top: 0;
    left: 0;
    width: var(--sidebar-width);
    height: 100vh;
    background: var(--bg-glass-heavy);
    border-right: 1px solid var(--border);
    z-index: 100;
    display: flex;
    flex-direction: column;
    overflow-y: auto;
    padding: 24px 16px 16px;
    box-sizing: border-box;
}
.apple-sidebar .sidebar-brand {
    font-size: 20px;
    font-weight: 700;
    color: var(--text-primary);
    margin-bottom: 4px;
}
.apple-sidebar .sidebar-subtitle {
    font-size: 12px;
    color: var(--text-secondary);
    margin-bottom: 22px;
}
.apple-sidebar .sidebar-section {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    color: var(--text-secondary);
    margin: 16px 0 8px 8px;
}
.apple-sidebar .sidebar-item {
    display: flex;
    align-items: center;
    padding: 8px 12px;
    border-radius: 8px;
    font-size: 14px;
    font-weight: 500;
    color: var(--text-primary);
    text-decoration: none;
    margin-bottom: 2px;
}
.apple-sidebar .sidebar-item:hover {
    background: var(--border);
}
.apple-sidebar .sidebar-item.active {
    background: var(--accent);
    color: #ffffff;
}
.sidebar-spacer {
    flex: 1;
}
.theme-toggle {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    border-radius: 8px;
    color: var(--text-secondary);
    cursor: pointer;
}
.theme-toggle:hover {
    background: var(--border);
}
.apple-content {
    margin-left: var(--sidebar-width);
    padding: 32px 36px;
    min-height: 100vh;
    box-sizing: border-box;
}
.apple-content-inner {
    max-width: var(--content-max-width);
    margin: 0 auto;
}
.apple-card {
    background: var(--bg-glass);
    border: 1px solid var(--border);
    border-radius: var(--radius-card);
    box-shadow: var(--shadow-md);
    padding: 24px 26px;
    margin-bottom: 18px;
}
.apple-card-title {
    font-size: 22px;
    font-weight: 700;
    color: var(--text-primary);
    margin: 0 0 4px 0;
}
.apple-card-subtitle {
    font-size: 14px;
    color: var(--text-secondary);
    margin: 0 0 18px 0;
}
.apple-field .q-field__control {
    border: 1px solid var(--border-input) !important;
    border-radius: var(--radius-input) !important;
    background: var(--bg-primary) !important;
}
.apple-btn {
    background: var(--accent) !important;
    color: #fff !important;
    border-radius: 999px !important;
    padding: 9px 18px !important;
    font-weight: 600 !important;
}
.apple-btn:hover {
    background: var(--accent-hover) !important;
}
.apple-upload .q-uploader {
    border: 2px dashed var(--border-input);
    border-radius: 10px;
    background: var(--bg-primary);
}
.apple-status {
    color: var(--text-secondary);
    font-size: 13px;
}
.apple-results a {
    color: var(--accent);
    text-decoration: none;
}
.apple-loading {
    display: flex;
    align-items: center;
    gap: 10px;
    color: var(--text-secondary);
    font-size: 13px;
    margin-top: 8px;
}
.apple-plot-wrap {
    margin-top: 16px;
}
@media (max-width: 980px) {
    .apple-sidebar {
        position: static;
        width: 100%;
        height: auto;
        border-right: none;
        border-bottom: 1px solid var(--border);
    }
    .apple-content {
        margin-left: 0;
        padding: 18px;
    }
}
"""

_NAV_GROUPS: list[tuple[str, list[tuple[str, str, str]]]] = [
    (
        "Workflows",
        [
            ("/", "BLASTer", "travel_explore"),
            ("/trim", "Trim", "content_cut"),
            ("/cluster", "pLM-clust v2", "hub"),
        ],
    )
]


class UploadBuffer:
    ALLOWED_SUFFIXES = {".fasta", ".fa", ".faa"}

    def __init__(self) -> None:
        self.files: List[Path] = []
        self.temp_dirs: List[Path] = []

    def clear(self) -> None:
        self.files.clear()
        for tmp in self.temp_dirs:
            try:
                resolved = tmp.resolve()
                tmp_root = Path(tempfile.gettempdir()).resolve()
                if tmp_root in resolved.parents and resolved.name.startswith("uht_gui_upload_"):
                    shutil.rmtree(resolved, ignore_errors=True)
            except Exception:
                pass
        self.temp_dirs.clear()

    def add_uploaded_file(self, name: str, content: bytes) -> Path:
        filename = Path(name).name
        suffix = Path(filename).suffix.lower()
        if suffix not in self.ALLOWED_SUFFIXES:
            raise ValueError(f"Unsupported file type: {suffix or 'unknown'}")
        tmp_dir = Path(tempfile.mkdtemp(prefix="uht_gui_upload_"))
        tmp_path = tmp_dir / filename
        with open(tmp_path, "wb") as f:
            f.write(content)
        self.temp_dirs.append(tmp_dir)
        self.files.append(tmp_path)
        return tmp_path

    def has_files(self) -> bool:
        return len(self.files) > 0


MAX_UPLOAD_FILE_SIZE = 20 * 1024 * 1024
MAX_UPLOAD_FILES = 20
_ACTIVE_RUN_LOCK = threading.Lock()


def _write_uploads(buffer: UploadBuffer, dest_dir: Path) -> List[Path]:
    dest_dir.mkdir(parents=True, exist_ok=True)
    written: List[Path] = []
    for source_path in buffer.files:
        dest_path = dest_dir / source_path.name
        shutil.copy2(source_path, dest_path)
        written.append(dest_path)
    return written

def _list_result_files(run_dir: Path, module: str) -> List[Path]:
    results_dir = run_dir / "results" / module
    if not results_dir.exists():
        return []
    return [p for p in results_dir.rglob("*") if p.is_file()]


def _relative_to_workdir(path: Path) -> str:
    base = get_workdir_base().resolve()
    try:
        rel = path.resolve().relative_to(base)
    except Exception:
        rel = path.name
    return f"/files/{rel.as_posix()}"


def _projection_files(results_root: Path, prefix: str) -> List[Path]:
    return sorted(results_root.rglob(f"{prefix}_coordinates_*.csv"))


def _projection_figure(df: pd.DataFrame, title: str) -> object:
    safe_df = df.copy()
    safe_df["cluster"] = safe_df["cluster"].astype(str)
    hover_cols = [col for col in ["header", "nll_score"] if col in safe_df.columns]
    fig = px.scatter(
        safe_df,
        x="Dim1",
        y="Dim2",
        color="cluster",
        hover_data=hover_cols,
        title=title,
        color_discrete_sequence=px.colors.qualitative.Bold,
    )
    fig.update_traces(marker={"size": 8, "opacity": 0.85})
    fig.update_layout(
        paper_bgcolor="rgba(0,0,0,0)",
        plot_bgcolor="rgba(0,0,0,0)",
        legend_title_text="Cluster",
        margin={"l": 16, "r": 16, "t": 50, "b": 16},
    )
    return fig


def _fasta_lengths(fasta_path: Path) -> List[int]:
    lengths: List[int] = []
    curr = 0
    with open(fasta_path, "r", encoding="utf-8", errors="ignore") as handle:
        for line in handle:
            line = line.strip()
            if not line:
                continue
            if line.startswith(">"):
                if curr > 0:
                    lengths.append(curr)
                curr = 0
            else:
                curr += len(line)
    if curr > 0:
        lengths.append(curr)
    return lengths


def _smart_bin_count(lengths: List[int]) -> int:
    if len(lengths) < 2:
        return 10
    arr = np.array(lengths, dtype=float)
    q75, q25 = np.percentile(arr, [75, 25])
    iqr = q75 - q25
    if iqr <= 0:
        return int(max(10, min(50, np.sqrt(len(arr)))))
    bin_width = 2 * iqr / np.cbrt(len(arr))
    if bin_width <= 0:
        return int(max(10, min(50, np.sqrt(len(arr)))))
    bins = int(np.ceil((arr.max() - arr.min()) / bin_width))
    return max(10, min(80, bins))


def _trim_histogram_figure(lengths: List[int], filename: str) -> object:
    arr = np.array(lengths, dtype=float)
    bins = _smart_bin_count(lengths)
    q05, q95 = np.percentile(arr, [5, 95])
    fig = go.Figure()
    fig.add_trace(
        go.Histogram(
            x=arr,
            nbinsx=bins,
            marker={"color": "#0071e3", "line": {"color": "rgba(0,0,0,0.15)", "width": 1}},
            opacity=0.9,
            name="Sequence length",
        )
    )
    fig.add_vline(x=float(q05), line={"color": "#f97316", "dash": "dash"}, annotation_text="Q5")
    fig.add_vline(x=float(q95), line={"color": "#16a34a", "dash": "dash"}, annotation_text="Q95")
    fig.update_layout(
        title=f"Length Distribution: {filename}",
        xaxis_title="Sequence length (aa)",
        yaxis_title="Count",
        paper_bgcolor="rgba(0,0,0,0)",
        plot_bgcolor="rgba(0,0,0,0)",
        bargap=0.03,
        margin={"l": 16, "r": 16, "t": 50, "b": 16},
    )
    return fig


def _render_trim_histogram(
    fasta_path: Path,
    container: ui.column,
    low_input: ui.number,
    high_input: ui.number,
    status: ui.label,
) -> None:
    lengths = _fasta_lengths(fasta_path)
    container.clear()
    if not lengths:
        with container:
            ui.label("No sequences found in uploaded FASTA.").classes("apple-status")
        status.set_text("Uploaded file contains no FASTA sequences.")
        return

    arr = np.array(lengths, dtype=float)
    q05, q95 = np.percentile(arr, [5, 95])
    low_input.value = int(round(q05))
    high_input.value = int(round(q95))

    with container:
        ui.label("Length Histogram (smart bins + suggested thresholds)").classes("apple-status")
        ui.plotly(_trim_histogram_figure(lengths, fasta_path.name)).classes("w-full apple-plot-wrap")

    status.set_text(
        f"Histogram ready for {fasta_path.name}. Suggested range: {int(round(q05))}-{int(round(q95))}."
    )


def _render_projection_section(results_root: Path) -> None:
    tsne_files = _projection_files(results_root, "tsne")
    umap_files = _projection_files(results_root, "umap")
    if not tsne_files and not umap_files:
        return

    ui.label("Interactive Projections").classes("apple-card-subtitle")

    with ui.tabs().classes("w-full") as plot_tabs:
        tsne_tab = ui.tab("t-SNE")
        umap_tab = ui.tab("UMAP")

    with ui.tab_panels(plot_tabs, value=tsne_tab).classes("w-full apple-plot-wrap"):
        with ui.tab_panel(tsne_tab):
            if not tsne_files:
                ui.label("No t-SNE coordinates available for this run.").classes("apple-status")
            else:
                for csv_path in tsne_files:
                    df = pd.read_csv(csv_path)
                    title = f"t-SNE: {csv_path.parent.name}"
                    ui.plotly(_projection_figure(df, title)).classes("w-full")

        with ui.tab_panel(umap_tab):
            if not umap_files:
                ui.label("No UMAP coordinates available for this run.").classes("apple-status")
            else:
                for csv_path in umap_files:
                    df = pd.read_csv(csv_path)
                    title = f"UMAP: {csv_path.parent.name}"
                    ui.plotly(_projection_figure(df, title)).classes("w-full")


def _render_results(container: ui.column, run_dir: Path, module: str) -> None:
    container.clear()
    results_root = run_dir / "results" / module
    files = _list_result_files(run_dir, module)
    with container:
        if not files:
            ui.label("No result files found.").classes("apple-status")
            return
        bundle_path = run_dir / f"{module}_results_bundle.zip"
        with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as zf:
            for file_path in files:
                zf.write(file_path, arcname=file_path.relative_to(run_dir))

        with ui.row().classes("items-center gap-3"):
            ui.label(f"Results ready ({len(files)} files bundled)").classes("apple-status")
            ui.button(
                "Download All Results (.zip)",
                on_click=lambda p=str(bundle_path): ui.download(p),
            ).classes("apple-btn").props("unelevated no-caps")
        if module == "plmclustv2":
            _render_projection_section(results_root)


def _ensure_workdir() -> Path:
    base = get_workdir_base()
    base.mkdir(parents=True, exist_ok=True)
    return base


@contextmanager
def apple_card(title: str, subtitle: str):
    with ui.element("div").classes("apple-card"):
        ui.label(title).classes("apple-card-title")
        if subtitle:
            ui.label(subtitle).classes("apple-card-subtitle")
        yield


def apple_input(label: str, value: str = "", placeholder: str = "") -> ui.input:
    el = ui.input(label=label, value=value, placeholder=placeholder)
    el.classes("apple-field w-full")
    el.props("outlined dense")
    return el


def apple_number(label: str, value: float, min_val: Optional[float] = None,
                 max_val: Optional[float] = None, step: float = 1.0) -> ui.number:
    kwargs = {"label": label, "value": value, "step": step}
    if min_val is not None:
        kwargs["min"] = min_val
    if max_val is not None:
        kwargs["max"] = max_val
    el = ui.number(**kwargs)
    el.classes("apple-field w-full")
    el.props("outlined dense")
    return el


def apple_select(label: str, options: List[str], value: str) -> ui.select:
    el = ui.select(options, value=value, label=label)
    el.classes("apple-field w-full")
    el.props("outlined dense")
    return el


def apple_upload(
    buffer: UploadBuffer,
    label: str,
    on_uploaded: Optional[Callable[[Path], None]] = None,
) -> ui.upload:
    async def handle_upload(e) -> None:
        size = int(e.file.size())
        if size > MAX_UPLOAD_FILE_SIZE:
            size_mb = round(size / (1024 * 1024), 2)
            ui.notify(f"File too large: {size_mb} MB (max 20 MB)", color="negative")
            return
        try:
            content = await e.file.read()
            uploaded_path = buffer.add_uploaded_file(e.file.name, content)
            if on_uploaded is not None:
                on_uploaded(uploaded_path)
            ui.notify(f"Uploaded: {Path(e.file.name).name}", color="positive")
        except ValueError as err:
            ui.notify(str(err), color="negative")
            ui.notify("Accepted extensions: .fasta, .fa, .faa", color="warning")

    upload = ui.upload(
        label=label,
        multiple=True,
        on_upload=handle_upload,
        auto_upload=True,
        max_file_size=MAX_UPLOAD_FILE_SIZE,
        max_files=MAX_UPLOAD_FILES,
        on_rejected=lambda: ui.notify(
            "Upload rejected. Use FASTA files only and keep each file under 20 MB.",
            color="negative",
        ),
    ).classes("apple-upload")
    upload.props("accept=.fasta,.fa,.faa")
    return upload


def apple_button(label: str, on_click):
    return ui.button(label, on_click=on_click).classes("apple-btn").props("unelevated no-caps")


def _run_blaster(buffer: UploadBuffer, num_hits: int, blast_db: str, evalue: float,
                 email: str, api_key: Optional[str], status: ui.label,
                 results_container: ui.column) -> None:
    from uht_discovery.core.BLASTer import run as blaster_run

    if not buffer.files:
        status.set_text("Please upload at least one FASTA file.")
        return
    run_id = generate_run_id()
    status.set_text(f"Run {run_id} started...")
    with RunIsolationContext(run_id) as run_ctx:
        _write_uploads(buffer, run_ctx.get_input_path("blaster"))
        cfg = {
            "blaster_project_directory": run_id,
            "blaster_num_hits": num_hits,
            "blaster_blast_db": blast_db,
            "blaster_evalue": evalue,
            "blaster_email": email,
            "blaster_api_key": api_key,
        }
        blaster_run(cfg)
        status.set_text(f"Run {run_id} completed.")
        _render_results(results_container, run_ctx.run_dir, "blaster")


def _run_trim(buffer: UploadBuffer, auto_mode: bool, low: Optional[int],
              high: Optional[int], status: ui.label,
              results_container: ui.column) -> None:
    from uht_discovery.core.trim import run as trim_run

    if not buffer.files:
        status.set_text("Please upload at least one FASTA file.")
        return
    run_id = generate_run_id()
    status.set_text(f"Run {run_id} started...")
    with RunIsolationContext(run_id) as run_ctx:
        _write_uploads(buffer, run_ctx.get_input_path("trim"))
        cfg = {
            "trim_project_directory": run_id,
            "auto_mode": auto_mode,
            "trim_low": low,
            "trim_high": high,
        }
        trim_run(cfg)
        status.set_text(f"Run {run_id} completed.")
        _render_results(results_container, run_ctx.run_dir, "trim")


def _run_plmclust(buffer: UploadBuffer, cluster_number: str, silhouette_min: int,
                  silhouette_max: int, keep_separate: bool,
                  status: ui.label, results_container: ui.column) -> None:
    from uht_discovery.core.plmclustv2 import run as plmclustv2_run

    if not buffer.files:
        status.set_text("Please upload at least one FASTA file.")
        return
    run_id = generate_run_id()
    status.set_text(f"Run {run_id} started...")
    with RunIsolationContext(run_id) as run_ctx:
        _write_uploads(buffer, run_ctx.get_input_path("plmclustv2"))
        cfg = {
            "plmclustv2_project_directory": run_id,
            "plmclustv2_cluster_number": cluster_number,
            "silhouette_range": [silhouette_min, silhouette_max],
            "plmclustv2_keepseparate": keep_separate,
        }
        plmclustv2_run(cfg)
        status.set_text(f"Run {run_id} completed.")
        _render_results(results_container, run_ctx.run_dir, "plmclustv2")


def _build_sidebar(current_path: str) -> None:
    with ui.element("nav").classes("apple-sidebar"):
        ui.label("UHT Discovery").classes("sidebar-brand")
        ui.label("Protein workflow interface").classes("sidebar-subtitle")

        for group_label, items in _NAV_GROUPS:
            ui.label(group_label).classes("sidebar-section")
            for route, label, icon_name in items:
                active = " active" if current_path == route else ""
                with ui.link(target=route).classes(f"sidebar-item{active}").style("text-decoration: none;"):
                    ui.icon(icon_name).style("font-size: 18px;")
                    ui.label(label).style("margin-left: 10px;")

        ui.element("div").classes("sidebar-spacer")

        dark = ui.dark_mode()

        def _get_saved_theme() -> str:
            try:
                return app.storage.user.get("theme", "light")
            except RuntimeError:
                return "light"

        def _set_saved_theme(theme: str) -> None:
            try:
                app.storage.user["theme"] = theme
            except RuntimeError:
                return

        def apply_saved_theme() -> None:
            theme = _get_saved_theme()
            if theme == "dark":
                dark.enable()
            else:
                dark.disable()
            ui.run_javascript(f"document.documentElement.setAttribute('data-theme', '{theme}')")

        def toggle_theme() -> None:
            current = _get_saved_theme()
            new_theme = "dark" if current != "dark" else "light"
            _set_saved_theme(new_theme)
            if new_theme == "dark":
                dark.enable()
            else:
                dark.disable()
            ui.run_javascript(f"document.documentElement.setAttribute('data-theme', '{new_theme}')")

        apply_saved_theme()

        with ui.element("div").classes("theme-toggle").on("click", toggle_theme):
            ui.icon("dark_mode").style("font-size: 18px;")
            ui.label("Toggle Dark Mode")


def _page_wrapper(current_path: str):
    ui.add_head_html(f"<style>{APPLE_CSS}</style>")
    _build_sidebar(current_path)
    return ui.element("main").classes("apple-content")


def _render_blaster_form() -> None:
    buffer = UploadBuffer()
    with apple_card("BLASTer", "Search NCBI and download combined sequence hits"):
        status = ui.label("Upload FASTA files and run BLAST.").classes("apple-status")
        results_container = ui.column().classes("mt-2")
        loading = ui.row().classes("apple-loading")
        with loading:
            ui.spinner("dots", size="1.2em")
            ui.label("Running workflow...")
        loading.set_visibility(False)

        apple_upload(buffer, "Upload FASTA query files")
        num_hits = apple_number("Hits per query", value=200, min_val=1, step=1)
        blast_db = apple_select("BLAST DB", ["nr", "swissprot", "pdb", "refseq_protein"], "nr")
        evalue = apple_number("E-value", value=1e-5, step=1e-6)
        email = apple_input("NCBI email", placeholder="you@example.com")
        api_key = apple_input("NCBI API key (optional)")

        async def run_job() -> None:
            acquired = _ACTIVE_RUN_LOCK.acquire(blocking=False)
            if not acquired:
                status.set_text("Another workflow is currently running. Please wait until it finishes.")
                ui.notify("Another workflow is running. Please wait.", color="warning")
                return
            status.set_text("Starting BLASTer...")
            loading.set_visibility(True)
            run_btn.disable()
            try:
                await run.io_bound(
                    _run_blaster,
                    buffer,
                    int(num_hits.value),
                    blast_db.value,
                    float(evalue.value),
                    email.value or "",
                    api_key.value or None,
                    status,
                    results_container,
                )
                buffer.clear()
            except Exception as err:
                status.set_text(f"Run failed: {err}")
            finally:
                _ACTIVE_RUN_LOCK.release()
                loading.set_visibility(False)
                run_btn.enable()

        run_btn = apple_button("Run BLASTer", run_job)


def _render_trim_form() -> None:
    buffer = UploadBuffer()
    with apple_card("Trim", "Filter sequences by automated or manual length range"):
        status = ui.label("Upload FASTA files to trim.").classes("apple-status")
        results_container = ui.column().classes("mt-2")
        loading = ui.row().classes("apple-loading")
        with loading:
            ui.spinner("dots", size="1.2em")
            ui.label("Running workflow...")
        loading.set_visibility(False)

        auto_mode = ui.checkbox("Auto-detect trim range", value=True)
        low = apple_number("Manual low", value=100, step=1)
        high = apple_number("Manual high", value=500, step=1)
        hist_container = ui.column().classes("mt-2")
        suggested = {"low": 100, "high": 500}

        def on_trim_uploaded(uploaded_path: Path) -> None:
            _render_trim_histogram(uploaded_path, hist_container, low, high, status)
            suggested["low"] = int(low.value)
            suggested["high"] = int(high.value)

        apple_upload(buffer, "Upload FASTA files", on_uploaded=on_trim_uploaded)

        with ui.row().classes("items-center gap-2 mt-2"):
            ui.button(
                "Use Suggested Thresholds",
                on_click=lambda: (
                    setattr(low, "value", suggested["low"]),
                    setattr(high, "value", suggested["high"]),
                    status.set_text(f"Applied suggested range: {suggested['low']}-{suggested['high']}."),
                ),
            ).classes("apple-btn").props("unelevated no-caps")
            ui.button(
                "Reset Manual Defaults",
                on_click=lambda: (
                    setattr(low, "value", 100),
                    setattr(high, "value", 500),
                    status.set_text("Reset to manual defaults: 100-500."),
                ),
            ).classes("apple-btn").props("unelevated no-caps")

        def _toggle_manual() -> None:
            low.set_enabled(not auto_mode.value)
            high.set_enabled(not auto_mode.value)

        auto_mode.on("update:model-value", lambda _: _toggle_manual())
        _toggle_manual()

        async def run_job() -> None:
            acquired = _ACTIVE_RUN_LOCK.acquire(blocking=False)
            if not acquired:
                status.set_text("Another workflow is currently running. Please wait until it finishes.")
                ui.notify("Another workflow is running. Please wait.", color="warning")
                return
            status.set_text("Starting Trim...")
            loading.set_visibility(True)
            run_btn.disable()
            try:
                manual_low = None if auto_mode.value else int(low.value)
                manual_high = None if auto_mode.value else int(high.value)
                await run.io_bound(
                    _run_trim,
                    buffer,
                    bool(auto_mode.value),
                    manual_low,
                    manual_high,
                    status,
                    results_container,
                )
                buffer.clear()
            except Exception as err:
                status.set_text(f"Run failed: {err}")
            finally:
                _ACTIVE_RUN_LOCK.release()
                loading.set_visibility(False)
                run_btn.enable()

        run_btn = apple_button("Run Trim", run_job)


def _render_cluster_form() -> None:
    buffer = UploadBuffer()
    with apple_card("pLM-clust v2", "Cluster sequences with ESM2 embeddings"):
        status = ui.label("Upload FASTA files for pLM-clust v2.").classes("apple-status")
        results_container = ui.column().classes("mt-2")
        loading = ui.row().classes("apple-loading")
        with loading:
            ui.spinner("dots", size="1.2em")
            ui.label("Running workflow...")
        loading.set_visibility(False)

        apple_upload(buffer, "Upload FASTA files")
        cluster_number = apple_input("Clusters (number or 'auto')", value="auto")
        silhouette_min = apple_number("Silhouette min", value=2, step=1)
        silhouette_max = apple_number("Silhouette max", value=10, step=1)
        keep_separate = ui.checkbox("Keep files separate", value=False)

        async def run_job() -> None:
            acquired = _ACTIVE_RUN_LOCK.acquire(blocking=False)
            if not acquired:
                status.set_text("Another workflow is currently running. Please wait until it finishes.")
                ui.notify("Another workflow is running. Please wait.", color="warning")
                return
            status.set_text("Starting pLM-clust v2...")
            loading.set_visibility(True)
            run_btn.disable()
            try:
                await run.io_bound(
                    _run_plmclust,
                    buffer,
                    str(cluster_number.value),
                    int(silhouette_min.value),
                    int(silhouette_max.value),
                    bool(keep_separate.value),
                    status,
                    results_container,
                )
                buffer.clear()
            except Exception as err:
                status.set_text(f"Run failed: {err}")
            finally:
                _ACTIVE_RUN_LOCK.release()
                loading.set_visibility(False)
                run_btn.enable()

        run_btn = apple_button("Run pLM-clust v2", run_job)


def register_routes() -> None:
    @ui.page("/")
    def page_blast() -> None:
        with _page_wrapper("/"):
            with ui.element("div").classes("apple-content-inner"):
                _render_blaster_form()

    @ui.page("/trim")
    def page_trim() -> None:
        with _page_wrapper("/trim"):
            with ui.element("div").classes("apple-content-inner"):
                _render_trim_form()

    @ui.page("/cluster")
    def page_cluster() -> None:
        with _page_wrapper("/cluster"):
            with ui.element("div").classes("apple-content-inner"):
                _render_cluster_form()


register_routes()


def create_fastapi_app() -> FastAPI:
    base = _ensure_workdir()
    fastapi_app = FastAPI()
    if os.getenv("NICEGUI_DISABLE_PROCESS_POOL") == "1":
        run.setup = lambda: None
    ui.run_with(app=fastapi_app, storage_secret=os.getenv("UHT_STORAGE_SECRET", "uht-discovery"))
    fastapi_app.mount("/files", StaticFiles(directory=str(base)), name="files")
    return fastapi_app


def main() -> None:
    base = _ensure_workdir()
    app.add_static_files("/files", str(base))
    if os.getenv("NICEGUI_DISABLE_PROCESS_POOL") == "1":
        run.setup = lambda: None
    ui.run(
        host=os.getenv("UHT_HOST", "0.0.0.0"),
        port=int(os.getenv("UHT_PORT", "7860")),
        reload=False,
        show=False,
        storage_secret=os.getenv("UHT_STORAGE_SECRET", "uht-discovery"),
    )


if __name__ == "__main__":
    main()
