from __future__ import annotations

import argparse
import inspect
import json
import os
import secrets
import shutil
import tempfile
import time
from dataclasses import asdict
from http import HTTPStatus
from pathlib import Path
from threading import RLock
from typing import Any, Callable, Dict, Iterable, List, Optional

from model_manager_tool.auth import AuthorizationError
from model_manager_tool.manager import ModelManagerService
from model_manager_tool.server_calculation_graph import (
    build_calculation_graph_view,
    empty_calculation_graph_option,
)

try:  # Optional for import-time compatibility when FastAPI stack is absent.
    from starlette.requests import Request as StarletteRequest
except Exception:  # pragma: no cover - fallback for environments without Starlette
    StarletteRequest = Any  # type: ignore[assignment]


PROJECT_ROOT = Path(__file__).resolve().parent
DEFAULT_DATA_ROOT = PROJECT_ROOT / "data"
SESSION_COOKIE_NAME = "mm_session"
SESSION_TTL_SECONDS = 8 * 60 * 60
MODEL_MANAGER_STORAGE_SECRET_ENV = "MODEL_MANAGER_STORAGE_SECRET"
MODEL_MANAGER_MAX_UPLOAD_MB_ENV = "MODEL_MANAGER_MAX_UPLOAD_MB"
MODEL_MANAGER_ENABLE_PURGE_ALL_ENV = "MODEL_MANAGER_ENABLE_PURGE_ALL"
DEFAULT_MAX_UPLOAD_MB = 64
DEFAULT_MAX_UPLOAD_BYTES = DEFAULT_MAX_UPLOAD_MB * 1024 * 1024
_APP_CONFIGURED = False
_APP_DATA_ROOT: Optional[Path] = None
_APP_MAX_UPLOAD_BYTES: Optional[int] = None
_APP_ENABLE_PURGE_ALL: Optional[bool] = None
_ORIGINAL_STARLETTE_REQUEST_FORM: Optional[Callable[..., Any]] = None
_PATCHED_REQUEST_FORM_MIN_PART_SIZE_BYTES: int = DEFAULT_MAX_UPLOAD_BYTES
_PATCHED_MULTIPART_SPOOL_MAX_SIZE_BYTES: int = DEFAULT_MAX_UPLOAD_BYTES


class SessionStore:
    def __init__(self, ttl_seconds: int = SESSION_TTL_SECONDS) -> None:
        self.ttl_seconds = ttl_seconds
        self._lock = RLock()
        self._sessions: Dict[str, Dict[str, Any]] = {}

    def create(self, username: str) -> str:
        token = secrets.token_urlsafe(48)
        now = time.time()
        with self._lock:
            self._sessions[token] = {"username": username, "expires_at": now + self.ttl_seconds}
            self._cleanup(now=now)
        return token

    def get_user(self, token: Optional[str]) -> Optional[str]:
        if not token:
            return None
        now = time.time()
        with self._lock:
            item = self._sessions.get(token)
            if not item:
                return None
            if float(item.get("expires_at", 0.0)) < now:
                self._sessions.pop(token, None)
                return None
            item["expires_at"] = now + self.ttl_seconds
            self._cleanup(now=now)
            return str(item.get("username", ""))

    def revoke(self, token: Optional[str]) -> None:
        if not token:
            return
        with self._lock:
            self._sessions.pop(token, None)

    def _cleanup(self, *, now: Optional[float] = None) -> None:
        now = time.time() if now is None else now
        expired = [token for token, data in self._sessions.items() if float(data.get("expires_at", 0.0)) < now]
        for token in expired:
            self._sessions.pop(token, None)


def _api_error(exc: Exception):
    from fastapi.responses import JSONResponse

    if isinstance(exc, AuthorizationError):
        return JSONResponse(status_code=HTTPStatus.FORBIDDEN, content={"error": str(exc)})
    if isinstance(exc, ValueError):
        return JSONResponse(status_code=HTTPStatus.BAD_REQUEST, content={"error": str(exc)})
    return JSONResponse(
        status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
        content={"error": f"Unhandled server error: {exc}"},
    )


async def _read_json_payload(request: Any) -> Dict[str, Any]:
    try:
        payload = await request.json()
    except Exception as exc:
        raise ValueError(f"Invalid JSON payload: {exc}") from exc
    if payload is None:
        return {}
    if not isinstance(payload, dict):
        raise ValueError("JSON payload must be an object.")
    return payload


def _as_string_list(payload: Any) -> List[str]:
    if isinstance(payload, str):
        parts = [part.strip() for part in payload.split(",")]
        return [part for part in parts if part]
    if not isinstance(payload, list):
        return []
    return [str(item).strip() for item in payload if str(item).strip()]


def _safe_upload_filename(filename: str, used_names: set[str]) -> str:
    candidate = Path(filename or "artifact.bin").name or "artifact.bin"
    stem = Path(candidate).stem
    suffix = Path(candidate).suffix
    idx = 1
    while candidate.lower() in used_names:
        candidate = f"{stem}_{idx}{suffix}"
        idx += 1
    used_names.add(candidate.lower())
    return candidate


def _pretty_json(payload: Any) -> str:
    return json.dumps(payload, ensure_ascii=False, indent=2)


def _compact_import_result_for_ui(result: Dict[str, Any]) -> Dict[str, Any]:
    artifact_manifest = list(result.get("artifact_manifest", []))
    preview_rows: List[Dict[str, Any]] = []
    for item in artifact_manifest[:12]:
        row: Dict[str, Any] = {
            "artifact_name": item.get("artifact_name"),
            "kind": item.get("kind"),
            "size_bytes": item.get("size_bytes"),
            "relative_path": item.get("relative_path"),
        }
        excel_profile = item.get("excel_profile", {})
        if isinstance(excel_profile, dict):
            config = excel_profile.get("config_auto_import", {})
            if isinstance(config, dict):
                calc_graph = config.get("calculation_graph", {})
                row["excel_summary"] = {
                    "has_structured_config": bool(config.get("has_structured_config")),
                    "sheet_count": len(config.get("sheet_names", [])),
                    "named_reference_count": len(config.get("named_references", [])),
                    "input_cell_count": len(config.get("input_cells", [])),
                    "formula_cell_count": len(config.get("formula_cells", [])),
                    "calculation_graph_node_count": len(calc_graph.get("nodes", [])) if isinstance(calc_graph, dict) else 0,
                    "calculation_graph_edge_count": len(calc_graph.get("edges", [])) if isinstance(calc_graph, dict) else 0,
                }
        preview_rows.append(row)

    return {
        "model_name": result.get("model_name"),
        "version": result.get("version"),
        "status": result.get("status"),
        "artifact_count": len(artifact_manifest),
        "artifacts_preview": preview_rows,
        "artifacts_truncated": len(artifact_manifest) > len(preview_rows),
        "excel_auto_config_count": len(result.get("excel_auto_configs", [])),
        "message": "UI preview is summarized. Full payload is persisted in registry artifacts metadata.",
    }


def _format_size(num_bytes: int) -> str:
    units = ("B", "KB", "MB", "GB", "TB")
    value = float(max(0, int(num_bytes)))
    for unit in units:
        if value < 1024 or unit == units[-1]:
            if unit == "B":
                return f"{int(value)} {unit}"
            return f"{value:.1f} {unit}"
        value /= 1024
    return f"{int(num_bytes)} B"


def _resolve_max_upload_bytes(max_upload_mb: int) -> int:
    value_mb = int(max_upload_mb)
    if value_mb <= 0:
        raise ValueError("--max-upload-mb must be a positive integer.")
    return value_mb * 1024 * 1024


def _parse_bool_flag(value: Any) -> bool:
    if isinstance(value, bool):
        return value
    if value is None:
        return False
    return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}


async def _read_multipart_form(request: Any, *, max_part_size: Optional[int] = None) -> Any:
    form_method = getattr(request, "form", None)
    if form_method is None:
        raise ValueError("Request does not support multipart form parsing.")
    if max_part_size is None:
        return await form_method()

    try:
        signature = inspect.signature(form_method)
        supports_max_part_size = "max_part_size" in signature.parameters
    except (TypeError, ValueError):
        supports_max_part_size = False

    if supports_max_part_size:
        return await form_method(max_part_size=int(max_part_size))

    try:
        return await form_method(max_part_size=int(max_part_size))
    except TypeError:
        return await form_method()


def _copy_uploaded_file_with_limit(*, src_file: Any, target_path: Path, max_bytes: int) -> int:
    bytes_written = 0
    chunk_size = 1024 * 1024
    with target_path.open("wb") as fh:
        while True:
            chunk = src_file.read(chunk_size)
            if not chunk:
                break
            if isinstance(chunk, str):
                chunk = chunk.encode("utf-8")
            bytes_written += len(chunk)
            if bytes_written > max_bytes:
                raise ValueError(
                    f"File '{target_path.name}' exceeds upload limit of {_format_size(max_bytes)}."
                )
            fh.write(chunk)
    return bytes_written


def _patch_starlette_request_form_limit(*, min_part_size_bytes: int) -> None:
    global _ORIGINAL_STARLETTE_REQUEST_FORM
    global _PATCHED_REQUEST_FORM_MIN_PART_SIZE_BYTES
    global _PATCHED_MULTIPART_SPOOL_MAX_SIZE_BYTES
    desired_min = max(1, int(min_part_size_bytes))
    _PATCHED_REQUEST_FORM_MIN_PART_SIZE_BYTES = desired_min
    _PATCHED_MULTIPART_SPOOL_MAX_SIZE_BYTES = desired_min

    try:
        from starlette.requests import Request as StarletteRequestClass
        from starlette.formparsers import MultiPartParser
    except Exception:
        return

    # Keep large uploads in Starlette's in-memory spool threshold to avoid
    # aiofiles tempfile path issues in some environment combinations.
    try:
        current_spool_max_size = int(getattr(MultiPartParser, "spool_max_size", 0) or 0)
        if current_spool_max_size < desired_min:
            MultiPartParser.spool_max_size = desired_min
    except Exception:
        pass

    try:
        current_class_max_part_size = int(getattr(MultiPartParser, "max_part_size", 0) or 0)
        if current_class_max_part_size < desired_min:
            MultiPartParser.max_part_size = desired_min
    except Exception:
        pass

    if _ORIGINAL_STARLETTE_REQUEST_FORM is not None:
        return

    _ORIGINAL_STARLETTE_REQUEST_FORM = StarletteRequestClass.form

    def _patched_form(
        self: Any,
        *,
        max_files: int | float = 1000,
        max_fields: int | float = 1000,
        max_part_size: int = 1024 * 1024,
    ) -> Any:
        requested_size = int(max_part_size)
        effective_max_part_size = max(requested_size, _PATCHED_REQUEST_FORM_MIN_PART_SIZE_BYTES)
        return _ORIGINAL_STARLETTE_REQUEST_FORM(
            self,
            max_files=max_files,
            max_fields=max_fields,
            max_part_size=effective_max_part_size,
        )

    StarletteRequestClass.form = _patched_form  # type: ignore[assignment]


def register_model_manager_api(
    *,
    service: ModelManagerService,
    sessions: SessionStore,
    max_upload_bytes: int,
    enable_purge_all: bool,
) -> None:
    from fastapi.responses import JSONResponse
    from nicegui import app

    def current_username(request: StarletteRequest) -> Optional[str]:
        return sessions.get_user(request.cookies.get(SESSION_COOKIE_NAME))

    def require_user(request: StarletteRequest) -> str:
        username = current_username(request)
        if not username:
            raise AuthorizationError("Authentication required.")
        return username

    @app.get("/api/whoami")
    async def api_whoami(request: StarletteRequest) -> JSONResponse:
        try:
            username = current_username(request)
            if not username:
                return JSONResponse(status_code=HTTPStatus.OK, content={"authenticated": False})
            profile = service.get_user_profile(username)
            return JSONResponse(
                status_code=HTTPStatus.OK,
                content={
                    "authenticated": True,
                    "user": asdict(profile),
                    "available_permissions": service.available_permissions,
                },
            )
        except Exception as exc:  # pragma: no cover - defensive
            return _api_error(exc)

    @app.post("/api/login")
    async def api_login(request: StarletteRequest) -> JSONResponse:
        try:
            payload = await _read_json_payload(request)
            username = str(payload.get("username", "")).strip()
            password = str(payload.get("password", ""))
            user = service.authenticate(username, password)
            token = sessions.create(user.username)
            response = JSONResponse(
                status_code=HTTPStatus.OK,
                content={"ok": True, "user": asdict(user)},
            )
            response.set_cookie(
                key=SESSION_COOKIE_NAME,
                value=token,
                httponly=True,
                samesite="lax",
                max_age=SESSION_TTL_SECONDS,
                path="/",
            )
            return response
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/logout")
    async def api_logout(request: StarletteRequest) -> JSONResponse:
        try:
            token = request.cookies.get(SESSION_COOKIE_NAME)
            sessions.revoke(token)
            response = JSONResponse(status_code=HTTPStatus.OK, content={"ok": True})
            response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
            return response
        except Exception as exc:  # pragma: no cover - defensive
            return _api_error(exc)

    @app.get("/api/models")
    async def api_models(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "model:view")
            models = service.list_models()
            return JSONResponse(status_code=HTTPStatus.OK, content={"models": models, "count": len(models)})
        except Exception as exc:
            return _api_error(exc)

    @app.get("/api/models/{model_name}/versions")
    async def api_model_versions(model_name: str, request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "model:view")
            versions = service.list_model_versions(model_name)
            return JSONResponse(
                status_code=HTTPStatus.OK,
                content={"model_name": model_name, "versions": versions},
            )
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/models/import")
    async def api_import_model(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "model:import")

            form = await _read_multipart_form(request, max_part_size=max_upload_bytes)
            model_name = str(form.get("model_name", "")).strip()
            version = str(form.get("version", "")).strip()
            model_type = str(form.get("model_type", "generic")).strip() or "generic"
            notes = str(form.get("notes", "")).strip()

            tags_raw = str(form.get("tags", "")).strip()
            metrics_raw = str(form.get("metrics", "")).strip()
            tags = service.parse_json_object(tags_raw, field_name="tags")
            metrics = service.parse_json_object(metrics_raw, field_name="metrics")

            uploaded_items = list(form.getlist("files")) if hasattr(form, "getlist") else []
            if not uploaded_items:
                raise ValueError("No files uploaded. Please upload at least one artifact.")

            tmp_dir = Path(tempfile.mkdtemp(prefix="model_manager_upload_"))
            artifact_paths: List[Path] = []
            used_names: set[str] = set()
            try:
                for item in uploaded_items:
                    src_file = getattr(item, "file", None)
                    file_name = _safe_upload_filename(getattr(item, "filename", "artifact.bin"), used_names)
                    if src_file is None:
                        continue
                    target = tmp_dir / file_name
                    _copy_uploaded_file_with_limit(
                        src_file=src_file,
                        target_path=target,
                        max_bytes=max_upload_bytes,
                    )
                    artifact_paths.append(target)

                result = service.import_model(
                    model_name=model_name,
                    version=version,
                    actor=username,
                    artifact_paths=artifact_paths,
                    model_type=model_type,
                    metrics=metrics,
                    tags=tags,
                    notes=notes or None,
                )
            finally:
                shutil.rmtree(tmp_dir, ignore_errors=True)

            return JSONResponse(status_code=HTTPStatus.OK, content={"ok": True, "model": result})
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/deploy")
    async def api_deploy(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "model:promote")
            payload = await _read_json_payload(request)
            env = str(payload.get("env", "")).strip()
            model_name = str(payload.get("model_name", "")).strip()
            version = str(payload.get("version", "")).strip()
            note = str(payload.get("note", "")).strip()
            result = service.deploy_model(
                env=env,
                model_name=model_name,
                version=version,
                actor=username,
                note=note,
            )
            return JSONResponse(status_code=HTTPStatus.OK, content={"ok": True, "state": result})
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/rollback")
    async def api_rollback(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "model:rollback")
            payload = await _read_json_payload(request)
            env = str(payload.get("env", "")).strip()
            note = str(payload.get("note", "")).strip()
            result = service.rollback_environment(env=env, actor=username, note=note)
            return JSONResponse(status_code=HTTPStatus.OK, content={"ok": True, "state": result})
        except Exception as exc:
            return _api_error(exc)

    @app.get("/api/environments")
    async def api_environments(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "model:view")
            envs = service.list_environments()
            states = [service.get_environment_state(env) for env in envs]
            return JSONResponse(status_code=HTTPStatus.OK, content={"environments": states})
        except Exception as exc:
            return _api_error(exc)

    @app.get("/api/env/{env}")
    async def api_env_state(env: str, request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "model:view")
            env_name = str(env).strip()
            if not env_name:
                raise ValueError("Environment name is required.")
            return JSONResponse(status_code=HTTPStatus.OK, content=service.get_environment_state(env_name))
        except Exception as exc:
            return _api_error(exc)

    @app.get("/api/audit")
    async def api_audit(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "audit:view")
            limit = 200
            model_name = request.query_params.get("model_name")
            limit_raw = request.query_params.get("limit")
            if limit_raw:
                try:
                    limit = int(limit_raw)
                except Exception:
                    limit = 200
            rows = service.get_audit_logs(limit=limit, model_name=model_name)
            return JSONResponse(status_code=HTTPStatus.OK, content={"logs": rows, "count": len(rows)})
        except Exception as exc:
            return _api_error(exc)

    @app.get("/api/admin/users")
    async def api_admin_users(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "admin:user_manage")
            return JSONResponse(status_code=HTTPStatus.OK, content={"users": service.list_users()})
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/admin/users")
    async def api_admin_create_user(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "admin:user_manage")
            payload = await _read_json_payload(request)
            new_user = str(payload.get("username", "")).strip()
            password = str(payload.get("password", ""))
            roles = _as_string_list(payload.get("roles", []))
            result = service.create_user(new_user, password, roles)
            return JSONResponse(status_code=HTTPStatus.OK, content={"ok": True, "user": result})
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/admin/users/roles")
    async def api_admin_set_user_roles(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "admin:user_manage")
            payload = await _read_json_payload(request)
            target_user = str(payload.get("username", "")).strip()
            roles = _as_string_list(payload.get("roles", []))
            result = service.set_user_roles(target_user, roles)
            return JSONResponse(status_code=HTTPStatus.OK, content={"ok": True, "user": result})
        except Exception as exc:
            return _api_error(exc)

    @app.get("/api/admin/roles")
    async def api_admin_roles(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "admin:user_manage")
            return JSONResponse(status_code=HTTPStatus.OK, content={"roles": service.list_roles()})
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/admin/roles")
    async def api_admin_create_role(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "admin:role_manage")
            payload = await _read_json_payload(request)
            role_name = str(payload.get("role_name", "")).strip()
            permissions = _as_string_list(payload.get("permissions", []))
            description = str(payload.get("description", "")).strip()
            result = service.create_role(role_name, permissions, description=description)
            return JSONResponse(status_code=HTTPStatus.OK, content={"ok": True, "role": result})
        except Exception as exc:
            return _api_error(exc)

    @app.post("/api/admin/purge-models")
    async def api_admin_purge_models(request: StarletteRequest) -> JSONResponse:
        try:
            username = require_user(request)
            service.require_permission(username, "admin:user_manage")
            if not enable_purge_all:
                raise AuthorizationError(
                    "Purge-all feature is disabled. Set MODEL_MANAGER_ENABLE_PURGE_ALL=1 to enable."
                )
            payload = await _read_json_payload(request)
            clear_audit = _parse_bool_flag(payload.get("clear_audit"))
            result = service.purge_all_models(actor=username, clear_audit=clear_audit)
            return JSONResponse(status_code=HTTPStatus.OK, content={"ok": True, "summary": result})
        except Exception as exc:
            return _api_error(exc)


def register_model_manager_page(
    *,
    service: ModelManagerService,
    sessions: SessionStore,
    max_upload_bytes: int,
    enable_purge_all: bool,
) -> None:
    from nicegui import app, events, run, ui

    @ui.page("/")
    def model_manager_page() -> None:
        max_upload_label = _format_size(max_upload_bytes)
        state: Dict[str, Any] = {
            "authenticated": False,
            "user": None,
            "permissions": set(),
            "uploaded_paths": [],
            "upload_dir": None,
            "models": [],
            "environments": [],
            "calculator_artifacts": [],
            "calculator_env_active": {},
            "calculator_schema": None,
            "calculator_controls": {},
            "calculator_graph": {"nodes": [], "edges": [], "output_cells": []},
            "import_in_progress": False,
            "import_progress": {
                "active": False,
                "value": 0.0,
                "message": "",
                "dirty": True,
            },
        }
        progress_lock = RLock()

        def show_error(exc: Exception) -> None:
            ui.notify(str(exc), type="negative")

        def show_success(message: str) -> None:
            ui.notify(message, type="positive")

        def _normalize_progress_value(value: Any) -> float:
            try:
                numeric = float(value)
            except Exception:
                numeric = 0.0
            return max(0.0, min(1.0, numeric))

        def _format_import_progress_label(*, value: Any, message: str) -> str:
            normalized = _normalize_progress_value(value)
            percent = int(round(normalized * 100))
            text = str(message or "").strip()
            if text:
                return f"{percent}% · {text}"
            return f"{percent}%"

        def set_import_progress(*, value: float, message: str, active: Optional[bool] = None) -> None:
            with progress_lock:
                payload = state["import_progress"]
                payload["value"] = _normalize_progress_value(value)
                payload["message"] = str(message)
                if active is not None:
                    payload["active"] = bool(active)
                payload["dirty"] = True

        def _sync_import_progress_ui() -> None:
            with progress_lock:
                payload = state["import_progress"]
                if not payload.get("dirty"):
                    return
                snapshot = {
                    "active": bool(payload.get("active")),
                    "value": _normalize_progress_value(payload.get("value", 0.0)),
                    "message": str(payload.get("message", "")),
                }
                payload["dirty"] = False

            import_progress_panel.visible = snapshot["active"] or bool(snapshot["message"])
            import_progress.value = snapshot["value"]
            import_progress.update()
            import_progress_label.set_text(
                _format_import_progress_label(value=snapshot["value"], message=snapshot["message"])
            )

        async def scroll_to_top() -> None:
            await ui.run_javascript("window.scrollTo({ top: 0, behavior: 'smooth' });")

        def has_permission(permission: str) -> bool:
            return permission in state["permissions"]

        def has_any_permission(permissions: Iterable[str]) -> bool:
            return any(permission in state["permissions"] for permission in permissions)

        def current_username_from_ui() -> Optional[str]:
            token = app.storage.user.get(SESSION_COOKIE_NAME)
            username = sessions.get_user(token) if token else None
            if token and not username:
                app.storage.user.pop(SESSION_COOKIE_NAME, None)
            return username

        def require_ui_user() -> str:
            username = current_username_from_ui()
            if not username:
                raise AuthorizationError("Authentication required.")
            return username

        def clear_uploaded_files() -> None:
            upload_dir = state.get("upload_dir")
            if upload_dir and isinstance(upload_dir, Path):
                shutil.rmtree(upload_dir, ignore_errors=True)
            state["upload_dir"] = None
            state["uploaded_paths"] = []
            upload_list.value = ""
            upload_list.update()

        async def on_file_upload(event: events.UploadEventArguments) -> None:
            try:
                upload_dir = state.get("upload_dir")
                if upload_dir is None:
                    upload_dir = Path(tempfile.mkdtemp(prefix="model_manager_ui_upload_"))
                    state["upload_dir"] = upload_dir

                used_names = {path.name.lower() for path in state["uploaded_paths"]}
                target_name = _safe_upload_filename(event.file.name, used_names)
                target_path = upload_dir / target_name
                await event.file.save(target_path)

                file_size = target_path.stat().st_size
                if file_size > max_upload_bytes:
                    target_path.unlink(missing_ok=True)
                    raise ValueError(f"File '{target_name}' exceeds upload limit of {max_upload_label}.")

                state["uploaded_paths"].append(target_path)
                upload_list.value = "\n".join(path.name for path in state["uploaded_paths"])
                upload_list.update()
            except Exception as exc:
                show_error(exc)

        def on_upload_rejected(_: events.UiEventArguments) -> None:
            show_error(ValueError(f"上传被拒绝。单文件上限为 {max_upload_label}。"))

        def update_auth_state() -> None:
            username = current_username_from_ui()
            if not username:
                state["authenticated"] = False
                state["user"] = None
                state["permissions"] = set()
                login_card.visible = True
                app_shell.visible = False
                session_panel.visible = False
                back_to_top_button.visible = False
                return

            profile = service.get_user_profile(username)
            state["authenticated"] = True
            state["user"] = asdict(profile)
            state["permissions"] = set(profile.permissions)

            login_card.visible = False
            app_shell.visible = True
            session_panel.visible = True
            back_to_top_button.visible = True
            current_user_label.set_text(f"{profile.username} ({', '.join(profile.roles)})")

            allow_admin = has_any_permission(["admin:user_manage", "admin:role_manage"])
            admin_tab.visible = allow_admin
            admin_panel.visible = allow_admin
            if tabs.value == admin_tab and not allow_admin:
                tabs.value = import_tab

        def refresh_model_overview() -> None:
            model_overview.clear()
            if not has_permission("model:view"):
                with model_overview:
                    ui.label("当前角色无 model:view 权限。").classes("text-grey-6")
                return

            models = service.list_models()
            state["models"] = models
            if not models:
                with model_overview:
                    ui.label("暂无模型版本。").classes("text-grey-6")
                return

            with model_overview:
                for model in models:
                    model_name = str(model.get("model_name", ""))
                    versions = service.list_model_versions(model_name)
                    with ui.card().classes("w-full bg-blue-1"):
                        with ui.row().classes("w-full justify-between items-center"):
                            ui.label(model_name).classes("text-h6")
                            ui.label(
                                f"最新版本: {model.get('latest_version', '-')} / 总数: {model.get('version_count', 0)}"
                            ).classes("text-grey-7")

                        with ui.row().classes("w-full text-weight-medium text-grey-8").style("gap: 16px;"):
                            ui.label("版本").style("width: 90px;")
                            ui.label("状态").style("width: 100px;")
                            ui.label("创建时间").style("width: 220px;")
                            ui.label("在线环境").style("width: 180px;")
                            ui.label("操作")

                        for row in versions:
                            version = str(row.get("version", ""))
                            active_env_text = ", ".join(row.get("active_envs", [])) or "-"
                            with ui.row().classes("w-full items-center").style("gap: 16px;"):
                                ui.label(version).style("width: 90px;")
                                ui.label(str(row.get("status", "-"))).style("width: 100px;")
                                ui.label(str(row.get("created_at", "-"))).style("width: 220px;")
                                ui.label(active_env_text).style("width: 180px;")
                                with ui.row().classes("items-center").style("gap: 8px;"):
                                    ui.button(
                                        "详情",
                                        on_click=lambda m=model_name, payload=row: show_version_detail(m, payload),
                                    ).props("outline")
                                    if has_permission("model:promote"):
                                        ui.button(
                                            "发布到 staging",
                                            on_click=lambda m=model_name, v=version: deploy_model(m, v, "staging"),
                                            color="primary",
                                        )
                                        ui.button(
                                            "发布到 production",
                                            on_click=lambda m=model_name, v=version: deploy_model(m, v, "production"),
                                            color="primary",
                                        )

        def show_version_detail(model_name: str, payload: Dict[str, Any]) -> None:
            version_detail_title.set_text(f"版本详情: {model_name} {payload.get('version', '')}")
            version_detail_json.value = _pretty_json(payload)
            version_detail_json.update()
            version_detail_card.visible = True

        def _apply_calculator_graph_options(option_payload: Dict[str, Any]) -> None:
            # NiceGUI EChart does not support assigning to .options in some versions.
            # Mutate existing options dict and refresh the component.
            existing_options = getattr(calculator_graph_chart, "options", None)
            if isinstance(existing_options, dict):
                existing_options.clear()
                existing_options.update(option_payload)
            calculator_graph_chart.update()

        def render_calculation_graph(payload: Any) -> None:
            graph_view = build_calculation_graph_view(payload)
            state["calculator_graph"] = {
                "nodes": graph_view.get("nodes", []),
                "edges": graph_view.get("edges", []),
            }
            calculator_graph_summary.set_text(str(graph_view.get("summary", "图结构：0 个节点，0 条连线")))
            option_payload = graph_view.get("option")
            if isinstance(option_payload, dict):
                _apply_calculator_graph_options(option_payload)
            else:
                _apply_calculator_graph_options(empty_calculation_graph_option())

        def _reset_calculator_form() -> None:
            state["calculator_schema"] = None
            state["calculator_controls"] = {}
            calculator_input_form.clear()
            calculator_schema_preview.value = ""
            calculator_schema_preview.update()
            calculator_result.value = ""
            calculator_result.update()
            render_calculation_graph({"nodes": [], "edges": []})

        def refresh_calculator_environments() -> None:
            env_names = service.list_environments()
            options: Dict[str, str] = {"manual": "手动选择（全部版本）"}
            for env_name in env_names:
                token = str(env_name).strip()
                if token:
                    options[token] = token
            selected_env = str(calculator_environment_select.value or "manual").strip() or "manual"
            if selected_env not in options:
                selected_env = "manual"
            calculator_environment_select.options = options
            calculator_environment_select.value = selected_env
            calculator_environment_select.update()

        def refresh_calculator_models() -> None:
            if not has_permission("model:view"):
                calculator_message.set_text("当前角色无 model:view 权限。")
                calculator_message.visible = True
                calculator_form_panel.visible = False
                return

            refresh_calculator_environments()
            selected_env = str(calculator_environment_select.value or "manual").strip() or "manual"
            active_model = ""
            active_version = ""
            if selected_env != "manual":
                env_state = service.get_environment_state(selected_env)
                active_payload = env_state.get("active") if isinstance(env_state, dict) else None
                if isinstance(active_payload, dict):
                    active_model = str(active_payload.get("name", "")).strip()
                    active_version = str(active_payload.get("version", "")).strip()
                    calculator_environment_hint.set_text(
                        f"{selected_env} 当前在线模型: {active_model}:{active_version}"
                    )
                else:
                    calculator_environment_hint.set_text(
                        f"{selected_env} 暂无在线模型，你仍可手动选择模型。"
                    )
            else:
                calculator_environment_hint.set_text("手动模式：请直接选择模型和版本。")

            state["calculator_env_active"] = {
                "env": selected_env,
                "model": active_model,
                "version": active_version,
            }

            models = service.list_models()
            model_names = [str(item.get("model_name", "")).strip() for item in models if str(item.get("model_name", "")).strip()]
            if active_model and active_model not in model_names:
                model_names.insert(0, active_model)
            selected = calculator_model_select.value if calculator_model_select.value in model_names else None
            if not selected and active_model in model_names:
                selected = active_model
            calculator_model_select.options = model_names
            calculator_model_select.value = selected or (model_names[0] if model_names else None)
            calculator_model_select.update()
            calculator_message.visible = False
            calculator_form_panel.visible = True
            refresh_calculator_versions()

        def refresh_calculator_versions() -> None:
            model_name = str(calculator_model_select.value or "").strip()
            if not model_name:
                calculator_version_select.options = []
                calculator_version_select.value = None
                calculator_version_select.update()
                refresh_calculator_artifacts()
                return

            versions = service.list_model_versions(model_name)
            version_values = [str(item.get("version", "")).strip() for item in versions if str(item.get("version", "")).strip()]
            selected = calculator_version_select.value if calculator_version_select.value in version_values else None
            if not selected and version_values:
                env_active = state.get("calculator_env_active", {})
                env_model = str(env_active.get("model", "")).strip() if isinstance(env_active, dict) else ""
                env_version = str(env_active.get("version", "")).strip() if isinstance(env_active, dict) else ""
                if model_name == env_model and env_version in version_values:
                    selected = env_version
            calculator_version_select.options = version_values
            calculator_version_select.value = selected or (version_values[-1] if version_values else None)
            calculator_version_select.update()
            refresh_calculator_artifacts()

        def refresh_calculator_artifacts() -> None:
            model_name = str(calculator_model_select.value or "").strip()
            version = str(calculator_version_select.value or "").strip()
            if not model_name or not version:
                state["calculator_artifacts"] = []
                calculator_artifact_select.options = []
                calculator_artifact_select.value = None
                calculator_artifact_select.update()
                calculator_artifact_hint.set_text("请先选择环境/模型/版本。")
                _reset_calculator_form()
                return

            artifacts = service.list_excel_calculator_artifacts(model_name, version)
            state["calculator_artifacts"] = artifacts
            options = [str(item.get("artifact_path", "")) for item in artifacts if str(item.get("artifact_path", "")).strip()]
            selected = calculator_artifact_select.value if calculator_artifact_select.value in options else None
            calculator_artifact_select.options = options
            calculator_artifact_select.value = selected or (options[0] if options else None)
            calculator_artifact_select.update()
            if options:
                calculator_artifact_hint.set_text(f"可用 Excel 文件数量: {len(options)}")
            else:
                calculator_artifact_hint.set_text("该版本未发现可用于录入计算的结构化 Excel 文件。")
            _reset_calculator_form()
            if options and calculator_artifact_select.value:
                load_calculator_schema(silent=True)

        def on_calculator_artifact_change() -> None:
            _reset_calculator_form()
            if str(calculator_artifact_select.value or "").strip():
                load_calculator_schema(silent=True)

        def load_calculator_schema(*, silent: bool = False) -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "model:view")
                model_name = str(calculator_model_select.value or "").strip()
                version = str(calculator_version_select.value or "").strip()
                artifact_path = str(calculator_artifact_select.value or "").strip()
                if not model_name or not version or not artifact_path:
                    raise ValueError("请选择模型、版本和文件。")

                schema = service.get_excel_calculator_schema(
                    model_name=model_name,
                    version=version,
                    artifact_path=artifact_path,
                )
                state["calculator_schema"] = schema
                state["calculator_controls"] = {}
                calculator_input_form.clear()

                input_fields = list(schema.get("input_fields", []))
                with calculator_input_form:
                    if not input_fields:
                        ui.label("该文件未识别到可录入字段。").classes("text-grey-7")
                    for item in input_fields:
                        key = str(item.get("key", "")).strip()
                        if not key:
                            continue
                        label = str(item.get("label", "")).strip() or key
                        default_value = item.get("default_value")
                        options = [str(opt) for opt in item.get("options", []) if str(opt).strip()]
                        display_label = label
                        if options:
                            init_value = default_value if default_value in options else (options[0] if options else None)
                            control = ui.select(options=options, label=display_label, value=init_value).classes("w-full")
                        else:
                            initial_text = "" if default_value is None else str(default_value)
                            control = ui.input(display_label, value=initial_text).classes("w-full")
                        state["calculator_controls"][key] = control

                schema_preview = {
                    "artifact_name": schema.get("artifact_name"),
                    "artifact_path": schema.get("artifact_path"),
                    "input_count": len(schema.get("input_fields", [])),
                    "output_count": len(schema.get("output_cells", [])),
                }
                calculator_schema_preview.value = _pretty_json(schema_preview)
                calculator_schema_preview.update()
                render_calculation_graph(schema.get("calculation_graph", {}))
                if not silent:
                    show_success("参数模板加载成功。")
            except Exception as exc:
                if silent:
                    calculator_artifact_hint.set_text(f"参数模板加载失败: {exc}")
                else:
                    show_error(exc)

        async def run_single_calculation() -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "model:view")
                schema = state.get("calculator_schema")
                if not isinstance(schema, dict):
                    raise ValueError("请先加载参数模板。")

                payload_inputs: Dict[str, Any] = {}
                for key, control in state.get("calculator_controls", {}).items():
                    value = getattr(control, "value", None)
                    if isinstance(value, str):
                        value = value.strip()
                        if value == "":
                            continue
                    if value is None:
                        continue
                    payload_inputs[str(key)] = value

                result = await run.io_bound(
                    lambda: service.run_excel_single_calculation(
                        model_name=str(schema.get("model_name", "")),
                        version=str(schema.get("version", "")),
                        artifact_path=str(schema.get("artifact_path", "")),
                        inputs=payload_inputs,
                    )
                )
                calculator_result.value = _pretty_json(result)
                calculator_result.update()
                show_success("单笔计算完成。")
            except Exception as exc:
                show_error(exc)

        def refresh_environment_panel() -> None:
            if not has_permission("model:view"):
                env_state.value = "当前角色无 model:view 权限。"
                env_state.update()
                return

            envs = service.list_environments()
            states = [service.get_environment_state(env) for env in envs]
            state["environments"] = states

            env_names = [item.get("env", "") for item in states if item.get("env")]
            selected_env = env_select.value if env_select.value in env_names else None
            env_select.options = env_names
            env_select.value = selected_env or (env_names[0] if env_names else None)
            env_select.update()
            refresh_current_environment_state()

        def refresh_current_environment_state() -> None:
            env_name = str(env_select.value or "").strip()
            if not env_name:
                env_state.value = "{}"
                env_state.update()
                return
            payload = service.get_environment_state(env_name)
            env_state.value = _pretty_json(payload)
            env_state.update()

        def deploy_model(model_name: str, version: str, env: str) -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "model:promote")
                note = deploy_note.value.strip()
                service.deploy_model(
                    env=env,
                    model_name=model_name,
                    version=version,
                    actor=username,
                    note=note,
                )
                show_success(f"已发布 {model_name}:{version} 到 {env}")
                refresh_environment_panel()
                refresh_model_overview()
                refresh_audit_table()
            except Exception as exc:
                show_error(exc)

        def rollback_environment() -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "model:rollback")
                env = str(env_select.value or "").strip()
                note = rollback_note.value.strip()
                payload = service.rollback_environment(env=env, actor=username, note=note)
                env_state.value = _pretty_json(payload)
                env_state.update()
                show_success(f"环境 {env} 已回滚到上一版本")
                refresh_model_overview()
                refresh_audit_table()
            except Exception as exc:
                show_error(exc)

        def refresh_audit_table() -> None:
            if not has_permission("audit:view"):
                audit_table.visible = False
                audit_message.visible = True
                audit_message.set_text("当前角色无 audit:view 权限。")
                return

            model_name = audit_filter.value.strip() or None
            rows = service.get_audit_logs(limit=200, model_name=model_name)
            if not rows:
                audit_table.visible = False
                audit_message.visible = True
                audit_message.set_text("暂无审计日志。")
                return

            audit_message.visible = False
            audit_table.visible = True
            payload_rows = []
            for idx, row in enumerate(rows):
                metadata = row.get("metadata", {})
                model_name_meta = row.get("model_name") or metadata.get("model_name") or metadata.get("name") or "-"
                payload_rows.append(
                    {
                        "id": idx,
                        "timestamp": row.get("timestamp", "-"),
                        "action": row.get("action", "-"),
                        "user": row.get("user", "-"),
                        "model_name": model_name_meta,
                        "summary": _pretty_json(metadata),
                    }
                )
            audit_table.rows = payload_rows
            audit_table.update()

        def refresh_admin_panel() -> None:
            allow_admin = has_any_permission(["admin:user_manage", "admin:role_manage"])
            admin_message.visible = not allow_admin
            admin_content.visible = allow_admin
            if not allow_admin:
                role_list.value = "当前角色无管理权限。"
                user_list.value = "当前角色无管理权限。"
                role_list.update()
                user_list.update()
                return

            users = service.list_users()
            roles = service.list_roles()
            role_list.value = _pretty_json(roles)
            user_list.value = _pretty_json(users)
            role_list.update()
            user_list.update()
            can_manage_users = has_permission("admin:user_manage")
            can_purge = bool(enable_purge_all and can_manage_users)
            purge_tools.visible = can_purge
            purge_disabled_hint.visible = not can_purge
            if not enable_purge_all:
                purge_disabled_hint.set_text(
                    "清空功能未启用。请使用 --enable-purge-all 或设置 MODEL_MANAGER_ENABLE_PURGE_ALL=1。"
                )
            elif not can_manage_users:
                purge_disabled_hint.set_text("当前用户缺少权限：admin:user_manage。")

        def refresh_purge_shortcut() -> None:
            can_manage_users = has_permission("admin:user_manage")
            purge_shortcut_button.visible = True
            purge_shortcut_button.enabled = can_manage_users
            purge_shortcut_button.update()
            purge_shortcut_hint.visible = True
            if not can_manage_users:
                purge_shortcut_hint.set_text("当前角色缺少权限：admin:user_manage。")
                return
            if enable_purge_all:
                purge_shortcut_hint.set_text("测试清空功能可在 管理 > 危险操作区 中执行。")
            else:
                purge_shortcut_hint.set_text(
                    "清空功能未启用。请使用 --enable-purge-all 或设置 MODEL_MANAGER_ENABLE_PURGE_ALL=1。"
                )

        def open_admin_danger_zone() -> None:
            if not has_permission("admin:user_manage"):
                show_error(AuthorizationError("当前用户缺少权限：admin:user_manage。"))
                return
            tabs.value = admin_tab
            tabs.update()
            if enable_purge_all:
                show_success("请在 管理 > 危险操作区 输入 DELETE ALL 进行确认。")
            else:
                show_error(
                    AuthorizationError(
                        "清空功能未启用。请使用 --enable-purge-all 或设置 MODEL_MANAGER_ENABLE_PURGE_ALL=1。"
                    )
                )

        def refresh_all() -> None:
            update_auth_state()
            if not state["authenticated"]:
                return
            refresh_purge_shortcut()
            refresh_model_overview()
            refresh_calculator_models()
            refresh_environment_panel()
            refresh_audit_table()
            refresh_admin_panel()

        def do_login() -> None:
            try:
                username = login_username.value.strip()
                password = login_password.value
                user = service.authenticate(username, password)
                token = sessions.create(user.username)
                app.storage.user[SESSION_COOKIE_NAME] = token
                show_success("登录成功。")
                refresh_all()
            except Exception as exc:
                show_error(exc)

        def do_logout() -> None:
            token = app.storage.user.get(SESSION_COOKIE_NAME)
            sessions.revoke(token)
            app.storage.user.pop(SESSION_COOKIE_NAME, None)
            clear_uploaded_files()
            uploader.reset()
            _reset_calculator_form()
            state["import_in_progress"] = False
            set_import_progress(value=0.0, message="", active=False)
            show_success("已退出登录。")
            refresh_all()

        async def submit_import() -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "model:import")
                if not state["uploaded_paths"]:
                    raise ValueError("请至少上传一个文件。")

                tags = service.parse_json_object(import_tags.value, field_name="tags")
                metrics = service.parse_json_object(import_metrics.value, field_name="metrics")
                if state["import_in_progress"]:
                    raise ValueError("导入任务正在进行，请稍后。")
                state["import_in_progress"] = True
                set_import_progress(value=0.03, message="导入开始", active=True)

                def _on_import_progress(progress_payload: Dict[str, Any]) -> None:
                    progress_value = progress_payload.get("value", 0.0)
                    progress_message = progress_payload.get("message", "")
                    set_import_progress(value=progress_value, message=str(progress_message), active=True)

                result = await run.io_bound(
                    lambda: service.import_model(
                        model_name=import_model_name.value.strip(),
                        version=import_version.value.strip(),
                        actor=username,
                        artifact_paths=list(state["uploaded_paths"]),
                        model_type=import_model_type.value.strip() or "generic",
                        tags=tags,
                        metrics=metrics,
                        notes=import_notes.value.strip() or None,
                        progress_callback=_on_import_progress,
                    )
                )
                set_import_progress(value=0.96, message="导入完成，正在刷新页面...", active=True)
                import_result.value = _pretty_json(_compact_import_result_for_ui(result))
                import_result.update()
                clear_uploaded_files()
                uploader.reset()
                show_success("导入成功。")
                refresh_model_overview()
                refresh_calculator_models()
                refresh_environment_panel()
                refresh_audit_table()
                set_import_progress(value=1.0, message="导入结束", active=False)
            except Exception as exc:
                set_import_progress(value=0.0, message=f"导入失败: {exc}", active=False)
                show_error(exc)
            finally:
                state["import_in_progress"] = False

        def create_role() -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "admin:role_manage")
                permissions = _as_string_list(role_permissions.value)
                service.create_role(
                    role_name=role_name.value.strip(),
                    permissions=permissions,
                    description=role_description.value.strip(),
                )
                role_name.value = ""
                role_permissions.value = ""
                role_description.value = ""
                role_name.update()
                role_permissions.update()
                role_description.update()
                show_success("角色创建成功。")
                refresh_admin_panel()
            except Exception as exc:
                show_error(exc)

        def create_user() -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "admin:user_manage")
                roles = _as_string_list(new_user_roles.value)
                service.create_user(
                    username=new_username.value.strip(),
                    password=new_password.value,
                    roles=roles,
                )
                new_username.value = ""
                new_password.value = ""
                new_user_roles.value = ""
                new_username.update()
                new_password.update()
                new_user_roles.update()
                show_success("用户创建成功。")
                refresh_admin_panel()
            except Exception as exc:
                show_error(exc)

        def set_user_roles() -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "admin:user_manage")
                roles = _as_string_list(set_roles.value)
                service.set_user_roles(
                    username=set_username.value.strip(),
                    roles=roles,
                )
                set_username.value = ""
                set_roles.value = ""
                set_username.update()
                set_roles.update()
                show_success("用户角色更新成功。")
                refresh_admin_panel()
            except Exception as exc:
                show_error(exc)

        def purge_all_models_data() -> None:
            try:
                username = require_ui_user()
                service.require_permission(username, "admin:user_manage")
                if not enable_purge_all:
                    raise AuthorizationError(
                        "清空功能未启用。请设置 MODEL_MANAGER_ENABLE_PURGE_ALL=1。"
                    )
                if state["import_in_progress"]:
                    raise ValueError("导入进行中，请等待完成后再清空。")
                if purge_confirm_input.value.strip().upper() != "DELETE ALL":
                    raise ValueError("请输入 DELETE ALL 以确认清空。")
                clear_audit = bool(purge_clear_audit_checkbox.value)
                summary = service.purge_all_models(actor=username, clear_audit=clear_audit)
                purge_confirm_input.value = ""
                purge_confirm_input.update()
                import_result.value = _pretty_json({"purge_summary": summary})
                import_result.update()
                clear_uploaded_files()
                uploader.reset()
                show_success("已清空所有模型数据。")
                refresh_model_overview()
                refresh_calculator_models()
                refresh_environment_panel()
                refresh_audit_table()
                refresh_admin_panel()
            except Exception as exc:
                show_error(exc)

        ui.add_head_html(
            """
            <style>
              body { background: radial-gradient(circle at top left, #d8f3dc 0%, #f1f4f8 35%); }
              .mm-topbar { background: linear-gradient(90deg, #1b4332 0%, #2d6a4f 100%); color: white; }
              .mm-title { font-size: 1.35rem; font-weight: 700; margin: 0; }
              .mm-subtitle { margin-top: 4px; opacity: 0.86; font-size: 0.85rem; }
              .mm-code { font-family: Consolas, Monaco, monospace; }
            </style>
            """
        )

        with ui.header().classes("mm-topbar w-full items-center justify-between px-6 py-3"):
            with ui.column().classes("gap-0"):
                ui.label("模型管理工具").classes("mm-title")
                ui.label("ins_pricing 模型仓库与发布回滚平台").classes("mm-subtitle")
            with ui.row().classes("items-center gap-3") as session_panel:
                current_user_label = ui.label("")
                ui.button("退出登录", on_click=do_logout).props("outline")

        with ui.column().classes("w-full max-w-[1260px] mx-auto p-4 gap-4"):
            with ui.card().classes("w-full max-w-[720px] mx-auto") as login_card:
                ui.label("登录").classes("text-h5")
                ui.label("默认账号 admin / admin123!，可通过环境变量覆盖。").classes(
                    "text-grey-7"
                )
                with ui.row().classes("w-full gap-3"):
                    login_username = ui.input("用户名", value="admin").classes("flex-1")
                    login_password = ui.input("密码", value="admin123!", password=True, password_toggle_button=True).classes(
                        "flex-1"
                    )
                ui.button("登录", on_click=do_login, color="primary")

            with ui.column().classes("w-full") as app_shell:
                with ui.tabs().classes("w-full") as tabs:
                    import_tab = ui.tab("模型导入")
                    versions_tab = ui.tab("版本管理")
                    calculator_tab = ui.tab("单笔计算")
                    deploy_tab = ui.tab("发布与回滚")
                    audit_tab = ui.tab("审计日志")
                    admin_tab = ui.tab("权限管理")

                with ui.tab_panels(tabs, value=import_tab).classes("w-full"):
                    with ui.tab_panel(import_tab):
                        with ui.card().classes("w-full"):
                            ui.label("模型导入").classes("text-h6")
                            ui.label("支持上传一个或多个文件；Excel 会自动解析结构化配置。")\
                                .classes("text-grey-7")

                            with ui.row().classes("w-full gap-3"):
                                import_model_name = ui.input("模型名称", placeholder="pricing_model").classes("flex-1")
                                import_version = ui.input("版本号", placeholder="1.0.0").classes("flex-1")
                                import_model_type = ui.input("模型类型", value="generic").classes("flex-1")

                            import_notes = ui.textarea("备注", placeholder="版本说明、训练批次等").classes("w-full")
                            with ui.row().classes("w-full gap-3"):
                                import_tags = ui.textarea("Tags（JSON 对象）", value="{}")\
                                    .props("autogrow")\
                                    .classes("flex-1")
                                import_metrics = ui.textarea("Metrics（JSON 对象）", value="{}")\
                                    .props("autogrow")\
                                    .classes("flex-1")

                            uploader = ui.upload(
                                label="模型文件 / 因子表（可多选）",
                                multiple=True,
                                max_file_size=max_upload_bytes,
                                auto_upload=True,
                                on_upload=on_file_upload,
                                on_rejected=on_upload_rejected,
                            ).classes("w-full")
                            ui.label(f"单文件大小限制: {max_upload_label}").classes("text-grey-6 text-caption")

                            upload_list = ui.textarea("已上传文件", value="")\
                                .props("readonly autogrow")\
                                .classes("w-full mm-code")
                            import_submit_button = ui.button("导入模型", on_click=submit_import, color="primary")
                            with ui.column().classes("w-full gap-1") as import_progress_panel:
                                import_progress = ui.linear_progress(value=0.0).classes("w-full")
                                import_progress_label = ui.label("").classes("text-grey-7 text-caption")
                            import_progress_panel.visible = False
                            import_result = ui.textarea("导入结果", value="")\
                                .props("readonly autogrow")\
                                .classes("w-full mm-code")

                    with ui.tab_panel(versions_tab):
                        with ui.card().classes("w-full"):
                            with ui.row().classes("w-full items-center justify-between"):
                                ui.label("版本管理").classes("text-h6")
                                with ui.row().classes("items-center gap-2"):
                                    ui.button("刷新", on_click=lambda: refresh_model_overview()).props("outline")
                                    purge_shortcut_button = ui.button(
                                        "清空所有模型（测试）",
                                        on_click=open_admin_danger_zone,
                                        color="negative",
                                    ).props("outline")
                            purge_shortcut_hint = ui.label("").classes("text-grey-7 text-caption")
                            deploy_note = ui.input("发布备注（可选）", placeholder="发布原因/批次说明")\
                                .classes("w-full")
                            model_overview = ui.column().classes("w-full gap-2")

                            with ui.card().classes("w-full") as version_detail_card:
                                version_detail_title = ui.label("版本详情").classes("text-subtitle1")
                                version_detail_json = ui.textarea("", value="")\
                                    .props("readonly autogrow")\
                                    .classes("w-full mm-code")
                            version_detail_card.visible = False

                    with ui.tab_panel(calculator_tab):
                        with ui.card().classes("w-full"):
                            ui.label("单笔计算").classes("text-h6")
                            ui.label(
                                "选择环境/模型/版本/文件后，系统按该模型参数要求自动生成录入界面。"
                            ).classes("text-grey-7")

                            calculator_message = ui.label("").classes("text-grey-7")
                            with ui.column().classes("w-full gap-3") as calculator_form_panel:
                                with ui.row().classes("w-full gap-3"):
                                    calculator_environment_select = ui.select(
                                        options={"manual": "手动选择（全部版本）"},
                                        label="环境",
                                        on_change=lambda _: refresh_calculator_models(),
                                    ).classes("flex-1")
                                    calculator_model_select = ui.select(
                                        options=[],
                                        label="模型",
                                        on_change=lambda _: refresh_calculator_versions(),
                                    ).classes("flex-1")
                                    calculator_version_select = ui.select(
                                        options=[],
                                        label="版本",
                                        on_change=lambda _: refresh_calculator_artifacts(),
                                    ).classes("flex-1")
                                calculator_environment_hint = ui.label("").classes("text-grey-7 text-caption")

                                with ui.row().classes("w-full gap-3 items-end"):
                                    calculator_artifact_select = ui.select(
                                        options=[],
                                        label="文件",
                                        on_change=lambda _: on_calculator_artifact_change(),
                                    ).classes("flex-1")
                                    ui.button("加载录入表单", on_click=load_calculator_schema, color="primary")

                                calculator_artifact_hint = ui.label("请先选择环境/模型/版本。").classes(
                                    "text-grey-7 text-caption"
                                )
                                calculator_schema_preview = ui.textarea("参数模板预览", value="")\
                                    .props("readonly autogrow")\
                                    .classes("w-full mm-code")

                                with ui.card().classes("w-full bg-blue-1"):
                                    with ui.row().classes("w-full items-center justify-between"):
                                        ui.label("模型图结构").classes("text-subtitle2")
                                        calculator_graph_summary = ui.label("图结构：0 个节点，0 条连线").classes(
                                            "text-grey-8 text-caption"
                                        )
                                    calculator_graph_chart = ui.echart(empty_calculation_graph_option()).classes("w-full")
                                    calculator_graph_chart.style("height: 460px;")

                                with ui.card().classes("w-full bg-grey-1"):
                                    ui.label("参数录入").classes("text-subtitle2")
                                    calculator_input_form = ui.column().classes("w-full gap-2")

                                with ui.row().classes("w-full justify-end"):
                                    ui.button("执行计算", on_click=run_single_calculation, color="primary")

                                calculator_result = ui.textarea("计算结果", value="")\
                                    .props("readonly autogrow")\
                                    .classes("w-full mm-code")
                            calculator_message.visible = False

                    with ui.tab_panel(deploy_tab):
                        with ui.card().classes("w-full"):
                            ui.label("发布与回滚").classes("text-h6")
                            with ui.row().classes("w-full gap-3"):
                                env_select = ui.select(options=[], label="环境", on_change=lambda _: refresh_current_environment_state())\
                                    .classes("flex-1")
                                rollback_note = ui.input("回滚备注", placeholder="回滚原因").classes("flex-1")
                            with ui.row().classes("w-full gap-2"):
                                ui.button("刷新环境状态", on_click=lambda: refresh_environment_panel()).props("outline")
                                ui.button("执行回滚", on_click=rollback_environment, color="negative")
                            env_state = ui.textarea("环境状态", value="{}")\
                                .props("readonly autogrow")\
                                .classes("w-full mm-code")

                    with ui.tab_panel(audit_tab):
                        with ui.card().classes("w-full"):
                            with ui.row().classes("w-full items-center justify-between"):
                                ui.label("审计日志").classes("text-h6")
                                ui.button("刷新日志", on_click=lambda: refresh_audit_table()).props("outline")
                            audit_filter = ui.input("按模型过滤（可选）", placeholder="model_name")\
                                .classes("w-full")
                            audit_filter.on("change", lambda _: refresh_audit_table())
                            audit_message = ui.label("暂无审计日志。")
                            audit_table = ui.table(
                                columns=[
                                    {"name": "timestamp", "label": "时间", "field": "timestamp"},
                                    {"name": "action", "label": "动作", "field": "action"},
                                    {"name": "user", "label": "用户", "field": "user"},
                                    {"name": "model_name", "label": "模型", "field": "model_name"},
                                    {"name": "summary", "label": "摘要", "field": "summary"},
                                ],
                                rows=[],
                                row_key="id",
                            ).classes("w-full")

                    with ui.tab_panel(admin_tab) as admin_panel:
                        with ui.card().classes("w-full"):
                            ui.label("权限管理（RBAC）").classes("text-h6")
                            ui.label("仅管理员可见，可创建角色/用户并分配权限。").classes(
                                "text-grey-7"
                            )
                            admin_message = ui.label("当前角色无管理权限。")

                            with ui.column().classes("w-full gap-3") as admin_content:
                                with ui.row().classes("w-full gap-3"):
                                    with ui.card().classes("flex-1"):
                                        ui.label("新建角色").classes("text-subtitle1")
                                        role_name = ui.input("角色名", placeholder="risk_reviewer")
                                        role_permissions = ui.input(
                                            "权限列表（逗号分隔）",
                                            placeholder="model:view,audit:view",
                                        )
                                        role_description = ui.input("描述", placeholder="角色说明")
                                        ui.button("创建角色", on_click=create_role).props("outline")

                                    with ui.card().classes("flex-1"):
                                        ui.label("新建用户").classes("text-subtitle1")
                                        new_username = ui.input("用户名", placeholder="alice")
                                        new_password = ui.input("密码", password=True, password_toggle_button=True)
                                        new_user_roles = ui.input("角色（逗号分隔）", placeholder="viewer")
                                        ui.button("创建用户", on_click=create_user).props("outline")

                                with ui.card().classes("w-full"):
                                    ui.label("修改用户角色").classes("text-subtitle1")
                                    with ui.row().classes("w-full gap-3"):
                                        set_username = ui.input("用户名", placeholder="alice").classes("flex-1")
                                        set_roles = ui.input("角色（逗号分隔）", placeholder="operator,viewer").classes(
                                            "flex-1"
                                        )
                                    ui.button("更新角色", on_click=set_user_roles).props("outline")

                                with ui.card().classes("w-full"):
                                    ui.label("危险操作区").classes("text-subtitle1 text-negative")
                                    purge_disabled_hint = ui.label(
                                        "清空功能未启用。请使用 --enable-purge-all 或设置 MODEL_MANAGER_ENABLE_PURGE_ALL=1。"
                                    ).classes("text-grey-7")
                                    with ui.column().classes("w-full gap-2") as purge_tools:
                                        ui.label("该操作会删除所有模型、文件和发布状态。")
                                        ui.label("请输入 DELETE ALL 以确认执行。")
                                        purge_confirm_input = ui.input("确认输入", placeholder="DELETE ALL").classes("w-full")
                                        purge_clear_audit_checkbox = ui.checkbox("同时清空审计日志", value=False)
                                        ui.button("清空所有模型", on_click=purge_all_models_data, color="negative")
                                    purge_tools.visible = enable_purge_all
                                    purge_disabled_hint.visible = not enable_purge_all

                                with ui.row().classes("w-full gap-3"):
                                    with ui.column().classes("flex-1"):
                                        ui.label("角色列表").classes("text-subtitle1")
                                        role_list = ui.textarea("", value="")\
                                            .props("readonly autogrow")\
                                            .classes("w-full mm-code")
                                    with ui.column().classes("flex-1"):
                                        ui.label("用户列表").classes("text-subtitle1")
                                        user_list = ui.textarea("", value="")\
                                            .props("readonly autogrow")\
                                            .classes("w-full mm-code")

        with ui.button(icon="keyboard_arrow_up", on_click=scroll_to_top).props("round color=primary").classes(
            "fixed bottom-6 right-6 z-50 shadow-lg"
        ) as back_to_top_button:
            ui.tooltip("回到顶部")
        back_to_top_button.visible = False

        ui.timer(0.2, _sync_import_progress_ui)
        refresh_all()


def configure_app(
    *,
    data_root: Path,
    max_upload_bytes: int = DEFAULT_MAX_UPLOAD_BYTES,
    enable_purge_all: bool = False,
) -> None:
    global _APP_CONFIGURED, _APP_DATA_ROOT, _APP_MAX_UPLOAD_BYTES, _APP_ENABLE_PURGE_ALL
    resolved_data_root = Path(data_root).resolve()
    resolved_max_upload_bytes = max(1, int(max_upload_bytes))
    resolved_enable_purge_all = bool(enable_purge_all)
    if _APP_CONFIGURED:
        if (
            _APP_DATA_ROOT == resolved_data_root
            and _APP_MAX_UPLOAD_BYTES == resolved_max_upload_bytes
            and _APP_ENABLE_PURGE_ALL == resolved_enable_purge_all
        ):
            return
        raise RuntimeError(
            f"model_manager_tool app already configured with data_root={_APP_DATA_ROOT} "
            f"and max_upload_bytes={_APP_MAX_UPLOAD_BYTES}, cannot reconfigure "
            f"to data_root={resolved_data_root}, max_upload_bytes={resolved_max_upload_bytes}, "
            f"enable_purge_all={resolved_enable_purge_all} "
            "in the same process."
        )

    service = ModelManagerService(base_dir=resolved_data_root)
    sessions = SessionStore()
    _patch_starlette_request_form_limit(min_part_size_bytes=resolved_max_upload_bytes)
    register_model_manager_api(
        service=service,
        sessions=sessions,
        max_upload_bytes=resolved_max_upload_bytes,
        enable_purge_all=resolved_enable_purge_all,
    )
    register_model_manager_page(
        service=service,
        sessions=sessions,
        max_upload_bytes=resolved_max_upload_bytes,
        enable_purge_all=resolved_enable_purge_all,
    )
    _APP_CONFIGURED = True
    _APP_DATA_ROOT = resolved_data_root
    _APP_MAX_UPLOAD_BYTES = resolved_max_upload_bytes
    _APP_ENABLE_PURGE_ALL = resolved_enable_purge_all


def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Standalone model manager for ins_pricing (NiceGUI).")
    parser.add_argument("--host", default=os.environ.get("MODEL_MANAGER_HOST", "127.0.0.1"))
    parser.add_argument("--port", type=int, default=int(os.environ.get("MODEL_MANAGER_PORT", "8092")))
    parser.add_argument(
        "--data-root",
        default=os.environ.get("MODEL_MANAGER_DATA_ROOT", str(DEFAULT_DATA_ROOT)),
        help="Directory used to persist registry, artifacts, audit and RBAC files.",
    )
    parser.add_argument(
        "--max-upload-mb",
        type=int,
        default=int(os.environ.get(MODEL_MANAGER_MAX_UPLOAD_MB_ENV, str(DEFAULT_MAX_UPLOAD_MB))),
        help="Per-file upload limit in MB (applies to API import and NiceGUI uploader).",
    )
    parser.add_argument(
        "--enable-purge-all",
        action="store_true",
        default=_parse_bool_flag(os.environ.get(MODEL_MANAGER_ENABLE_PURGE_ALL_ENV)),
        help="Enable destructive purge-all operation for test environments.",
    )
    return parser.parse_args(argv)


def main(argv: Optional[List[str]] = None) -> None:
    args = parse_args(argv)
    data_root = Path(args.data_root).resolve()
    data_root.mkdir(parents=True, exist_ok=True)
    max_upload_bytes = _resolve_max_upload_bytes(args.max_upload_mb)
    enable_purge_all = bool(args.enable_purge_all)

    try:
        from nicegui import ui
    except Exception as exc:
        raise RuntimeError(
            "NiceGUI is required for model_manager_tool.server. "
            "Install it with: pip install nicegui"
        ) from exc

    configure_app(
        data_root=data_root,
        max_upload_bytes=max_upload_bytes,
        enable_purge_all=enable_purge_all,
    )

    admin_password = os.environ.get("MODEL_MANAGER_ADMIN_PASSWORD", "admin123!")
    storage_secret = os.environ.get(MODEL_MANAGER_STORAGE_SECRET_ENV, "model-manager-local-secret")

    print(f"模型管理工具已启动: http://{args.host}:{args.port}")
    print("UI 框架: NiceGUI")
    print(f"数据目录: {data_root}")
    print(f"单文件上传上限: {_format_size(max_upload_bytes)}")
    print(f"是否启用清空功能: {enable_purge_all}")
    print("初始化管理员账号: admin")
    print(f"初始化管理员密码: {admin_password}")
    print("按 Ctrl+C 停止服务。")

    ui.run(
        host=args.host,
        port=args.port,
        title="模型管理工具",
        language="zh-CN",
        show=False,
        reload=False,
        fastapi_docs=True,
        storage_secret=storage_secret,
    )


if __name__ == "__main__":
    main()
 


