#!/usr/bin/env python3
"""MirrorOS primary dashboard bridge.

Local-only HTTP bridge for the browser UI. It serves the dashboard, queues prompts
for the active MirrorOS runtime session, and manages provider endpoint metadata
plus redacted credential state. It intentionally does not execute kernel actions.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import secrets
import subprocess
import time
import threading
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

WORKSPACE_ROOT = Path(__file__).resolve().parents[3]
MAILBOX_INBOX = WORKSPACE_ROOT / "Mailbox" / "Inbox"
DASHBOARD_PROMPTS = WORKSPACE_ROOT / "State" / "DashboardPrompts"
SESSION_DIR = WORKSPACE_ROOT / "State" / "Sessions"
CONTINUE_REQUEST = WORKSPACE_ROOT / "State" / "continue_session.json"
MEMORY_CHAT = WORKSPACE_ROOT / "Memory" / "chat_history.json"
PROVIDER_CONFIG = WORKSPACE_ROOT / "Config" / "providers.json"
LEGACY_PROVIDER_CONFIG = WORKSPACE_ROOT / "Config" / "provider.json"
ACTIVE_STATE = WORKSPACE_ROOT / "Config" / "active_state.json"
AGENT_STATUS = WORKSPACE_ROOT / "State" / "agent_status.json"
KEY_DIR = WORKSPACE_ROOT / "Config" / "Keys"
OAUTH_CONFIG_DIR = WORKSPACE_ROOT / "Config" / "OAuth"
MODEL_CACHE = WORKSPACE_ROOT / "Config" / "provider_model_cache.json"
NEW_MODEL_WINDOW_SECONDS = 14 * 24 * 60 * 60
BUSY = False
OAUTH_STATES: dict[str, dict] = {}
OPENAI_CODEX_AUTH: dict[str, object] = {}
AGENTS = {"OS", "OSX", "OSP", "OSH"}

PROVIDER_DEFAULTS = {
    "gemini": {
        "name": "Google Gemini / free key",
        "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
        "default_model": "gemini-3.5-flash",
        "model_options": ["gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-pro"],
        "auth_mode": "api_key",
        "type": "openai-compatible",
        "key_file": "Config/Keys/gemini.key.txt",
        "instructions_url": "https://aistudio.google.com/app/apikey",
        "category": "recommended",
        "reason": "Default executable route: already configured for the included free Gemini key path and low-friction first run."
    },
    "ollama-cloud": {
        "name": "Ollama Cloud",
        "endpoint": "https://ollama.com/v1/chat/completions",
        "default_model": "nemotron-3-ultra:cloud",
        "model_options": ["nemotron-3-ultra:cloud", "gpt-oss:120b", "llama3.1"],
        "auth_mode": "api_key",
        "type": "openai-compatible",
        "key_file": "Config/Keys/ollama-cloud.key.txt",
        "instructions_url": "https://ollama.com/",
        "category": "recommended",
        "reason": "Existing executable configuration for Ollama cloud: useful when Gemini is not the desired backend."
    },
    "local-ollama": {
        "name": "Local Ollama",
        "endpoint": "http://localhost:11434/v1/chat/completions",
        "default_model": "llama3.1",
        "model_options": ["llama3.1", "qwen3", "deepseek-r1", "gpt-oss:120b"],
        "auth_mode": "api_key",
        "type": "openai-compatible",
        "key_file": "Config/Keys/local-ollama.key.txt",
        "instructions_url": "https://ollama.com/search",
        "category": "recommended",
        "reason": "Existing local Ollama profile for users running models on their own machine; usually no key required."
    },
    "openai-compatible": {
        "name": "Custom OpenAI-compatible API",
        "endpoint": "https://api.example.com/v1/chat/completions",
        "default_model": "your-model-id",
        "model_options": ["your-model-id"],
        "auth_mode": "api_key",
        "type": "openai-compatible",
        "key_file": "Config/Keys/openai-compatible.key.txt",
        "instructions_url": "",
        "category": "recommended",
        "reason": "Generic fallback for any service that implements the OpenAI chat-completions shape."
    },
    "chatgpt-oauth": {
        "name": "ChatGPT / OpenAI OAuth",
        "endpoint": "codex://chatgpt",
        "default_model": "gpt-5.5",
        "model_options": ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-4.1"],
        "auth_mode": "codex_oauth",
        "type": "codex-oauth",
        "oauth_url": "https://platform.openai.com/settings/organization/general",
        "authorization_url": "",
        "token_url": "",
        "oauth_scope": "",
        "key_file": "Config/OAuth/chatgpt-oauth/access_token.key.txt",
        "instructions_url": "https://platform.openai.com/api-keys",
        "category": "recommended",
        "reason": "OAuth/login route for users configuring OpenAI through a browser/app flow; unlike API providers, it does not use pasted bearer keys."
    },
    "openai-api": {
        "name": "OpenAI API",
        "endpoint": "https://api.openai.com/v1/chat/completions",
        "default_model": "gpt-5.5",
        "model_options": ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-4.1", "gpt-4.1-mini"],
        "auth_mode": "api_key",
        "type": "openai-compatible",
        "key_file": "Config/Keys/openai-api.key.txt",
        "instructions_url": "https://platform.openai.com/api-keys",
        "category": "frontier",
        "reason": "Frontier direct OpenAI API route for platform billing and normal API-key integration."
    },
    "anthropic": {
        "name": "Anthropic Claude",
        "endpoint": "https://api.anthropic.com/v1/messages",
        "default_model": "claude-sonnet-4-5",
        "model_options": ["claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5"],
        "auth_mode": "api_key",
        "type": "anthropic",
        "key_file": "Config/Keys/anthropic.key.txt",
        "instructions_url": "https://console.anthropic.com/",
        "category": "frontier",
        "reason": "Frontier reasoning/writing fallback after the executable's Gemini/Ollama defaults are working."
    },
    "grok": {
        "name": "xAI Grok",
        "endpoint": "https://api.x.ai/v1/chat/completions",
        "default_model": "grok-4",
        "model_options": ["grok-4", "grok-3", "grok-3-mini"],
        "auth_mode": "api_key",
        "type": "openai-compatible",
        "key_file": "Config/Keys/grok.key.txt",
        "instructions_url": "https://console.x.ai/",
        "category": "frontier",
        "reason": "Frontier alternative with a different ecosystem and behavior profile."
    },
}

def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

def utc_stamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")

def read_json(path: Path, default):
    try:
        return json.loads(path.read_text(encoding="utf-8-sig"))
    except Exception:
        return default

def write_json_atomic(path: Path, data) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp")
    tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
    tmp.replace(path)

def safe_chat_load() -> list:
    data = read_json(MEMORY_CHAT, [])
    return data if isinstance(data, list) else []

def append_chat(role: str, content: str, **extra) -> None:
    MEMORY_CHAT.parent.mkdir(parents=True, exist_ok=True)
    data = safe_chat_load()
    entry = {"role": role, "content": content, "timestamp_utc": now_iso()}
    entry.update(extra)
    data.append(entry)
    write_json_atomic(MEMORY_CHAT, data[-500:])

def safe_slug(text: str, fallback: str = "session") -> str:
    text = re.sub(r"\s+", " ", str(text or "")).strip()
    text = re.sub(r"[^A-Za-z0-9 _.-]", "", text).strip().lower().replace(" ", "-")
    return (text[:70].strip("-._") or fallback)

def session_title_from_messages(messages: list) -> str:
    for item in reversed(messages or []):
        if isinstance(item, dict) and str(item.get("role", "")).lower() == "user":
            content = re.sub(r"\s+", " ", str(item.get("content", ""))).strip()
            if content:
                return content[:90]
    return "Untitled MirrorOS session"

def session_preview(messages: list) -> str:
    for item in reversed(messages or []):
        if isinstance(item, dict):
            content = re.sub(r"\s+", " ", str(item.get("content", ""))).strip()
            if content:
                return content[:220]
    return ""

def active_agent_session(agent: str) -> dict:
    status = read_agent_status().get(agent, {})
    return status if isinstance(status, dict) else {}

def pid_is_alive(pid) -> bool:
    try:
        pid_int = int(pid)
        if pid_int <= 0:
            return False
        ps = f"if (Get-Process -Id {pid_int} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}"
        # The dashboard may be launched from WSL while runtimes are Windows processes.
        # Prefer the Windows process table when PowerShell is available, then fall back to /proc.
        try:
            if subprocess.run(["powershell.exe", "-NoProfile", "-Command", ps], capture_output=True, text=True, timeout=3).returncode == 0:
                return True
        except Exception:
            pass
        return Path(f"/proc/{pid_int}").exists()
    except Exception:
        return False

def as_windows_path(path: Path) -> str:
    text = str(path.resolve())
    if os.name == "nt":
        return text
    m = re.match(r"^/mnt/([A-Za-z])/(.*)$", text)
    if m:
        return f"{m.group(1).upper()}:\\" + m.group(2).replace("/", "\\")
    return text

def ps_quote(value: str) -> str:
    return "'" + str(value).replace("'", "''") + "'"

def launch_agent(agent: str, hidden: bool = True) -> dict:
    agent = str(agent or "OSP").strip().upper()
    if agent not in AGENTS:
        raise ValueError("agent must be one of OS, OSX, OSP, OSH")
    active = detect_active_runtime_agents()
    if agent in active:
        return {"ok": True, "agent": agent, "already_running": True, "runtime_agents": active}
    launchers = {
        "OSP": WORKSPACE_ROOT / "Start MirrorOSP.bat",
        "OS": WORKSPACE_ROOT / "Scripts" / "MirrorOS.ps1",
        "OSX": WORKSPACE_ROOT / "Scripts" / "MirrorOSX.ps1",
        "OSH": WORKSPACE_ROOT / "Scripts" / "Launch-MirrorOSH.ps1",
    }
    target = launchers[agent]
    if not target.exists():
        raise ValueError(f"launcher not found for {agent}: {target.relative_to(WORKSPACE_ROOT)}")
    root_win = as_windows_path(WORKSPACE_ROOT)
    target_win = as_windows_path(target)
    if target.suffix.lower() == ".bat":
        file_path = "cmd.exe"
        argument_list = f"/c \"{target_win}\""
    else:
        file_path = "powershell.exe"
        argument_list = f"-NoProfile -ExecutionPolicy Bypass -File \"{target_win}\""
    window_style = "Hidden" if hidden else "Normal"
    ps = (
        "$p = Start-Process "
        f"-FilePath {ps_quote(file_path)} "
        f"-ArgumentList {ps_quote(argument_list)} "
        f"-WorkingDirectory {ps_quote(root_win)} "
        f"-WindowStyle {window_style} -PassThru; "
        "[pscustomobject]@{ Ok = $true; Pid = $p.Id; Name = $p.ProcessName } | ConvertTo-Json -Compress"
    )
    res = subprocess.run(["powershell.exe", "-NoProfile", "-Command", ps], capture_output=True, text=True, timeout=15)
    if res.returncode != 0:
        detail = (res.stderr or res.stdout or "launch failed").strip()
        raise ValueError(detail[:800])
    try:
        launched = json.loads((res.stdout or "{}").strip())
    except Exception:
        launched = {"raw": (res.stdout or "").strip()}
    return {"ok": True, "agent": agent, "already_running": False, "hidden": hidden, "launcher": str(target.relative_to(WORKSPACE_ROOT)), "process": launched}

def list_sessions() -> dict:
    SESSION_DIR.mkdir(parents=True, exist_ok=True)
    sessions = []
    for path in SESSION_DIR.glob("*.json"):
        rec = read_json(path, {})
        if not isinstance(rec, dict):
            continue
        messages = rec.get("messages") if isinstance(rec.get("messages"), list) else []
        sessions.append({
            "id": rec.get("id") or path.stem,
            "title": rec.get("title") or session_title_from_messages(messages),
            "topic": rec.get("topic") or rec.get("title") or session_title_from_messages(messages),
            "agent": str(rec.get("agent") or "OSP").upper(),
            "message_count": int(rec.get("message_count") or len(messages)),
            "created_utc": rec.get("created_utc"),
            "updated_utc": rec.get("updated_utc") or rec.get("created_utc"),
            "preview": rec.get("preview") or session_preview(messages),
            "path": str(path.relative_to(WORKSPACE_ROOT)),
            "active": False,
        })
    current = safe_chat_load()
    if current:
        sessions.append({
            "id": "active-current",
            "title": "Current active dashboard session",
            "topic": session_title_from_messages(current),
            "agent": str((current[-1].get("agent") if isinstance(current[-1], dict) else "OSP") or "OSP").upper(),
            "message_count": len(current),
            "created_utc": next((m.get("timestamp_utc") for m in current if isinstance(m, dict) and m.get("timestamp_utc")), None),
            "updated_utc": next((m.get("timestamp_utc") for m in reversed(current) if isinstance(m, dict) and m.get("timestamp_utc")), None),
            "preview": session_preview(current),
            "path": str(MEMORY_CHAT.relative_to(WORKSPACE_ROOT)),
            "active": True,
        })
    sessions.sort(key=lambda x: str(x.get("updated_utc") or ""), reverse=True)
    return {"ok": True, "sessions": sessions[:80]}

def find_session_record(session_id: str) -> dict | None:
    if session_id == "active-current":
        return {"id": "active-current", "messages": safe_chat_load(), "agent": "OSP", "title": "Current active dashboard session"}
    for path in SESSION_DIR.glob("*.json"):
        rec = read_json(path, {})
        if isinstance(rec, dict) and (rec.get("id") == session_id or path.stem == session_id):
            rec["_path"] = path
            return rec
    return None

def activate_session(session_id: str, target_agent: str, launch: bool = False) -> dict:
    target_agent = str(target_agent or "OSP").upper()
    if target_agent not in AGENTS:
        raise ValueError("target_agent must be one of OS, OSX, OSP, OSH")
    rec = find_session_record(session_id)
    if not rec:
        raise ValueError("session not found")
    messages = rec.get("messages") if isinstance(rec.get("messages"), list) else []
    if not messages:
        raise ValueError("session has no messages")
    # Re-tag only missing/legacy agent metadata; do not rewrite explicit historical roles/content.
    restored = []
    for item in messages:
        if not isinstance(item, dict):
            continue
        msg = dict(item)
        msg.setdefault("agent", target_agent)
        msg.setdefault("session_agent", target_agent)
        restored.append(msg)
    write_json_atomic(MEMORY_CHAT, restored)
    new_runtime_session_id = f"{target_agent.lower()}-continue-{utc_stamp()}-{safe_slug(session_id)[:24]}"
    write_json_atomic(CONTINUE_REQUEST, {
        "session_id": session_id,
        "target_agent": target_agent,
        "created_utc": now_iso(),
        "new_runtime_session_id": new_runtime_session_id,
        "title": rec.get("title") or session_title_from_messages(restored),
    })
    result = {"ok": True, "session_id": session_id, "target_agent": target_agent, "new_runtime_session_id": new_runtime_session_id, "message_count": len(restored)}
    if launch:
        result["launch"] = launch_agent(target_agent, hidden=True)
    return result

def render_dashboard_prompt_message(message: str, attachments: list) -> str:
    parts = [message.strip()] if message.strip() else []
    text_attachments = []
    binary_attachments = []
    for item in attachments:
        if not isinstance(item, dict):
            continue
        name = str(item.get("name", "attachment"))[:160]
        data = item.get("data")
        if data:
            text_attachments.append((name, str(data)))
        else:
            binary_attachments.append(name)
    for name, data in text_attachments:
        parts.append(f"[DASHBOARD_ATTACHMENT: {name}]\n{data}\n[/DASHBOARD_ATTACHMENT]")
    if binary_attachments:
        parts.append("[DASHBOARD_ATTACHMENTS_NOT_INCLUDED] " + ", ".join(binary_attachments))
    return "\n\n".join(parts).strip()

def queue_dashboard_prompt(agent: str, message: str, attachments: list, target_session_id: str) -> tuple[str, Path, str]:
    prompt_id = f"dashboard-{agent.lower()}-{utc_stamp()}"
    prompt_dir = DASHBOARD_PROMPTS / agent
    prompt_dir.mkdir(parents=True, exist_ok=True)
    rendered = render_dashboard_prompt_message(message, attachments)
    payload = {
        "id": prompt_id,
        "agent": agent,
        "source": "dashboard",
        "status": "queued",
        "target_session_id": target_session_id,
        "created_utc": now_iso(),
        "message": rendered,
        "raw_message": message,
        "attachment_count": len(attachments),
        "attachments": [{
            "name": str(a.get("name", "attachment"))[:160],
            "size": a.get("size"),
            "type": str(a.get("type", ""))[:120],
            "has_text_data": bool(a.get("data")),
            "is_binary": bool(a.get("isBinary")),
            "data": str(a.get("data", "")) if a.get("data") else "",
        } for a in attachments if isinstance(a, dict)],
    }
    prompt_path = prompt_dir / f"{prompt_id}.json"
    write_json_atomic(prompt_path, payload)
    return prompt_id, prompt_path, rendered

RUNTIME_DETECT_CACHE = {"ts": 0.0, "agents": []}

def detect_active_runtime_agents() -> list[str]:
    """Best-effort Windows process check for the active console agent(s).

    Legacy chat entries did not carry an agent id. When exactly one runtime is
    open, the dashboard can safely display those legacy messages under that
    agent instead of guessing from boot text that mentions every variant.
    """
    now = time.time()
    if now - float(RUNTIME_DETECT_CACHE.get("ts", 0.0)) < 5:
        return list(RUNTIME_DETECT_CACHE.get("agents", []))
    agents: set[str] = set()
    try:
        ps = (
            "Get-CimInstance Win32_Process | "
            "Where-Object { $_.CommandLine -match 'MirrorOSP.py|MirrorOSX.ps1|MirrorOS.ps1|MirrorOSH' } | "
            "Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Depth 3"
        )
        res = subprocess.run(["powershell.exe", "-NoProfile", "-Command", ps], capture_output=True, text=True, timeout=4)
        raw = (res.stdout or "").strip()
        if raw:
            rows = json.loads(raw)
            if isinstance(rows, dict):
                rows = [rows]
            for row in rows if isinstance(rows, list) else []:
                cmd = str(row.get("CommandLine", ""))
                if "dashboard_server.py" in cmd:
                    continue
                if re.search(r"MirrorOSP\.py", cmd, re.I):
                    agents.add("OSP")
                elif re.search(r"MirrorOSX\.ps1", cmd, re.I):
                    agents.add("OSX")
                elif re.search(r"MirrorOS\.ps1", cmd, re.I):
                    agents.add("OS")
                elif re.search(r"MirrorOSH", cmd, re.I):
                    agents.add("OSH")
    except Exception:
        pass
    detected = sorted(agents)
    RUNTIME_DETECT_CACHE.update({"ts": now, "agents": detected})
    return detected

def infer_agent_from_content(content: str) -> str | None:
    c = str(content or "")
    # Boot payloads list every variant; don't misclassify them by keyword order.
    if c.lstrip().startswith("[SYSTEM BOOT SEQUENCE]"):
        if "You are MirrorOSX" in c:
            return "OSX"
        if "You are MirrorOSP" in c:
            return "OSP"
        if "You are MirrorOSH" in c:
            return "OSH"
        if "You are MirrorOS" in c:
            return "OS"
        return None
    patterns = [("OSX", r"\bMirrorOSX\b|\bOSX\b"), ("OSP", r"\bMirrorOSP\b|\bOSP\b"), ("OSH", r"\bMirrorOSH\b|\bOSH\b"), ("OS", r"\bMirrorOS\b|\bOS\b")]
    for agent, pattern in patterns:
        if re.search(pattern, c):
            return agent
    return None

def read_agent_status() -> dict:
    status = read_json(AGENT_STATUS, {})
    return status if isinstance(status, dict) else {}

def summarize_agent_sessions(messages: list[dict], runtime_agents: list[str]) -> dict:
    persisted = read_agent_status()
    sessions = {}
    for agent in sorted(AGENTS):
        agent_messages = [m for m in messages if m.get("agent") == agent]
        last_msg = agent_messages[-1] if agent_messages else None
        last_user = next((m for m in reversed(agent_messages) if str(m.get("role", "")).lower() == "user"), None)
        last_assistant = next((m for m in reversed(agent_messages) if str(m.get("role", "")).lower() == "assistant"), None)
        persisted_agent = persisted.get(agent, {}) if isinstance(persisted.get(agent), dict) else {}
        runtime_status = str(persisted_agent.get("status", "")).lower()
        process_alive = agent in runtime_agents
        last_user_seq = last_user.get("sequence") if last_user else None
        last_assistant_seq = last_assistant.get("sequence") if last_assistant else None
        has_reply_to_last_prompt = (
            last_user_seq is not None
            and last_assistant_seq is not None
            and int(last_assistant_seq) > int(last_user_seq)
        )
        if runtime_status in {"responding", "looping", "processing"}:
            status = runtime_status
            completed = False
        elif has_reply_to_last_prompt:
            status = "completed"
            completed = True
        elif last_user_seq is not None:
            status = "waiting" if process_alive else "queued"
            completed = False
        else:
            status = persisted_agent.get("status") or ("idle" if process_alive else "offline")
            completed = False
        sessions[agent] = {
            "agent": agent,
            "status": status,
            "completed": completed,
            "process_alive": process_alive,
            "message_count": len(agent_messages),
            "last_sequence": last_msg.get("sequence") if last_msg else None,
            "last_role": last_msg.get("role") if last_msg else None,
            "last_user_sequence": last_user_seq,
            "last_assistant_sequence": last_assistant_seq,
            "last_updated_utc": persisted_agent.get("updated_utc") or last_msg.get("timestamp_utc") if last_msg else persisted_agent.get("updated_utc"),
            "loop": {
                "auto_loop_count": persisted_agent.get("auto_loop_count"),
                "max_auto_loops": persisted_agent.get("max_auto_loops"),
                "auto_trigger": bool(persisted_agent.get("auto_trigger", False)),
            },
        }
    return sessions

def normalized_chat_history() -> dict:
    raw = safe_chat_load()
    runtime_agents = detect_active_runtime_agents()
    legacy_owner = runtime_agents[0] if len(runtime_agents) == 1 else None
    messages = []
    for idx, item in enumerate(raw):
        if not isinstance(item, dict):
            continue
        msg = dict(item)
        agent = str(msg.get("agent") or "").upper()
        if agent not in AGENTS:
            inferred = legacy_owner or infer_agent_from_content(str(msg.get("content", ""))) or "OS"
            msg["agent"] = inferred
            msg["agent_inferred"] = True
        else:
            msg["agent"] = agent
        msg.setdefault("sequence", idx)
        messages.append(msg)
    counts = {"ALL": len(messages), "OS": 0, "OSX": 0, "OSP": 0, "OSH": 0}
    for msg in messages:
        a = msg.get("agent")
        if a in counts:
            counts[a] += 1
    sessions = summarize_agent_sessions(messages, runtime_agents)
    return {"ok": True, "messages": messages, "counts": counts, "sessions": sessions, "runtime_agents": runtime_agents, "legacy_owner": legacy_owner}

def safe_provider_id(value: str) -> str:
    value = str(value or "").strip()
    if value not in PROVIDER_DEFAULTS:
        raise ValueError("unknown provider")
    return value

def mask_secret(value: str, visible: int = 4) -> str:
    value = str(value or "").strip()
    if not value:
        return ""
    tail = value[-visible:] if len(value) > visible else value
    return f"••••{tail}"

def workspace_rel_path(rel: str) -> Path:
    return WORKSPACE_ROOT / str(rel or "").replace("\\", "/")

def provider_key_dir(provider: str) -> Path:
    provider = safe_provider_id(provider)
    return KEY_DIR / provider

def provider_key_meta_path(provider: str) -> Path:
    return provider_key_dir(provider) / "keys.json"

def oauth_config_key_path(provider: str) -> Path:
    provider = safe_provider_id(provider)
    return OAUTH_CONFIG_DIR / provider / "oauth_config_key.txt"

def oauth_config_key_registry_path(provider: str) -> Path:
    provider = safe_provider_id(provider)
    return OAUTH_CONFIG_DIR / provider / "config_keys.json"

def oauth_client_config_path(provider: str) -> Path:
    provider = safe_provider_id(provider)
    return OAUTH_CONFIG_DIR / provider / "client.json"

def oauth_token_path(provider: str) -> Path:
    provider = safe_provider_id(provider)
    return OAUTH_CONFIG_DIR / provider / "tokens.json"

def oauth_access_key_path(provider: str) -> Path:
    provider = safe_provider_id(provider)
    return workspace_rel_path(PROVIDER_DEFAULTS[provider].get("key_file", f"Config/OAuth/{provider}/access_token.key.txt"))

def model_cache_now() -> float:
    return time.time()

def parse_iso_ts(value: str) -> float:
    try:
        return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
    except Exception:
        return 0.0

def _legacy_key_path(provider: str) -> Path:
    provider = safe_provider_id(provider)
    return workspace_rel_path(PROVIDER_DEFAULTS[provider]["key_file"])

def load_raw_key_metadata(provider: str) -> list[dict]:
    provider = safe_provider_id(provider)
    meta = read_json(provider_key_meta_path(provider), [])
    return meta if isinstance(meta, list) else []

def save_key_metadata(provider: str, entries: list[dict]) -> None:
    provider = safe_provider_id(provider)
    meta_entries = []
    for item in entries:
        if not isinstance(item, dict) or not item.get("id") or not item.get("key_file"):
            continue
        meta_entries.append({
            "id": str(item["id"]),
            "label": str(item.get("label") or item["id"]),
            "key_file": str(item["key_file"]),
            "created_utc": item.get("created_utc"),
            "updated_utc": item.get("updated_utc"),
            "active": bool(item.get("active", False)),
        })
    write_json_atomic(provider_key_meta_path(provider), meta_entries)

def load_provider_keys(provider: str) -> list[dict]:
    """Return redacted key metadata for one provider. Never returns key material."""
    provider = safe_provider_id(provider)
    by_id = {str(item.get("id")): item for item in load_raw_key_metadata(provider) if isinstance(item, dict) and item.get("id")}

    # Backward compatibility with the original single-key file.
    legacy = _legacy_key_path(provider)
    if legacy.exists() and legacy.stat().st_size > 0 and "default" not in by_id:
        by_id["default"] = {
            "id": "default",
            "label": "Default key",
            "key_file": PROVIDER_DEFAULTS[provider]["key_file"],
            "created_utc": None,
            "updated_utc": None,
            "active": True,
        }

    redacted = []
    for key_id, item in sorted(by_id.items(), key=lambda kv: (kv[0] != "default", kv[0])):
        key_file = str(item.get("key_file", ""))
        path = workspace_rel_path(key_file) if key_file else None
        exists = bool(path and path.exists() and path.stat().st_size > 0)
        if not exists:
            continue
        try:
            raw = path.read_text(encoding="utf-8").strip()
        except Exception:
            raw = ""
        redacted.append({
            "id": key_id,
            "label": str(item.get("label") or key_id),
            "masked": mask_secret(raw),
            "last_chars": raw[-4:] if raw else "",
            "created_utc": item.get("created_utc"),
            "updated_utc": item.get("updated_utc"),
            "active": bool(item.get("active", key_id == "default")),
        })
    return redacted

def active_provider_secret(provider: str) -> str:
    """Return the active bearer secret for provider discovery/runtime checks. Never expose this through JSON."""
    provider = safe_provider_id(provider)
    if provider_is_oauth(provider):
        token = load_oauth_tokens(provider).get("access_token", "")
        if token:
            return str(token).strip()
    for item in load_raw_key_metadata(provider):
        if item.get("active") and item.get("key_file"):
            path = workspace_rel_path(str(item["key_file"]))
            if path.exists():
                return path.read_text(encoding="utf-8").strip()
    legacy = _legacy_key_path(provider)
    if legacy.exists():
        return legacy.read_text(encoding="utf-8").strip()
    return ""

def load_oauth_client_config(provider: str) -> dict:
    provider = safe_provider_id(provider)
    defaults = PROVIDER_DEFAULTS[provider]
    data = read_json(oauth_client_config_path(provider), {})
    if not isinstance(data, dict):
        data = {}
    return {
        "provider": provider,
        "client_id": str(data.get("client_id", "")),
        "client_secret_set": bool(data.get("client_secret")),
        "client_secret_masked": mask_secret(str(data.get("client_secret", ""))),
        "authorization_url": str(data.get("authorization_url", defaults.get("authorization_url", ""))),
        "token_url": str(data.get("token_url", defaults.get("token_url", ""))),
        "scope": str(data.get("scope", defaults.get("oauth_scope", ""))),
        "redirect_uri": str(data.get("redirect_uri", "http://127.0.0.1:8000/api/oauth/callback")),
        "updated_utc": data.get("updated_utc"),
    }

def save_oauth_client_config(provider: str, payload: dict) -> dict:
    provider = safe_provider_id(provider)
    if not provider_is_oauth(provider):
        raise ValueError("provider is not configured for OAuth")
    existing_raw = read_json(oauth_client_config_path(provider), {})
    existing_raw = existing_raw if isinstance(existing_raw, dict) else {}
    client_secret = str(payload.get("client_secret", "")).strip() or str(existing_raw.get("client_secret", ""))
    data = {
        "client_id": str(payload.get("client_id", existing_raw.get("client_id", ""))).strip(),
        "client_secret": client_secret,
        "authorization_url": str(payload.get("authorization_url", existing_raw.get("authorization_url", PROVIDER_DEFAULTS[provider].get("authorization_url", "")))).strip(),
        "token_url": str(payload.get("token_url", existing_raw.get("token_url", PROVIDER_DEFAULTS[provider].get("token_url", "")))).strip(),
        "scope": str(payload.get("scope", existing_raw.get("scope", PROVIDER_DEFAULTS[provider].get("oauth_scope", "")))).strip(),
        "redirect_uri": str(payload.get("redirect_uri", existing_raw.get("redirect_uri", "http://127.0.0.1:8000/api/oauth/callback"))).strip(),
        "updated_utc": now_iso(),
    }
    write_json_atomic(oauth_client_config_path(provider), data)
    return load_oauth_client_config(provider)

def load_oauth_tokens(provider: str) -> dict:
    data = read_json(oauth_token_path(provider), {})
    return data if isinstance(data, dict) else {}

def save_oauth_tokens(provider: str, tokens: dict) -> dict:
    provider = safe_provider_id(provider)
    if not isinstance(tokens, dict) or not tokens.get("access_token"):
        raise ValueError("OAuth token response did not include access_token")
    existing = load_oauth_tokens(provider)
    existing.update(tokens)
    existing["updated_utc"] = now_iso()
    write_json_atomic(oauth_token_path(provider), existing)
    key_path = oauth_access_key_path(provider)
    key_path.parent.mkdir(parents=True, exist_ok=True)
    key_path.write_text(str(existing["access_token"]).strip() + "\n", encoding="utf-8")
    try:
        os.chmod(key_path, 0o600)
    except OSError:
        pass
    config = load_provider_config()
    item = config["providers"][provider]
    item["key_file"] = str(key_path.relative_to(WORKSPACE_ROOT)).replace("/", "\\")
    item["keyFile"] = item["key_file"]
    item["api_key_set"] = True
    item["auth_mode"] = "oauth_ready"
    config["active_provider"] = provider
    config["activeProvider"] = provider
    write_provider_config_without_secrets(config)
    return {"set": True, "masked": mask_secret(str(existing["access_token"]), 6), "updated_utc": existing["updated_utc"]}

def load_model_cache() -> dict:
    data = read_json(MODEL_CACHE, {})
    return data if isinstance(data, dict) else {}

def save_model_cache(data: dict) -> None:
    write_json_atomic(MODEL_CACHE, data if isinstance(data, dict) else {})

def canonical_model_id(model: str) -> str:
    model = str(model or "").strip()
    aliases = {
        "glm5.2": "glm-5.2",
        "glm5": "glm-5",
        "nemotron-3-ultra:cloud": "nemotron-3-ultra",
        "nemotron3-ultra": "nemotron-3-ultra",
    }
    return aliases.get(model, model)

def dedupe_models(models: list[str]) -> list[str]:
    seen = set()
    out = []
    for model in models or []:
        mid = canonical_model_id(model)
        key = mid.lower()
        if mid and key not in seen:
            seen.add(key)
            out.append(mid)
    return out

def build_model_metadata(provider: str, models: list[str], source: str) -> dict:
    cache = load_model_cache()
    pdata = cache.setdefault(provider, {})
    now = now_iso()
    now_ts = model_cache_now()
    result = {}
    for model in models:
        meta = pdata.get(model, {}) if isinstance(pdata.get(model), dict) else {}
        first_seen = meta.get("first_seen_utc") or now
        meta.update({"first_seen_utc": first_seen, "last_seen_utc": now, "source": source})
        pdata[model] = meta
        age = now_ts - parse_iso_ts(first_seen)
        result[model] = {**meta, "is_new": age <= NEW_MODEL_WINDOW_SECONDS}
    cache[provider] = pdata
    save_model_cache(cache)
    return result

def models_url_for_endpoint(endpoint: str) -> str:
    base = chat_to_base_url(endpoint).rstrip("/")
    if base.endswith("/v1") or base.endswith("/v1beta"):
        return base + "/models"
    return base + "/models"

def parse_model_ids(payload) -> list[str]:
    items = payload.get("data", payload.get("models", [])) if isinstance(payload, dict) else []
    models = []
    if isinstance(items, dict):
        items = list(items.values())
    for item in items if isinstance(items, list) else []:
        mid = item.get("id") if isinstance(item, dict) else item
        if mid:
            text = str(mid).strip()
            if text and text not in models:
                models.append(text)
    return dedupe_models(models)

def discover_provider_models(provider: str, force: bool = False) -> dict:
    provider = safe_provider_id(provider)
    config = load_provider_config()
    item = config["providers"][provider]
    existing_models = item.get("model_options") or PROVIDER_DEFAULTS[provider].get("model_options", [])
    source = "configured"
    error = ""
    models = dedupe_models([str(m) for m in existing_models if m])
    endpoint = item.get("endpoint") or PROVIDER_DEFAULTS[provider].get("endpoint", "")
    if endpoint.startswith(("http://", "https://")):
        headers = {"Accept": "application/json"}
        secret = active_provider_secret(provider)
        if secret:
            headers["Authorization"] = "Bearer " + secret
        try:
            req = urllib.request.Request(models_url_for_endpoint(endpoint), headers=headers)
            with urllib.request.urlopen(req, timeout=12) as resp:
                payload = json.loads(resp.read().decode("utf-8"))
            live_models = parse_model_ids(payload)
            if live_models:
                models = live_models
                source = "live"
        except Exception as exc:
            error = str(exc)
    metadata = build_model_metadata(provider, models, source)
    # Preserve curated favorites. If none existed before first live refresh, seed them from the old configured list/current model, not the full live inventory.
    raw = read_json(PROVIDER_CONFIG, {}) if PROVIDER_CONFIG.exists() else {}
    providers = raw.setdefault("providers", {})
    entry = providers.setdefault(provider, {})
    old_favs = entry.get("favoriteModels", entry.get("favorite_models"))
    old_models = entry.get("models", existing_models)
    entry["models"] = dedupe_models(models)
    model_value = canonical_model_id(entry.get("model") or item.get("default_model"))
    if model_value:
        entry["model"] = model_value
    if old_favs is None:
        seed = old_models if isinstance(old_models, list) and old_models else [entry.get("model") or item.get("default_model")]
        valid = [canonical_model_id(m) for m in seed if canonical_model_id(m) in models]
        if not valid and model_value in models:
            valid = [model_value]
        entry["favoriteModels"] = dedupe_models(valid)
    elif isinstance(old_favs, list):
        valid = [canonical_model_id(m) for m in old_favs if canonical_model_id(m) in models]
        if old_favs and not valid and model_value in models:
            valid = [model_value]
        entry["favoriteModels"] = dedupe_models(valid)
    write_json_atomic(PROVIDER_CONFIG, raw)
    return {"provider": provider, "models": models, "model_metadata": metadata, "source": source, "error": error, "count": len(models)}

def refresh_provider_models(provider: str | None = None) -> dict:
    targets = [safe_provider_id(provider)] if provider else list(PROVIDER_DEFAULTS)
    results = {}
    for pid in targets:
        results[pid] = discover_provider_models(pid)
    return {"ok": True, "results": results, "providers": load_provider_config()}

def run_powershell_codex(args: list[str], timeout_seconds: int = 20) -> subprocess.CompletedProcess:
    return subprocess.run(["powershell.exe", "-NoProfile", "-Command", " ".join(args)], capture_output=True, text=True, timeout=timeout_seconds)

def codex_available() -> bool:
    try:
        res = subprocess.run(["powershell.exe", "-NoProfile", "-Command", "Get-Command codex -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source"], capture_output=True, text=True, timeout=8)
        return res.returncode == 0 and bool(res.stdout.strip())
    except Exception:
        return False

def openai_codex_login_status() -> dict:
    if not codex_available():
        return {"available": False, "logged_in": False, "message": "Codex CLI was not found. Install Codex first."}
    try:
        res = subprocess.run(["powershell.exe", "-NoProfile", "-Command", "codex login status"], capture_output=True, text=True, timeout=12)
        out = (res.stdout + res.stderr).strip()
        return {"available": True, "logged_in": res.returncode == 0, "message": out or ("Logged in" if res.returncode == 0 else "Not logged in")}
    except Exception as exc:
        return {"available": True, "logged_in": False, "message": str(exc)}

def strip_ansi(text: str) -> str:
    return re.sub(r"\x1b\[[0-9;]*m", "", str(text or ""))

def _openai_codex_reader(proc: subprocess.Popen):
    output = OPENAI_CODEX_AUTH.setdefault("output", [])
    try:
        if proc.stdout:
            for line in proc.stdout:
                line = line.rstrip()
                clean = strip_ansi(line)
                output.append(clean)
                m_url = re.search(r"https://\S+", clean)
                if m_url:
                    OPENAI_CODEX_AUTH["url"] = m_url.group(0)
                m_code = re.search(r"\b[A-Z0-9]{4,}-[A-Z0-9-]{4,}\b", clean)
                if m_code:
                    OPENAI_CODEX_AUTH["code"] = m_code.group(0)
                OPENAI_CODEX_AUTH["output_preview"] = "\n".join(output[-20:])
    except Exception as exc:
        OPENAI_CODEX_AUTH["reader_error"] = str(exc)


def start_openai_codex_device_auth() -> dict:
    """Start the real OpenAI/Codex device-code login and keep the process alive for completion."""
    if not codex_available():
        return {"ok": False, "error": "Codex CLI not found", "install_url": "https://developers.openai.com/codex/cli"}
    existing = OPENAI_CODEX_AUTH.get("process")
    if existing and getattr(existing, "poll", lambda: 1)() is None:
        auth = {k: v for k, v in OPENAI_CODEX_AUTH.items() if k not in ("process", "reader")}
        if auth.get("url") and auth.get("code"):
            return {"ok": True, "already_running": True, "auth": auth, "status": openai_codex_login_status()}
        return {"ok": False, "error": "An OpenAI login is already starting. Wait a moment or refresh status.", "auth": auth}
    OPENAI_CODEX_AUTH.clear()
    proc = subprocess.Popen(
        ["powershell.exe", "-NoProfile", "-Command", "codex login --device-auth"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,
    )
    OPENAI_CODEX_AUTH.update({"started_utc": now_iso(), "url": "", "code": "", "output": [], "output_preview": "", "process": proc})
    reader = threading.Thread(target=_openai_codex_reader, args=(proc,), daemon=True)
    OPENAI_CODEX_AUTH["reader"] = reader
    reader.start()
    deadline = time.time() + 10
    while time.time() < deadline:
        if OPENAI_CODEX_AUTH.get("url") and OPENAI_CODEX_AUTH.get("code"):
            break
        if proc.poll() is not None:
            break
        time.sleep(0.1)
    auth = {k: v for k, v in OPENAI_CODEX_AUTH.items() if k not in ("process", "reader", "output")}
    if not (auth.get("url") and auth.get("code")):
        return {"ok": False, "error": "Could not read OpenAI login URL/code yet", "auth": auth, "status": openai_codex_login_status()}
    return {"ok": True, "auth": auth, "status": openai_codex_login_status(), "message": "Open the URL, sign in, and enter the copied one-time code."}


def cancel_openai_codex_device_auth() -> dict:
    proc = OPENAI_CODEX_AUTH.get("process")
    killed = False
    if proc and getattr(proc, "poll", lambda: None)() is None:
        try:
            proc.terminate()
            killed = True
        except Exception:
            try:
                proc.kill()
                killed = True
            except Exception:
                pass
    OPENAI_CODEX_AUTH.clear()
    return {"ok": True, "killed": killed}

def openai_codex_device_auth_status() -> dict:
    proc = OPENAI_CODEX_AUTH.get("process")
    auth = {k: v for k, v in OPENAI_CODEX_AUTH.items() if k not in ("process", "reader", "output")}
    if proc and getattr(proc, "poll", lambda: None)() is None:
        return {"ok": True, "in_progress": True, "auth": auth, "status": openai_codex_login_status()}
    if proc:
        return {"ok": True, "in_progress": False, "exit_code": proc.returncode, "auth": auth, "status": openai_codex_login_status()}
    return {"ok": True, "in_progress": False, "auth": auth, "status": openai_codex_login_status()}

def load_oauth_config_key(provider: str) -> dict:
    """Return redacted OAuth setup-code metadata. Never returns full key."""
    provider = safe_provider_id(provider)
    path = oauth_config_key_path(provider)
    registry = read_json(oauth_config_key_registry_path(provider), {})
    if not isinstance(registry, dict):
        registry = {}
    accounts = registry.get("accounts", {}) if isinstance(registry.get("accounts"), dict) else {}
    active_account = registry.get("active_account", "default")
    if not path.exists() or path.stat().st_size == 0:
        return {"set": False, "masked": "", "updated_utc": None, "active_account": active_account, "accounts": accounts}
    raw = path.read_text(encoding="utf-8").strip()
    return {
        "set": bool(raw),
        "masked": mask_secret(raw, visible=6),
        "updated_utc": datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(),
        "active_account": active_account,
        "accounts": accounts,
    }

def generate_oauth_config_key(provider: str, account_label: str = "default", overwrite: bool = True) -> dict:
    """Generate a local OAuth setup/config code for provider-side app/device configuration.

    Default behavior overwrites the active/default code, matching the low-friction setup case.
    For multiple accounts, pass overwrite=False and an account_label; the generated code is
    also made active so the runtime uses the most recently selected account code.
    """
    provider = safe_provider_id(provider)
    label = str(account_label or "default").strip()[:80] or "default"
    registry_path = oauth_config_key_registry_path(provider)
    registry = read_json(registry_path, {})
    if not isinstance(registry, dict):
        registry = {}
    accounts = registry.get("accounts", {}) if isinstance(registry.get("accounts"), dict) else {}
    if overwrite:
        account_id = registry.get("active_account") or "default"
    else:
        base = re.sub(r"[^a-zA-Z0-9_-]+", "-", label.lower()).strip("-") or "account"
        account_id = base
        if account_id in accounts:
            account_id = f"{base}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"
    key = "aos-oauth-" + secrets.token_urlsafe(32)
    rel_path = f"Config/OAuth/{provider}/setup-codes/{account_id}.key.txt"
    account_path = workspace_rel_path(rel_path)
    account_path.parent.mkdir(parents=True, exist_ok=True)
    account_path.write_text(key + "\n", encoding="utf-8")
    path = oauth_config_key_path(provider)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(key + "\n", encoding="utf-8")
    try:
        os.chmod(account_path, 0o600); os.chmod(path, 0o600)
    except OSError:
        pass
    now = now_iso()
    accounts[account_id] = {"label": label, "key_file": rel_path, "masked": mask_secret(key, visible=6), "updated_utc": now}
    registry = {"active_account": account_id, "accounts": accounts, "updated_utc": now}
    write_json_atomic(registry_path, registry)
    redacted = load_oauth_config_key(provider)
    redacted["generated_key"] = key
    redacted["provider"] = provider
    redacted["account_id"] = account_id
    redacted["account_label"] = label
    redacted["overwrote"] = bool(overwrite)
    redacted["message"] = "Generated, securely stored, and ready to copy. Later views show only the masked tail."
    return redacted

def provider_is_oauth(provider: str) -> bool:
    provider = safe_provider_id(provider)
    return PROVIDER_DEFAULTS[provider].get("auth_mode") in ("oauth_ready", "codex_oauth") or PROVIDER_DEFAULTS[provider].get("type") in ("oauth", "codex-oauth")

def chat_to_base_url(endpoint: str) -> str:
    endpoint = str(endpoint or "").strip()
    return re.sub(r"/chat/completions/?$", "", endpoint.rstrip("/"))

def base_to_chat_endpoint(endpoint: str) -> str:
    endpoint = str(endpoint or "").strip().rstrip("/")
    if endpoint.endswith("/chat/completions"):
        return endpoint
    if endpoint.endswith("/v1") or endpoint.endswith("/openai"):
        return endpoint + "/chat/completions"
    return endpoint

def executable_to_dashboard_config(raw: dict) -> dict:
    providers = raw.get("providers") if isinstance(raw.get("providers"), dict) else {}
    merged = {}
    for pid, defaults in PROVIDER_DEFAULTS.items():
        current = providers.get(pid, {}) if isinstance(providers.get(pid), dict) else {}
        endpoint = current.get("endpoint", defaults.get("endpoint", ""))
        model = current.get("model", current.get("default_model", defaults.get("default_model", "")))
        key_file = current.get("keyFile", current.get("key_file", defaults.get("key_file", "")))
        model_options = dedupe_models(current.get("models", current.get("model_options", defaults.get("model_options", [model]))))
        raw_favs = current.get("favoriteModels", current.get("favorite_models"))
        if isinstance(raw_favs, list):
            favorite_models = dedupe_models([m for m in raw_favs if canonical_model_id(m) in model_options])
        else:
            favorite_models = []
        item = {
            **defaults,
            "endpoint": endpoint,
            "default_model": model,
            "model_options": model_options,
            "favorite_models": favorite_models,
            "favoriteModels": favorite_models,
            "key_file": key_file,
            "keyFile": key_file,
            "type": current.get("type", defaults.get("type", "openai-compatible")),
            "auth_mode": current.get("auth_mode", defaults.get("auth_mode", "api_key")),
        }
        if provider_is_oauth(pid):
            item["api_key_set"] = False
            item["key_entries"] = []
        else:
            key_path = workspace_rel_path(key_file) if key_file else None
            item["api_key_set"] = bool(key_path and key_path.exists() and key_path.stat().st_size > 0) or bool(load_provider_keys(pid))
            item["key_entries"] = load_provider_keys(pid)
        item["model_metadata"] = build_model_metadata(pid, item.get("model_options", []), "configured")
        item["oauth_config_key"] = load_oauth_config_key(pid)
        if provider_is_oauth(pid):
            item["oauth_client"] = load_oauth_client_config(pid)
            token = load_oauth_tokens(pid)
            item["oauth_token"] = {"set": bool(token.get("access_token")), "masked": mask_secret(str(token.get("access_token", "")), 6), "updated_utc": token.get("updated_utc")}
        item.pop("api_key", None)
        merged[pid] = item
    active = raw.get("activeProvider") or raw.get("active_provider") or "gemini"
    if active not in merged:
        active = "gemini"
    return {
        "schema_version": raw.get("schema_version", "executable-0.1"),
        "active_provider": active,
        "activeProvider": active,
        "active_model": merged.get(active, {}).get("default_model", ""),
        "activeModel": merged.get(active, {}).get("default_model", ""),
        "updated_utc": raw.get("updated_utc"),
        "providers": merged,
        "config_path": str(PROVIDER_CONFIG),
        "key_dir": str(KEY_DIR),
    }

def load_provider_config() -> dict:
    raw = read_json(PROVIDER_CONFIG, {}) if PROVIDER_CONFIG.exists() else {}
    return executable_to_dashboard_config(raw)

def write_provider_config_without_secrets(config: dict) -> None:
    current_raw = read_json(PROVIDER_CONFIG, {}) if PROVIDER_CONFIG.exists() else {}
    current_providers = current_raw.get("providers") if isinstance(current_raw.get("providers"), dict) else {}
    active = config.get("active_provider") or config.get("activeProvider") or "gemini"
    to_save = {
        "activeProvider": active,
        "providers": {},
        "notes": current_raw.get("notes", "Switch at runtime with /providers and /provider NAME. Keys live in Config/Keys and are never embedded in JSON."),
    }
    for pid, defaults in PROVIDER_DEFAULTS.items():
        value = config.get("providers", {}).get(pid, {}) if isinstance(config.get("providers"), dict) else {}
        existing = current_providers.get(pid, {}) if isinstance(current_providers.get(pid), dict) else {}
        endpoint = value.get("endpoint", existing.get("endpoint", defaults.get("endpoint", "")))
        model = value.get("default_model", value.get("model", existing.get("model", defaults.get("default_model", ""))))
        key_file = value.get("key_file", value.get("keyFile", existing.get("keyFile", defaults.get("key_file", ""))))
        entry = {
            "type": value.get("type", existing.get("type", defaults.get("type", "openai-compatible"))),
            "endpoint": endpoint,
            "model": model,
        }
        models = value.get("model_options", value.get("models", existing.get("models")))
        if models:
            entry["models"] = models
        favorite_models = value.get("favorite_models", value.get("favoriteModels", existing.get("favoriteModels", existing.get("favorite_models"))))
        if favorite_models is not None:
            entry["favoriteModels"] = favorite_models
        if not provider_is_oauth(pid):
            entry["keyFile"] = key_file
            entry["apiKeySet"] = bool(workspace_rel_path(key_file).exists() and workspace_rel_path(key_file).stat().st_size > 0) if key_file else False
        else:
            oauth_key_file = value.get("key_file", value.get("keyFile", existing.get("keyFile", defaults.get("key_file", ""))))
            if oauth_key_file:
                entry["keyFile"] = oauth_key_file
                entry["apiKeySet"] = bool(workspace_rel_path(oauth_key_file).exists() and workspace_rel_path(oauth_key_file).stat().st_size > 0)
            entry["authMode"] = "oauth"
            if defaults.get("oauth_url"):
                entry["oauthUrl"] = defaults["oauth_url"]
        to_save["providers"][pid] = entry
    write_json_atomic(PROVIDER_CONFIG, to_save)
    active_item = to_save["providers"].get(active, {})
    legacy = {
        "endpoint": active_item.get("endpoint", ""),
        "model": active_item.get("model", ""),
        "apiKey": "",
        "apiKeySet": bool(active_item.get("apiKeySet", False)),
        "keyFile": active_item.get("keyFile", ""),
        "activeProvider": active,
        "installedAtUtc": now_iso().replace("+00:00", "Z"),
    }
    write_json_atomic(LEGACY_PROVIDER_CONFIG, legacy)

def sync_provider_key_status(provider: str) -> None:
    provider = safe_provider_id(provider)
    if provider_is_oauth(provider):
        return
    config = load_provider_config()
    keys = load_provider_keys(provider)
    item = config["providers"][provider]
    item["api_key_set"] = bool(keys)
    active = next((k for k in keys if k.get("active")), keys[0] if keys else None)
    if active:
        raw = next((x for x in load_raw_key_metadata(provider) if x.get("id") == active.get("id")), None)
        if raw:
            item["key_file"] = raw["key_file"]
            item["keyFile"] = raw["key_file"]
        elif active.get("id") == "default":
            item["key_file"] = PROVIDER_DEFAULTS[provider].get("key_file", "")
            item["keyFile"] = item["key_file"]
    else:
        item["key_file"] = PROVIDER_DEFAULTS[provider].get("key_file", "")
        item["keyFile"] = item["key_file"]
    write_provider_config_without_secrets(config)

def add_provider_key(provider: str, label: str, api_key: str) -> list[dict]:
    provider = safe_provider_id(provider)
    if provider_is_oauth(provider):
        raise ValueError("OAuth providers do not accept pasted API keys")
    api_key = str(api_key or "").strip()
    if not api_key:
        raise ValueError("api key is required")
    label = str(label or "").strip()[:80] or "API key"
    key_id = "key-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + "-" + secrets.token_hex(3)
    rel_file = f"Config/Keys/{provider}/{key_id}.key.txt"
    key_path = workspace_rel_path(rel_file)
    key_path.parent.mkdir(parents=True, exist_ok=True)
    key_path.write_text(api_key + "\n", encoding="utf-8")
    try:
        os.chmod(key_path, 0o600)
    except OSError:
        pass
    now = now_iso()
    existing = []
    for item in load_raw_key_metadata(provider):
        item = dict(item); item["active"] = False; existing.append(item)
    legacy = _legacy_key_path(provider)
    if legacy.exists() and legacy.stat().st_size > 0 and not any(x.get("id") == "default" for x in existing):
        existing.insert(0, {"id": "default", "label": "Default key", "key_file": PROVIDER_DEFAULTS[provider]["key_file"], "created_utc": None, "updated_utc": None, "active": False})
    existing.append({"id": key_id, "label": label, "key_file": rel_file, "created_utc": now, "updated_utc": now, "active": True})
    save_key_metadata(provider, existing)
    sync_provider_key_status(provider)
    return load_provider_keys(provider)

def delete_provider_key(provider: str, key_id: str) -> list[dict]:
    provider = safe_provider_id(provider)
    if provider_is_oauth(provider):
        raise ValueError("OAuth providers do not use dashboard API-key entries")
    key_id = str(key_id or "").strip()
    if not key_id:
        raise ValueError("key id is required")
    raw_entries = load_raw_key_metadata(provider)
    if key_id == "default" and not any(x.get("id") == "default" for x in raw_entries):
        raw_entries.append({"id": "default", "label": "Default key", "key_file": PROVIDER_DEFAULTS[provider]["key_file"], "created_utc": None, "updated_utc": None, "active": True})
    kept = []
    removed = False
    for item in raw_entries:
        if str(item.get("id")) == key_id:
            key_file = str(item.get("key_file", ""))
            path = workspace_rel_path(key_file) if key_file else None
            if path and path.exists():
                path.unlink()
            removed = True
            continue
        kept.append(item)
    if not removed:
        raise ValueError("key not found")
    if kept and not any(item.get("active") for item in kept):
        kept[0]["active"] = True
    save_key_metadata(provider, kept)
    sync_provider_key_status(provider)
    return load_provider_keys(provider)

def persist_provider(provider: str, endpoint: str, default_model: str, auth_mode: str, api_key: str | None, favorite_models=None) -> dict:
    provider = safe_provider_id(provider)
    if provider_is_oauth(provider):
        auth_mode = "oauth_ready"
        # OAuth providers (e.g. codex://chatgpt) use non-HTTP endpoints; skip the URL check
        if not endpoint.strip():
            endpoint = PROVIDER_DEFAULTS[provider].get("endpoint", "")
    elif not endpoint.startswith(("http://", "https://")):
        raise ValueError("endpoint must start with http:// or https://")
    if not default_model:
        raise ValueError("default model is required")
    expected_auth = PROVIDER_DEFAULTS[provider].get("auth_mode", "api_key")
    if not provider_is_oauth(provider) and auth_mode not in {"api_key", "oauth_ready"}:
        auth_mode = expected_auth
    config = load_provider_config()
    item = config["providers"][provider]
    item["endpoint"] = endpoint.strip().rstrip("/")
    item["default_model"] = default_model.strip()
    item["model"] = default_model.strip()
    item["auth_mode"] = auth_mode
    if favorite_models is not None:
        if not isinstance(favorite_models, list):
            raise ValueError("favorite_models must be a list")
        allowed = item.get("model_options") or PROVIDER_DEFAULTS[provider].get("model_options", [])
        cleaned = []
        for model in favorite_models:
            model = canonical_model_id(model)
            allowed_canon = dedupe_models(allowed)
            if model and model in allowed_canon and model not in cleaned:
                cleaned.append(model)
        item["favorite_models"] = cleaned
        item["favoriteModels"] = cleaned
    item["updated_utc"] = now_iso()
    config["active_provider"] = provider
    config["activeProvider"] = provider
    config["updated_utc"] = now_iso()
    write_provider_config_without_secrets(config)
    return load_provider_config()

def list_mailbox(limit: int = 50) -> list[dict]:
    MAILBOX_INBOX.mkdir(parents=True, exist_ok=True)
    tasks = []
    for path in sorted(MAILBOX_INBOX.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]:
        data = read_json(path, {})
        if isinstance(data, dict):
            tasks.append({
                "id": path.name,
                "agent": data.get("agent", ""),
                "status": data.get("status", ""),
                "message": data.get("message", ""),
                "created_utc": data.get("created_utc", ""),
            })
    return tasks

class AgentOSDashboardHandler(SimpleHTTPRequestHandler):
    server_version = "MirrorOSDashboard/0.2"
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(WORKSPACE_ROOT), **kwargs)
    def log_message(self, fmt, *args):
        print(f"[{datetime.now().isoformat(timespec='seconds')}] {self.address_string()} {fmt % args}")
    def end_headers(self):
        # Prevent browser caching of HTML/JS files so code changes are always served fresh.
        # API responses already use _send_json with their own headers.
        path = getattr(self, '_current_path', '') or urlparse(getattr(self, 'path', '')).path
        if path.endswith(('.html', '.js', '.css', '.mjs')):
            self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
            self.send_header('Pragma', 'no-cache')
            self.send_header('Expires', '0')
        super().end_headers()
    def _read_body(self) -> dict:
        length = int(self.headers.get("Content-Length", "0") or "0")
        raw = self.rfile.read(length)
        if not raw:
            return {}
        return json.loads(raw.decode("utf-8"))
    def _send_json(self, obj, status=200):
        payload = json.dumps(obj, indent=2, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        # Enable full CORS headers to support running UI out of direct file:/// local protocol
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()
        self.wfile.write(payload)
    def do_OPTIONS(self):
        # Support OPTIONS preflight requests for modern browsers loading file:/// assets
        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()
    def do_GET(self):
        parsed = urlparse(self.path)
        self._current_path = parsed.path
        if parsed.path == "/":
            self.send_response(302)
            self.send_header("Location", "/Adapters/UI/LiveDashboard/MirrorOS_Dashboard.html")
            self.end_headers()
            return
        if parsed.path == "/api/status":
            provider_config = load_provider_config()
            active = provider_config["providers"].get(provider_config["active_provider"], {})
            self._send_json({"busy": BUSY, "status": "ready", "workspace": str(WORKSPACE_ROOT), "mailbox_inbox": str(MAILBOX_INBOX), "provider_config": str(PROVIDER_CONFIG), "active_provider": provider_config["active_provider"], "active_model": active.get("default_model"), "timestamp_utc": now_iso()})
            return
        if parsed.path == "/api/providers":
            self._send_json(load_provider_config())
            return
        if parsed.path == "/api/chat":
            self._send_json(normalized_chat_history())
            return
        if parsed.path == "/api/openai-codex/status":
            self._send_json(openai_codex_device_auth_status())
            return
        if parsed.path == "/api/provider-models":
            qs = parse_qs(parsed.query)
            provider = qs.get("provider", [""])[0]
            if not provider:
                self._send_json({"ok": False, "error": "provider is required"}, status=400); return
            self._send_json(discover_provider_models(provider, force=qs.get("refresh", ["0"])[0] == "1"))
            return
        if parsed.path == "/api/oauth/callback":
            self.handle_oauth_callback(parsed); return
        if parsed.path == "/api/mailbox":
            self._send_json({"tasks": list_mailbox()})
            return
        if parsed.path == "/api/sessions":
            self._send_json(list_sessions())
            return
        return super().do_GET()
    def do_POST(self):
        parsed = urlparse(self.path)
        try:
            payload = self._read_body()
            if parsed.path == "/api/message":
                self.handle_message(payload); return
            if parsed.path == "/api/providers":
                self.handle_provider_save(payload); return
            if parsed.path == "/api/provider-keys":
                self.handle_key_add(payload); return
            if parsed.path == "/api/provider-models/refresh":
                self._send_json(refresh_provider_models(str(payload.get("provider", "")).strip() or None)); return
            if parsed.path == "/api/openai-codex/device-start":
                self._send_json(start_openai_codex_device_auth()); return
            if parsed.path == "/api/openai-codex/cancel":
                self._send_json(cancel_openai_codex_device_auth()); return
            if parsed.path == "/api/sessions/activate":
                self._send_json(activate_session(str(payload.get("session_id", "")).strip(), str(payload.get("target_agent", "OSP")).strip(), bool(payload.get("launch", False)))); return
            if parsed.path == "/api/agents/launch":
                self._send_json(launch_agent(str(payload.get("agent", "OSP")).strip(), bool(payload.get("hidden", True)))); return
            if parsed.path == "/api/oauth/start":
                self.handle_oauth_start(payload); return
            if parsed.path == "/api/oauth/config":
                self.handle_oauth_config(payload); return
            if parsed.path == "/api/oauth/config-key":
                self.handle_oauth_config_key(payload); return
            self._send_json({"ok": False, "error": "not_found"}, status=404)
        except json.JSONDecodeError as exc:
            self._send_json({"ok": False, "error": "invalid_json", "detail": str(exc)}, status=400)
        except ValueError as exc:
            self._send_json({"ok": False, "error": str(exc)}, status=400)
        except Exception as exc:
            self._send_json({"ok": False, "error": "server_error", "detail": str(exc)}, status=500)
    def do_DELETE(self):
        parsed = urlparse(self.path)
        try:
            payload = self._read_body()
            if parsed.path == "/api/provider-keys":
                self.handle_key_delete(payload); return
            self._send_json({"ok": False, "error": "not_found"}, status=404)
        except json.JSONDecodeError as exc:
            self._send_json({"ok": False, "error": "invalid_json", "detail": str(exc)}, status=400)
        except ValueError as exc:
            self._send_json({"ok": False, "error": str(exc)}, status=400)
        except Exception as exc:
            self._send_json({"ok": False, "error": "server_error", "detail": str(exc)}, status=500)
    def handle_message(self, payload: dict):
        message = str(payload.get("message", "")).strip()
        agent = str(payload.get("agent", "OS")).strip().upper()
        if agent not in AGENTS:
            raise ValueError("agent must be one of OS, OSX, OSP, OSH")
        if agent != "OSP":
            raise ValueError("Direct dashboard prompt input is currently wired for OSP. Select OSP or add the same prompt-queue consumer to the PowerShell runtime first.")
        runtime_agents = detect_active_runtime_agents()
        if agent not in runtime_agents:
            raise ValueError("OSP is not currently running. Launch MirrorOSP first; dashboard prompts do not queue for future sessions by default.")
        active_session = active_agent_session(agent)
        if not pid_is_alive(active_session.get("pid")):
            raise ValueError("OSP runtime status is stale. Relaunch MirrorOSP before sending prompts; old queued prompts are not resumed automatically.")
        target_session_id = str(active_session.get("session_id") or "").strip()
        if not target_session_id:
            raise ValueError("OSP is not advertising an active runtime session yet. Relaunch MirrorOSP, wait for READY/IDLE, then send the prompt.")
        attachments = payload.get("attachments", []) if isinstance(payload.get("attachments", []), list) else []
        if not message and not attachments:
            raise ValueError("empty message")
        prompt_id, prompt_path, rendered = queue_dashboard_prompt(agent, message, attachments, target_session_id)
        append_chat(
            "user",
            rendered or f"[dashboard queued {len(attachments)} attachment(s)]",
            agent=agent,
            source="dashboard",
            dashboard_prompt_id=prompt_id,
            dashboard_prompt_status="queued",
            target_session_id=target_session_id,
        )
        self._send_json({
            "ok": True,
            "status": "queued",
            "prompt": str(prompt_path.relative_to(WORKSPACE_ROOT)),
            "prompt_id": prompt_id,
            "target_session_id": target_session_id,
            "agent": agent,
            "busy": False,
            "message": f"Queued prompt for active {agent} session",
        })
    def handle_provider_save(self, payload: dict):
        config = persist_provider(str(payload.get("provider", "")), str(payload.get("endpoint", "")).strip(), str(payload.get("default_model", "")).strip(), str(payload.get("auth_mode", "api_key")).strip(), str(payload.get("api_key", "")).strip(), payload.get("favorite_models", payload.get("favoriteModels")))
        provider = config["active_provider"]
        self._send_json({"ok": True, "provider": provider, "api_key_set": config["providers"][provider]["api_key_set"], "key_entries": config["providers"][provider].get("key_entries", []), "config_path": str(PROVIDER_CONFIG)})
    def handle_key_add(self, payload: dict):
        provider = safe_provider_id(str(payload.get("provider", "")))
        keys = add_provider_key(provider, str(payload.get("label", "")).strip(), str(payload.get("api_key", "")).strip())
        self._send_json({"ok": True, "provider": provider, "api_key_set": bool(keys), "key_entries": keys})
    def handle_key_delete(self, payload: dict):
        provider = safe_provider_id(str(payload.get("provider", "")))
        keys = delete_provider_key(provider, str(payload.get("key_id", "")).strip())
        self._send_json({"ok": True, "provider": provider, "api_key_set": bool(keys), "key_entries": keys})
    def handle_oauth_start(self, payload: dict):
        provider = safe_provider_id(str(payload.get("provider", "")))
        cfg = load_oauth_client_config(provider)
        if not cfg.get("client_id") or not cfg.get("authorization_url") or not cfg.get("token_url") or not cfg.get("redirect_uri"):
            self._send_json({"ok": False, "provider": provider, "setup_required": True, "oauth_client": cfg, "error": "OAuth requires client_id, authorization_url, token_url, and redirect_uri. Save OAuth config first."}, status=400)
            return
        state = secrets.token_urlsafe(24)
        OAUTH_STATES[state] = {"provider": provider, "created": time.time()}
        params = {"response_type": "code", "client_id": cfg["client_id"], "redirect_uri": cfg["redirect_uri"], "state": state}
        if cfg.get("scope"):
            params["scope"] = cfg["scope"]
        url = cfg["authorization_url"] + ("&" if "?" in cfg["authorization_url"] else "?") + urllib.parse.urlencode(params)
        self._send_json({"ok": True, "provider": provider, "url": url, "redirect_uri": cfg["redirect_uri"], "message": "Open this authorization URL. The callback will exchange the code and store the access token locally for MirrorOS."})
    def handle_oauth_config(self, payload: dict):
        provider = safe_provider_id(str(payload.get("provider", "")))
        self._send_json({"ok": True, "provider": provider, "oauth_client": save_oauth_client_config(provider, payload)})
    def handle_oauth_callback(self, parsed):
        qs = parse_qs(parsed.query)
        state = qs.get("state", [""])[0]
        code = qs.get("code", [""])[0]
        error = qs.get("error", [""])[0]
        state_info = OAUTH_STATES.pop(state, None)
        if error:
            self._send_json({"ok": False, "error": error}, status=400); return
        if not state_info or not code:
            self._send_json({"ok": False, "error": "invalid OAuth state or missing code"}, status=400); return
        provider = state_info["provider"]
        cfg = read_json(oauth_client_config_path(provider), {})
        token_payload = {"grant_type": "authorization_code", "code": code, "redirect_uri": cfg.get("redirect_uri", ""), "client_id": cfg.get("client_id", "")}
        if cfg.get("client_secret"):
            token_payload["client_secret"] = cfg["client_secret"]
        data = urllib.parse.urlencode(token_payload).encode("utf-8")
        req = urllib.request.Request(str(cfg.get("token_url", "")), data=data, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST")
        try:
            with urllib.request.urlopen(req, timeout=20) as resp:
                tokens = json.loads(resp.read().decode("utf-8"))
            saved = save_oauth_tokens(provider, tokens)
            body = f"<html><body style='font-family:sans-serif;background:#0b0f19;color:#e2e8f0'><h2>OAuth complete</h2><p>Provider {provider} token stored: {saved['masked']}</p><p>You can close this tab.</p></body></html>".encode("utf-8")
            self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", "ignore")
            self._send_json({"ok": False, "error": "token_exchange_failed", "status": exc.code, "detail": detail}, status=400)
    def handle_oauth_config_key(self, payload: dict):
        provider = safe_provider_id(str(payload.get("provider", "")))
        account_label = str(payload.get("account_label", "default")).strip()
        overwrite = bool(payload.get("overwrite", True))
        self._send_json({"ok": True, **generate_oauth_config_key(provider, account_label=account_label, overwrite=overwrite)})

def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8000)
    args = parser.parse_args()
    httpd = ThreadingHTTPServer((args.host, args.port), AgentOSDashboardHandler)
    print(f"MirrorOS dashboard listening on http://{args.host}:{args.port}/")
    print(f"Workspace root: {WORKSPACE_ROOT}")
    httpd.serve_forever()
    return 0
if __name__ == "__main__":
    raise SystemExit(main())