"""ConfigServer — HTTP handlers, rendering orchestration, config building."""

import copy
import json
import secrets
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlencode

import jinja2
from aiohttp import web

# Access config via module reference so values survive importlib.reload().
import otto.config as _cfg
from otto import __version__
from otto.auth import AuthStorage
from otto.config import (
    AgentConfig,
    BotAuthConfig,
    BotConfig,
    ChannelConfig,
    Config,
    ConfigError,
    UserConfig,
    WebConfig,
    WorkspaceConfig,
    save_config,
)
from otto.log import get_logger
from otto.setup_core.apply import apply_plan as _apply_setup_plan
from otto.setup_core.engine import build_plan as _build_setup_plan
from otto.setup_core.engine import evaluate_requirements as _evaluate_setup_requirements
from otto.setup_core.models import SetupChoice as _SetupChoice
from otto.setup_core.models import SetupValidationError as _SetupValidationError
from otto.telegram import TelegramBot
from otto.web import helpers as _h
from otto.web.auth import (
    _SESSION_COOKIE,
    _SESSION_TTL,
    PasskeyStorage,
    SessionManager,
    get_authentication_options_json,
    get_registration_options_json,
    verify_authentication,
    verify_registration,
)
from otto.web.helpers import (
    _BOOTSTRAP_OPTIONS,
    _LOG_LEVEL_OPTIONS,
    _MASKED_TOKEN,
)
from otto.web.scripts import AUTH_LOGIN_JS, AUTH_REGISTER_JS, DASHBOARD_JS, WIZARD_JS
from otto.web.styles import CSS, FONT_LINK

_log = get_logger("otto.web.server")

# ---------------------------------------------------------------------------
# Jinja2 template environment
# ---------------------------------------------------------------------------

_jinja_env = jinja2.Environment(
    loader=jinja2.PackageLoader("otto.web", "templates"),
    autoescape=True,
    trim_blocks=True,
    lstrip_blocks=True,
)

# Shared context constants injected into every render call.
_TEMPLATE_GLOBALS = {
    "css": CSS,
    "font_link": FONT_LINK,
    "masked_token": _MASKED_TOKEN,
    "log_level_options": _LOG_LEVEL_OPTIONS,
    "bootstrap_options": _BOOTSTRAP_OPTIONS,
    "auth_login_js": AUTH_LOGIN_JS,
    "auth_register_js": AUTH_REGISTER_JS,
}


def _render(template_name: str, **ctx: Any) -> str:
    """Render a Jinja template with shared globals + caller context."""
    tpl = _jinja_env.get_template(template_name)
    return tpl.render(**_TEMPLATE_GLOBALS, **ctx)


def _has_oauth_credentials(auth_storage: AuthStorage, provider: str) -> bool:
    credentials = auth_storage.get_credentials(provider)
    if isinstance(credentials, dict) and credentials.get("type") == "oauth":
        return True

    if provider != "copilot":
        return False

    try:
        from otto.auth.copilot import has_litellm_credentials

        return has_litellm_credentials()
    except Exception:
        return False


class ConfigServer:
    def __init__(
        self,
        config: Config,
        host: str = "127.0.0.1",
        port: int = 7070,
        bots: list[TelegramBot] | None = None,
        pulse_runner: Any | None = None,
    ):
        self._config = config
        self._host = host
        self._port = port
        _raw_hostname = config.web.hostname
        self._hostname: str | None = _raw_hostname if isinstance(_raw_hostname, str) else None
        self._bots = bots or []
        self._pulse_runner = pulse_runner
        self._app: web.Application | None = None
        self._runner: web.AppRunner | None = None
        self._site: web.BaseSite | None = None
        self._passkey_storage = PasskeyStorage()
        self._session_mgr = SessionManager(self._ensure_session_secret(config))

        if self._hostname:
            self._rp_id = self._hostname
            self._expected_origin = f"https://{self._hostname}"
        else:
            self._rp_id = "localhost"
            self._expected_origin = f"http://localhost:{port}"

    # Panels that map to individual URL routes.
    _PANELS = ("agents", "orchestration", "connections", "config")

    # Auth paths exempt from session check.
    # /auth/login/* and /auth/register/* are covered by startswith() in middleware.
    _AUTH_EXEMPT = frozenset({"/health", "/auth/callback", "/oauth2callback"})

    @staticmethod
    def _ensure_session_secret(config: Config) -> str:
        try:
            existing = config.web.session_secret
            if existing and isinstance(existing, str):
                return existing
        except (AttributeError, TypeError):
            pass
        secret = secrets.token_hex(32)
        try:
            new_web = WebConfig(
                enabled=config.web.enabled,
                host=config.web.host,
                hostname=config.web.hostname,
                port=config.web.port,
                session_secret=secret,
            )
            # Update in-place via frozen dataclass bypass so save_config picks it up.
            object.__setattr__(config, "web", new_web)
            save_config(config)
        except Exception as exc:
            _log.debug("session secret not persisted", error=str(exc))
        return secret

    def _make_auth_middleware(self) -> Any:
        passkey_storage = self._passkey_storage
        session_mgr = self._session_mgr
        hostname = self._hostname

        @web.middleware
        async def auth_middleware(request: web.Request, handler: Any) -> web.Response:
            # Block direct localhost access when hostname is configured
            if hostname:
                req_host = request.host
                if req_host.startswith("localhost") or req_host.startswith("127.0.0.1"):
                    html = _render("wrong_host.html", hostname=hostname)
                    return web.Response(text=html, content_type="text/html", status=421)

            path = request.path
            if (
                path in self._AUTH_EXEMPT
                or path.startswith("/auth/login")
                or path.startswith("/auth/register")
            ):
                return await handler(request)
            cookie = request.cookies.get(_SESSION_COOKIE, "")
            if cookie and session_mgr.validate_token(cookie):
                return await handler(request)
            if passkey_storage.is_registered:
                raise web.HTTPFound("/auth/login")
            raise web.HTTPFound("/auth/register")

        return auth_middleware

    def _build_app(self) -> web.Application:
        app = web.Application(middlewares=[self._make_auth_middleware()])
        app.router.add_get("/", self._handle_index)
        # Per-panel routes so each panel is bookmarkable / refreshable.
        for panel in self._PANELS:
            app.router.add_get(f"/{panel}", self._handle_panel)
        app.router.add_get("/auth/callback", self._handle_openai_callback)
        app.router.add_get("/oauth2callback", self._handle_google_callback)
        app.router.add_post("/config", self._handle_save)
        app.router.add_get("/health", self._handle_health)
        app.router.add_get("/status", self._handle_status)
        app.router.add_get("/orchestration/summary", self._handle_orchestration_summary)
        app.router.add_get("/orchestration/jobs", self._handle_orchestration_jobs)
        app.router.add_get(
            "/orchestration/jobs/{job_id}/session", self._handle_orchestration_job_session
        )
        app.router.add_get("/orchestration/events", self._handle_orchestration_events)
        app.router.add_get("/schedules/list", self._handle_schedules_list)
        app.router.add_post("/schedules/{task_id}/run", self._handle_schedule_run)
        app.router.add_post("/orchestration/jobs/{job_id}/cancel", self._handle_job_cancel)
        app.router.add_post("/start", self._handle_start)
        app.router.add_post("/stop", self._handle_stop)
        app.router.add_post("/auth/connect", self._handle_auth_connect)
        app.router.add_post("/setup", self._handle_setup)
        app.router.add_post("/bots/{alias}/start", self._handle_bot_start)
        app.router.add_post("/bots/{alias}/stop", self._handle_bot_stop)
        # Auth routes
        app.router.add_get("/auth/login", self._handle_auth_login)
        app.router.add_get("/auth/register", self._handle_auth_register)
        app.router.add_post("/auth/register/begin", self._handle_register_begin)
        app.router.add_post("/auth/register/complete", self._handle_register_complete)
        app.router.add_post("/auth/login/begin", self._handle_login_begin)
        app.router.add_post("/auth/login/complete", self._handle_login_complete)
        app.router.add_post("/auth/logout", self._handle_logout)
        return app

    async def start(self) -> None:
        if self._runner is not None:
            return
        self._app = self._build_app()
        self._runner = web.AppRunner(self._app)
        await self._runner.setup()
        self._site = web.TCPSite(self._runner, self._host, self._port)
        await self._site.start()

    async def stop(self) -> None:
        if self._runner is None:
            return
        await self._runner.cleanup()
        self._runner = None
        self._site = None
        self._app = None

    # ------------------------------------------------------------------
    # Auth handlers
    # ------------------------------------------------------------------

    async def _handle_auth_login(self, request: web.Request) -> web.Response:
        html = _render("login.html")
        return web.Response(text=html, content_type="text/html")

    async def _handle_auth_register(self, request: web.Request) -> web.Response:
        if self._passkey_storage.is_registered:
            raise web.HTTPFound("/auth/login")
        html = _render("register.html")
        return web.Response(text=html, content_type="text/html")

    async def _handle_register_begin(self, request: web.Request) -> web.Response:
        options_json = get_registration_options_json(self._rp_id)
        return web.Response(text=options_json, content_type="application/json")

    async def _handle_register_complete(self, request: web.Request) -> web.Response:
        body = await request.json()
        ok = verify_registration(body, self._expected_origin, self._rp_id, self._passkey_storage)
        if not ok:
            return web.json_response({"error": "registration failed"}, status=400)
        return self._session_response()

    async def _handle_login_begin(self, request: web.Request) -> web.Response:
        options_json = get_authentication_options_json(self._passkey_storage, self._rp_id)
        return web.Response(text=options_json, content_type="application/json")

    async def _handle_login_complete(self, request: web.Request) -> web.Response:
        body = await request.json()
        ok = verify_authentication(body, self._expected_origin, self._rp_id, self._passkey_storage)
        if not ok:
            return web.json_response({"error": "authentication failed"}, status=400)
        return self._session_response()

    def _session_response(self) -> web.Response:
        token = self._session_mgr.create_token()
        resp = web.json_response({"ok": True})
        resp.set_cookie(
            _SESSION_COOKIE,
            token,
            httponly=True,
            samesite="Strict",
            path="/",
            max_age=_SESSION_TTL,
            secure=bool(self._hostname),
        )
        return resp

    async def _handle_logout(self, request: web.Request) -> web.Response:
        resp = web.HTTPFound("/auth/login")
        resp.del_cookie(_SESSION_COOKIE, path="/")
        raise resp

    # ------------------------------------------------------------------
    # Request handlers
    # ------------------------------------------------------------------

    async def _handle_index(self, request: web.Request) -> web.Response:
        status = _h.clean_str(request.query.get("status"))
        message = _h.clean_str(request.query.get("message"))
        html = self._render_index_html(status, message, active_panel="agents")
        return web.Response(text=html, content_type="text/html")

    async def _handle_panel(self, request: web.Request) -> web.Response:
        # The path is "/<panel>" — extract the panel name.
        panel = request.path.lstrip("/").split("/")[0]
        if panel not in self._PANELS:
            panel = "agents"
        status = _h.clean_str(request.query.get("status"))
        message = _h.clean_str(request.query.get("message"))
        html = self._render_index_html(status, message, active_panel=panel)
        return web.Response(text=html, content_type="text/html")

    async def _handle_openai_callback(self, request: web.Request) -> web.Response:
        return await self._handle_oauth_callback(request, provider="openai")

    async def _handle_google_callback(self, request: web.Request) -> web.Response:
        return await self._handle_oauth_callback(request, provider="google")

    async def _handle_oauth_callback(self, request: web.Request, *, provider: str) -> web.Response:
        code = _h.clean_str(request.query.get("code"))
        state = _h.clean_str(request.query.get("state"))

        if not code or not state:
            html = _render(
                "callback.html", success=False, message="Missing authorization code or state."
            )
            return web.Response(status=400, text=html, content_type="text/html")

        expected_state = _h.load_expected_state(provider)
        if expected_state and state != expected_state:
            html = _render("callback.html", success=False, message="State mismatch.")
            return web.Response(status=400, text=html, content_type="text/html")

        try:
            _h.write_auth_callback(provider=provider, code=code, state=state)
        except OSError:
            html = _render(
                "callback.html", success=False, message="Failed to persist OAuth callback."
            )
            return web.Response(status=500, text=html, content_type="text/html")

        html = _render(
            "callback.html",
            success=True,
            message="Authentication successful. Return to your terminal.",
        )
        return web.Response(text=html, content_type="text/html")

    async def _handle_save(self, request: web.Request) -> web.StreamResponse:
        data = await request.post()
        try:
            updated = self._build_updated_config(data)
            _cfg.save_config(updated, _cfg.CONFIG_FILE)
            self._config = _cfg.load_config(_cfg.CONFIG_FILE)
        except (ConfigError, ValueError) as exc:
            raise web.HTTPFound(
                location=self._redirect_location("error", str(exc), panel="config")
            ) from exc
        raise web.HTTPFound(
            location=self._redirect_location("success", "Configuration saved.", panel="config")
        )

    async def _handle_health(self, request: web.Request) -> web.Response:
        return web.json_response({"status": "ok", "version": __version__})

    async def _handle_status(self, request: web.Request) -> web.Response:
        running, pid = _h._daemon_status()
        model = self._config.agent.model
        provider = model.split("/", 1)[0] if "/" in model else "unknown"
        workspace_mode, workspace_root = _h.workspace_status(self._config)

        bot_statuses = []
        for bot in self._bots:
            bot_running = (
                bot._app is not None and bot._app.updater is not None and bot._app.updater.running
            )
            bot_statuses.append({"alias": bot.alias, "running": bot_running})

        return web.json_response(
            {
                "running": running,
                "pid": pid,
                "model": model,
                "provider": provider,
                "workspace_mode": workspace_mode,
                "workspace_root": str(workspace_root),
                "bots": bot_statuses,
            }
        )

    async def _handle_orchestration_summary(self, request: web.Request) -> web.Response:
        recent_done_limit = self._parse_positive_int(
            request.query.get("recent_done_limit"),
            default=50,
            maximum=200,
        )
        jobs = await self._load_orchestration_jobs()

        done_recent = sorted(
            [job for job in jobs if job.get("status") in {"done", "cancelled"}],
            key=self._job_updated_at_sort_key,
            reverse=True,
        )[:recent_done_limit]

        status_counts = {
            "running": 0,
            "reworking": 0,
            "ready": 0,
            "failed": 0,
            "done_recent": len(done_recent),
            "total": len(jobs),
        }
        for job in jobs:
            status = str(job.get("status", "")).lower()
            if status in {"in_progress", "delivered"}:
                status_counts["running"] += 1
            elif status == "reworking":
                status_counts["reworking"] += 1
            elif status == "ready":
                status_counts["ready"] += 1
            elif status == "failed":
                status_counts["failed"] += 1

        from otto.subagents import load_subagent_profiles

        configured = {profile.name: profile for profile in load_subagent_profiles()}
        observed_assignees = {str(job.get("assignee") or "main") for job in jobs}
        all_assignees = sorted({*configured.keys(), *observed_assignees, "main"})

        subagents_payload: list[dict[str, Any]] = []
        for assignee in all_assignees:
            rows = [job for job in jobs if str(job.get("assignee") or "main") == assignee]
            running_count = sum(
                1
                for job in rows
                if str(job.get("status", "")).lower() in {"in_progress", "delivered", "reworking"}
            )
            failed_count = sum(1 for job in rows if str(job.get("status", "")).lower() == "failed")
            done_recent_count = sum(
                1 for job in done_recent if str(job.get("assignee") or "main") == assignee
            )
            profile = configured.get(assignee)
            subagents_payload.append(
                {
                    "name": assignee,
                    "description": (
                        profile.description
                        if profile is not None
                        else (
                            "Main session routing"
                            if assignee == "main"
                            else "Observed from runtime jobs"
                        )
                    ),
                    "running": running_count,
                    "failed": failed_count,
                    "done_recent": done_recent_count,
                    "configured": profile is not None or assignee == "main",
                }
            )

        return web.json_response(
            {
                "summary": status_counts,
                "subagents": subagents_payload,
                "recent_done_limit": recent_done_limit,
                "updated_at": time.time(),
            }
        )

    async def _handle_orchestration_jobs(self, request: web.Request) -> web.Response:
        scope = (_h.clean_str(request.query.get("scope")) or "active").lower()
        if scope not in {"active", "recent_done", "all"}:
            scope = "active"

        assignee_filter = _h.clean_str(request.query.get("assignee")) or None
        limit = self._parse_positive_int(request.query.get("limit"), default=50, maximum=200)
        cursor = self._parse_positive_int(request.query.get("cursor"), default=0, maximum=10**9)

        jobs = await self._load_orchestration_jobs()

        if scope == "active":
            filtered = [
                job
                for job in jobs
                if str(job.get("status", "")).lower() not in {"done", "cancelled"}
            ]
        elif scope == "recent_done":
            filtered = [
                job for job in jobs if str(job.get("status", "")).lower() in {"done", "cancelled"}
            ]
        else:
            filtered = list(jobs)

        if assignee_filter:
            filtered = [
                job for job in filtered if str(job.get("assignee") or "main") == assignee_filter
            ]

        filtered.sort(key=self._job_sort_tuple)

        start = min(cursor, len(filtered))
        end = min(start + limit, len(filtered))
        page = filtered[start:end]

        page_ids = {str(job.get("id", "")) for job in page}
        rows: list[dict[str, Any]] = []
        seen_rows: set[str] = set()

        jobs_by_id = {str(job.get("id", "")): job for job in page}

        def append_row(job: dict[str, Any], *, depth: int = 0) -> None:
            job_id = str(job.get("id", ""))
            if not job_id or job_id in seen_rows:
                return
            seen_rows.add(job_id)
            rows.append(
                {
                    "id": job_id,
                    "depth": depth,
                    "status": job.get("status"),
                    "assignee": job.get("assignee") or "main",
                    "title": job.get("title"),
                    "description": job.get("description"),
                    "updatedAt": job.get("updatedAt"),
                    "parentId": job.get("parentId"),
                    "children": job.get("children") or [],
                    "blockedBy": job.get("blockedBy") or [],
                    "attempts": (job.get("x-otto") or {}).get("attempts"),
                    "maxAttempts": (job.get("x-otto") or {}).get("maxAttempts"),
                    "error": job.get("error"),
                }
            )
            for child_id in job.get("children") or []:
                child = jobs_by_id.get(str(child_id))
                if child is not None:
                    append_row(child, depth=depth + 1)

        for job in page:
            parent_id = job.get("parentId")
            if parent_id and str(parent_id) in page_ids:
                continue
            append_row(job, depth=0)

        for job in page:
            append_row(job, depth=0)

        next_cursor = end if end < len(filtered) else None
        prev_cursor = max(0, start - limit) if start > 0 else None

        return web.json_response(
            {
                "scope": scope,
                "total": len(filtered),
                "limit": limit,
                "cursor": start,
                "next_cursor": next_cursor,
                "prev_cursor": prev_cursor,
                "jobs": page,
                "rows": rows,
            }
        )

    async def _handle_orchestration_job_session(self, request: web.Request) -> web.Response:
        job_id = _h.clean_str(request.match_info.get("job_id"))
        if not job_id:
            return web.json_response({"error": "Missing job_id"}, status=400)

        try:
            from otto.orchestrator import get_orchestrator

            job = await get_orchestrator().get_job(job_id)
        except Exception:
            job = None

        if job is None:
            return web.json_response(
                {"error": f"Job not found: {job_id}"},
                status=404,
            )

        limit = self._parse_positive_int(request.query.get("limit"), default=200, maximum=500)
        snapshot_lines = job.get("logEntries") if isinstance(job.get("logEntries"), list) else None
        lines = self._collect_orchestration_session_log(
            job_id,
            limit=limit,
            snapshot_lines=snapshot_lines,
        )

        return web.json_response(
            {
                "job": job,
                "lines": lines,
                "total": len(lines),
                "limit": limit,
            }
        )

    async def _handle_orchestration_events(self, request: web.Request) -> web.Response:
        limit = self._parse_positive_int(request.query.get("limit"), default=200, maximum=500)
        cursor_raw = request.query.get("cursor")

        events_path = _cfg.OTTO_HOME / "state" / "events.jsonl"
        all_events: list[dict[str, Any]] = []
        if events_path.exists():
            try:
                for idx, raw_line in enumerate(
                    events_path.read_text(encoding="utf-8").splitlines()
                ):
                    line = raw_line.strip()
                    if not line:
                        continue
                    try:
                        payload = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    if isinstance(payload, dict):
                        payload["_index"] = idx
                        all_events.append(payload)
            except OSError:
                all_events = []

        if cursor_raw is None or str(cursor_raw).strip() == "":
            start = max(0, len(all_events) - limit)
        else:
            start = self._parse_positive_int(cursor_raw, default=0, maximum=len(all_events))

        end = min(start + limit, len(all_events))
        page = all_events[start:end]

        next_cursor = end if end < len(all_events) else None
        prev_cursor = max(0, start - limit) if start > 0 else None

        return web.json_response(
            {
                "total": len(all_events),
                "limit": limit,
                "cursor": start,
                "next_cursor": next_cursor,
                "prev_cursor": prev_cursor,
                "events": page,
            }
        )

    async def _handle_schedules_list(self, request: web.Request) -> web.Response:
        if self._pulse_runner is None:
            return web.json_response([])
        try:
            from otto.autonomy import list_pulse_tasks

            tasks = list_pulse_tasks(self._pulse_runner.board)
            tasks.sort(key=lambda t: t.due_at)
            items = []
            for t in tasks:
                full_title = t.title or ""
                short_title = full_title[:80] + ("…" if len(full_title) > 80 else "")
                items.append(
                    {
                        "id": t.id,
                        "title": short_title,
                        "prompt": full_title,
                        "type": "cron" if t.repeat else "one-shot",
                        "repeat": t.repeat,
                        "next_run": t.due_at.isoformat() if t.due_at else None,
                        "chat_id": t.chat_id,
                    }
                )
            return web.json_response(items)
        except Exception as exc:
            _log.warning("Failed to list schedules: %s", exc)
            return web.json_response([])

    async def _handle_schedule_run(self, request: web.Request) -> web.Response:
        task_id = request.match_info.get("task_id", "").strip()
        if not task_id:
            return web.json_response({"error": "Missing task_id"}, status=400)
        if self._pulse_runner is None:
            return web.json_response({"error": "Scheduler not available"}, status=503)
        try:
            body = await request.json() if request.can_read_body else {}
        except Exception:
            body = {}
        skip_next = bool(body.get("skip_next", False))
        try:
            result = await self._pulse_runner.trigger_now(task_id, skip_next=skip_next)
        except Exception as exc:
            _log.warning("Schedule trigger failed for %s: %s", task_id, exc)
            return web.json_response({"error": str(exc)}, status=500)
        if result == "not_found":
            return web.json_response({"error": "Schedule not found"}, status=404)
        if result == "dispatch_failed":
            return web.json_response({"error": "Dispatch failed"}, status=502)
        return web.json_response({"status": result})

    async def _handle_job_cancel(self, request: web.Request) -> web.Response:
        job_id = request.match_info.get("job_id", "").strip()
        if not job_id:
            return web.json_response({"error": "Missing job_id"}, status=400)
        try:
            from otto.orchestrator import get_orchestrator

            cancelled = await get_orchestrator().cancel_routine(job_id)
        except Exception as exc:
            _log.warning("Job cancel failed for %s: %s", job_id, exc)
            return web.json_response({"error": str(exc)}, status=500)
        if not cancelled:
            return web.json_response({"error": "No cancellable jobs found"}, status=404)
        return web.json_response({"cancelled": cancelled})

    def _iter_json_lines(self, path: Path) -> list[dict[str, Any]]:
        if not path.exists():
            return []
        records: list[dict[str, Any]] = []
        try:
            for raw_line in path.read_text(encoding="utf-8").splitlines():
                line = raw_line.strip()
                if not line:
                    continue
                try:
                    payload = json.loads(line)
                except json.JSONDecodeError:
                    continue
                if isinstance(payload, dict):
                    records.append(payload)
        except OSError:
            return []
        return records

    def _session_log_text(self, payload: dict[str, Any], *, source: str) -> str:
        event = str(payload.get("event") or payload.get("message") or "log")
        event = event.strip() or "log"

        extras: list[str] = []
        for key in ("tool", "command", "status", "model", "logger", "agent_kind", "turn", "error"):
            value = payload.get(key)
            if value is None:
                continue
            text = str(value).strip()
            if not text:
                continue
            if key == "command" and len(text) > 240:
                text = text[:237] + "…"
            extras.append(f"{key}={text}")

        if extras:
            return f"{event}: " + " | ".join(extras)
        return event

    def _collect_orchestration_session_log(
        self,
        job_id: str,
        *,
        limit: int,
        snapshot_lines: list[str] | None = None,
    ) -> list[dict[str, Any]]:
        runtime_log_path = _cfg.OTTO_HOME / "logs" / "otto.log"
        events_path = _cfg.OTTO_HOME / "state" / "events.jsonl"

        combined: list[tuple[float, dict[str, Any]]] = []

        for payload in self._iter_json_lines(runtime_log_path):
            if str(payload.get("delegation_id") or "") != job_id:
                continue
            ts = payload.get("timestamp") or payload.get("ts") or ""
            combined.append(
                (
                    self._iso_to_epoch(ts),
                    {
                        "ts": ts,
                        "source": "runtime",
                        "event": str(payload.get("event") or payload.get("level") or "log"),
                        "level": str(payload.get("level") or ""),
                        "messageText": self._session_log_text(payload, source="runtime"),
                    },
                )
            )

        for payload in self._iter_json_lines(events_path):
            if str(payload.get("job_id") or "") != job_id:
                continue
            ts = payload.get("ts") or ""
            combined.append(
                (
                    self._iso_to_epoch(ts),
                    {
                        "ts": ts,
                        "source": "orchestrator",
                        "event": str(payload.get("event") or "event"),
                        "status": payload.get("status"),
                        "messageText": self._session_log_text(payload, source="orchestrator"),
                        "level": "info",
                    },
                )
            )

        if snapshot_lines:
            for entry in snapshot_lines:
                if not isinstance(entry, str):
                    continue
                combined.append(
                    (
                        0.0,
                        {
                            "ts": "",
                            "source": "snapshot",
                            "event": "job_log",
                            "messageText": entry,
                            "level": "debug",
                        },
                    )
                )

        combined.sort(key=lambda item: item[0])
        # Keep most recent records.
        trimmed = (
            [entry for _, entry in combined[-limit:]] if limit else [entry for _, entry in combined]
        )
        return trimmed

    async def _handle_bot_start(self, request: web.Request) -> web.Response:
        alias = request.match_info["alias"]
        bot = next((b for b in self._bots if b.alias == alias), None)
        if not bot:
            return web.json_response({"error": "Bot not found"}, status=404)
        if bot._app is None or bot._app.updater is None or not bot._app.updater.running:
            await bot.start()
            return web.json_response({"status": "started", "bot": alias})
        return web.json_response({"status": "already running", "bot": alias})

    async def _handle_bot_stop(self, request: web.Request) -> web.Response:
        alias = request.match_info["alias"]
        bot = next((b for b in self._bots if b.alias == alias), None)
        if not bot:
            return web.json_response({"error": "Bot not found"}, status=404)
        if bot._app is not None and bot._app.updater is not None and bot._app.updater.running:
            await bot.stop()
            return web.json_response({"status": "stopped", "bot": alias})
        return web.json_response({"status": "not running", "bot": alias})

    async def _handle_start(self, request: web.Request) -> web.StreamResponse:
        try:
            import otto.daemon as daemon_mod

            if not daemon_mod.is_running():
                from otto.cli import _spawn_background

                _spawn_background()
        except Exception as exc:
            _log.exception("start failed")
            raise web.HTTPFound(
                location=self._redirect_location("error", "Start failed.", panel="agents")
            ) from exc
        raise web.HTTPFound(
            location=self._redirect_location("success", "Otto started.", panel="agents")
        )

    async def _handle_stop(self, request: web.Request) -> web.StreamResponse:
        try:
            import otto.daemon as daemon_mod

            if daemon_mod.is_running():
                daemon_mod.stop_daemon()
        except Exception as exc:
            _log.exception("stop failed")
            raise web.HTTPFound(
                location=self._redirect_location("error", "Stop failed.", panel="agents")
            ) from exc
        raise web.HTTPFound(
            location=self._redirect_location("success", "Otto stopped.", panel="agents")
        )

    async def _handle_auth_connect(self, request: web.Request) -> web.StreamResponse:
        data = await request.post()
        provider = _h.clean_str(data.get("provider")).lower()
        valid = {"anthropic", "openai", "google", "copilot"}
        if provider not in valid:
            raise web.HTTPFound(
                location=self._redirect_location(
                    "error", "Unknown OAuth provider.", panel="connections"
                )
            )

        cmd = f"otto auth login {provider}"
        message = f"To connect {provider}, run '{cmd}' in your terminal, then refresh this page."
        raise web.HTTPFound(location=self._redirect_location("info", message, panel="connections"))

    async def _handle_setup(self, request: web.Request) -> web.StreamResponse:
        from otto.cli import DEFAULT_MODEL

        data = await request.post()
        model = _h.clean_str(data.get("model")) or DEFAULT_MODEL
        setup_auth_mode = _h.clean_str(data.get("setup_auth_mode")) or ""
        owner_name = _h.clean_str(data.get("owner_name")) or "owner"
        owner_tid_raw = _h.clean_str(data.get("owner_telegram_id"))

        telegram_token_input = _h.clean_str(data.get("telegram_token")) or None
        add_telegram_flag = _h.clean_str(data.get("_add_telegram"))
        raw_channels = data.getall("setup_channels", [])

        if raw_channels:
            setup_channels: tuple[str, ...] = tuple(
                sorted({c.strip().lower() for c in raw_channels if c.strip()})
            ) or ("cli",)
        else:
            include_telegram = add_telegram_flag == "yes" or (
                not add_telegram_flag and bool(telegram_token_input)
            )
            setup_channels = ("telegram", "cli") if include_telegram else ("cli",)

        owner_tid: int | None = None
        if owner_tid_raw:
            try:
                owner_tid = int(owner_tid_raw)
            except ValueError:
                raise web.HTTPFound(
                    location=self._redirect_location("error", "Telegram user ID must be a number.")
                )

        try:
            auth_storage = AuthStorage()

            if setup_auth_mode not in {"api_key", "oauth", "auto"}:
                probe = _SetupChoice(
                    model=model,
                    channels=setup_channels,
                    auth_mode="api_key",
                    owner_name=owner_name,
                    owner_telegram_id=owner_tid,
                )
                probe_requirements = _evaluate_setup_requirements(probe)
                setup_auth_mode = "oauth" if probe_requirements.oauth_provider else "api_key"

            baseline_choice = _SetupChoice(
                model=model,
                channels=setup_channels,
                auth_mode=setup_auth_mode,
                owner_name=owner_name,
                owner_telegram_id=owner_tid,
            )
            baseline_requirements = _evaluate_setup_requirements(baseline_choice)

            oauth_credentials_present = False
            if setup_auth_mode in {"oauth", "auto"} and baseline_requirements.oauth_provider:
                oauth_credentials_present = _has_oauth_credentials(
                    auth_storage,
                    baseline_requirements.oauth_provider,
                )

            choice_for_requirements = _SetupChoice(
                model=model,
                channels=setup_channels,
                auth_mode=setup_auth_mode,
                oauth_credentials_present=oauth_credentials_present,
                owner_name=owner_name,
                owner_telegram_id=owner_tid,
            )
            requirements = _evaluate_setup_requirements(choice_for_requirements)

            import os

            provider_api_key = _h.clean_str(data.get("api_key")) or None
            if not requirements.requires_provider_api_key:
                provider_api_key = None
            elif not provider_api_key and requirements.provider_env_var:
                # Fall back to env var already exported in the shell.
                provider_api_key = os.environ.get(requirements.provider_env_var) or None

            telegram_token = telegram_token_input
            if not requirements.requires_telegram_token:
                telegram_token = None
            elif not telegram_token:
                telegram_token = os.environ.get("TELEGRAM_BOT_TOKEN") or None

            choice = _SetupChoice(
                model=model,
                channels=requirements.channels,
                auth_mode=setup_auth_mode,
                oauth_credentials_present=oauth_credentials_present,
                provider_api_key=provider_api_key,
                telegram_token=telegram_token,
                owner_name=owner_name,
                owner_telegram_id=owner_tid,
            )
            plan = _build_setup_plan(choice)
            if not plan.is_complete:
                gap_msgs = "; ".join(g.message for g in plan.gaps)
                raise _SetupValidationError(gap_msgs or "Setup is incomplete.")

            self._config = _apply_setup_plan(
                plan,
                config_path=_cfg.CONFIG_FILE,
                env_file=_cfg.OTTO_HOME / ".env",
            )
        except _SetupValidationError as exc:
            raise web.HTTPFound(location=self._redirect_location("error", str(exc))) from exc
        except ValueError as exc:
            raise web.HTTPFound(location=self._redirect_location("error", str(exc))) from exc
        except Exception as exc:
            _log.exception("setup failed")
            raise web.HTTPFound(
                location=self._redirect_location("error", "Setup failed. Check logs for details.")
            ) from exc

        raise web.HTTPFound(location=self._redirect_location("success", "Setup complete!"))

    async def _load_orchestration_jobs(self) -> list[dict[str, Any]]:
        try:
            from otto.orchestrator import get_orchestrator

            return await get_orchestrator().list_jobs()
        except Exception:
            return []

    @staticmethod
    def _parse_positive_int(value: Any, *, default: int, maximum: int) -> int:
        try:
            parsed = int(str(value).strip())
        except (TypeError, ValueError):
            return default
        if parsed < 0:
            return default
        return min(parsed, maximum)

    @staticmethod
    def _iso_to_epoch(value: Any) -> float:
        raw = str(value or "").strip()
        if not raw:
            return 0.0
        if raw.endswith("Z"):
            raw = raw[:-1] + "+00:00"
        try:
            from datetime import datetime

            return datetime.fromisoformat(raw).timestamp()
        except ValueError:
            return 0.0

    @classmethod
    def _job_updated_at_sort_key(cls, job: dict[str, Any]) -> tuple[float, float]:
        updated_at = cls._iso_to_epoch(job.get("updatedAt"))
        created_at = cls._iso_to_epoch(job.get("createdAt"))
        return (updated_at, created_at)

    @classmethod
    def _job_sort_tuple(cls, job: dict[str, Any]) -> tuple[int, float, float]:
        status = str(job.get("status", "")).lower()
        order = {
            "in_progress": 0,
            "reworking": 1,
            "delivered": 2,
            "ready": 3,
            "failed": 4,
            "blocked": 5,
            "done": 6,
            "cancelled": 7,
        }.get(status, 8)
        updated_at, created_at = cls._job_updated_at_sort_key(job)
        return (order, -updated_at, -created_at)

    # ------------------------------------------------------------------
    # Rendering orchestration
    # ------------------------------------------------------------------

    def _render_index_html(self, status: str, message: str, *, active_panel: str = "agents") -> str:
        if not _cfg.CONFIG_FILE.exists():
            return self._render_wizard_page(status, message)
        running, pid = _h._daemon_status()
        return self._render_dashboard(running, pid, status, message, active_panel=active_panel)

    def _render_wizard_page(self, status: str, message: str) -> str:
        from otto.cli import DEFAULT_MODEL

        auth_storage = AuthStorage()
        oauth_state: dict[str, bool] = {}
        for provider in ("anthropic", "openai", "google", "copilot"):
            oauth_state[provider] = _has_oauth_credentials(auth_storage, provider)

        return _render(
            "wizard.html",
            flash_status=status,
            flash_message=message,
            default_model=DEFAULT_MODEL,
            oauth_state_json=json.dumps(oauth_state),
            wizard_js=WIZARD_JS,
        )

    def _render_dashboard(
        self,
        running: bool,
        pid: int | None,
        status: str,
        message: str,
        *,
        active_panel: str = "agents",
    ) -> str:
        # Build live status lookup from daemon bot instances.
        live_aliases = {}
        for bot in self._bots:
            bot_running = (
                bot._app is not None and bot._app.updater is not None and bot._app.updater.running
            )
            live_aliases[bot.alias] = bot_running

        bot_vms = []
        seen_aliases: set[str] = set()
        if self._config.bots:
            # New schema: build from config, merge with live state.
            for bot_cfg in self._config.bots:
                seen_aliases.add(bot_cfg.name)
                bot_vms.append(
                    _BotVM(
                        alias=bot_cfg.name,
                        is_running=live_aliases.get(bot_cfg.name, False),
                        model_display=bot_cfg.model or "default",
                        channel_types=[ch.type for ch in bot_cfg.channels]
                        if bot_cfg.channels
                        else [],
                    )
                )
        elif self._config.telegram and self._config.telegram.token:
            # Legacy config — synthesize a "main" view-model.
            seen_aliases.add("main")
            bot_vms.append(
                _BotVM(
                    alias="main",
                    is_running=live_aliases.get("main", bool(live_aliases)),
                    model_display=self._config.agent.model,
                    channel_types=["telegram"],
                )
            )

        # Include any live bot instances not already covered (e.g. legacy telegram sub-bots).
        for alias, is_running in live_aliases.items():
            if alias not in seen_aliases:
                bot_vms.append(
                    _BotVM(
                        alias=alias,
                        is_running=is_running,
                        model_display=self._config.agent.model,
                        channel_types=["telegram"],
                    )
                )

        oauth_providers = self._build_oauth_provider_vms()

        config_form = self._render_config_form()

        return _render(
            "dashboard.html",
            running=running,
            pid=pid,
            bots=bot_vms,
            oauth_providers=oauth_providers,
            config_form=config_form,
            version=__version__,
            flash_status=status,
            flash_message=message,
            dashboard_js=DASHBOARD_JS,
            active_panel=active_panel,
        )

    def _build_oauth_provider_vms(self) -> "list[_OAuthProviderVM]":
        storage = AuthStorage()
        now = time.time()
        providers: list[tuple[str, str]] = [
            ("anthropic", "Anthropic"),
            ("openai", "OpenAI"),
            ("google", "Google"),
            ("copilot", "GitHub Copilot"),
        ]
        rows: list[_OAuthProviderVM] = []
        for key, label in providers:
            if key == "copilot":
                if _has_oauth_credentials(storage, key):
                    rows.append(
                        _OAuthProviderVM(
                            key=key,
                            label=label,
                            status="Connected",
                            detail="OAuth credentials are available via LiteLLM.",
                            action_label=None,
                        )
                    )
                else:
                    rows.append(
                        _OAuthProviderVM(
                            key=key,
                            label=label,
                            status="Not connected",
                            detail="No Copilot OAuth credentials found.",
                            action_label="Connect",
                        )
                    )
                continue

            credentials = storage.get_credentials(key)
            if not isinstance(credentials, dict):
                rows.append(
                    _OAuthProviderVM(
                        key=key,
                        label=label,
                        status="Not connected",
                        detail="No OAuth credentials found.",
                        action_label="Connect",
                    )
                )
                continue

            cred_type = str(credentials.get("type", "unknown"))
            if cred_type != "oauth":
                rows.append(
                    _OAuthProviderVM(
                        key=key,
                        label=label,
                        status="API key mode",
                        detail="OAuth is not configured for this provider.",
                        action_label="Connect",
                    )
                )
                continue

            refresh = str(credentials.get("refresh", "")).strip()
            access = str(credentials.get("access", "")).strip()
            expires_raw = credentials.get("expires")
            expires: float | None = None
            try:
                if expires_raw is not None:
                    expires = float(expires_raw)
            except (TypeError, ValueError):
                expires = None

            if not refresh or not access:
                rows.append(
                    _OAuthProviderVM(
                        key=key,
                        label=label,
                        status="Reconnect required",
                        detail="Stored OAuth token is incomplete.",
                        action_label="Reconnect",
                    )
                )
                continue

            if expires is not None and expires <= now:
                rows.append(
                    _OAuthProviderVM(
                        key=key,
                        label=label,
                        status="Expired",
                        detail="Will attempt automatic refresh when used.",
                        action_label=None,
                    )
                )
                continue

            rows.append(
                _OAuthProviderVM(
                    key=key,
                    label=label,
                    status="Connected",
                    detail="OAuth credentials are available.",
                    action_label=None,
                )
            )

        return rows

    def _render_config_form(self) -> str:
        """Render the settings form via Jinja templates."""
        if self._config.bots:
            return _render(
                "_form_new.html",
                config=self._config,
            )
        # Legacy schema — extract telegram fields for template
        tg = self._config.telegram
        return _render(
            "_form_legacy.html",
            config=self._config,
            workdir=str(self._config.workdir) if self._config.workdir else "~",
            tg_token=tg.token if tg else "",
            owner_id="" if tg is None or tg.owner_id is None else str(tg.owner_id),
            allowed_users="" if tg is None else ", ".join(str(uid) for uid in tg.allowed_users),
            tg_bootstrap=tg.bootstrap if tg else "first_user",
        )

    # ------------------------------------------------------------------
    # Config building (from form POST data)
    # ------------------------------------------------------------------

    def _build_updated_config(self, form_data: dict[str, Any]) -> Config:
        schema = _h.clean_str(form_data.get("_schema"))
        if schema == "new":
            return self._build_updated_config_new(form_data)
        return self._build_updated_config_legacy(form_data)

    def _build_updated_config_legacy(self, form_data: dict[str, Any]) -> Config:
        from otto.config import TelegramConfig

        log_level = _h.clean_str(form_data.get("log_level")) or self._config.log_level
        workdir_raw = _h.clean_str(form_data.get("workdir")) or str(self._config.workdir)
        model = _h.clean_str(form_data.get("model")) or self._config.agent.model

        tg = self._config.telegram
        bootstrap = _h.clean_str(form_data.get("bootstrap")) or (
            tg.bootstrap if tg else "first_user"
        )
        if bootstrap not in ("first_user", "disabled"):
            raise ValueError("bootstrap must be either 'first_user' or 'disabled'.")

        owner_raw = _h.clean_str(form_data.get("owner_id"))
        owner_id = _h.parse_optional_int(owner_raw, field_name="owner_id")
        allowed_users = _h.parse_allowed_users(_h.clean_str(form_data.get("allowed_users")))

        token_input = _h.clean_str(form_data.get("telegram_token"))
        token_unchanged = token_input in {"", _MASKED_TOKEN}
        current_token = tg.token if tg else ""
        token_for_runtime = current_token if token_unchanged else token_input

        raw_values = self._copy_raw_values()
        if not isinstance(raw_values.get("telegram"), dict):
            raw_values["telegram"] = {}
        current_raw_token = raw_values["telegram"].get("token", current_token)
        token_for_file = current_raw_token if token_unchanged else token_input

        raw_values["log_level"] = log_level
        raw_values["workdir"] = workdir_raw
        raw_values["telegram"]["token"] = token_for_file
        raw_values["telegram"]["owner_id"] = owner_id
        raw_values["telegram"]["allowed_users"] = allowed_users
        raw_values["telegram"]["bootstrap"] = bootstrap
        raw_values["agent"]["model"] = model
        if self._config.env_file is not None:
            raw_values["env_file"] = self._config.env_file
        else:
            raw_values.pop("env_file", None)

        updated = Config(
            telegram=TelegramConfig(
                token=token_for_runtime,
                owner_id=owner_id,
                allowed_users=allowed_users,
                bootstrap=bootstrap,
            ),
            agent=AgentConfig(model=model),
            log_level=log_level,
            workdir=Path(workdir_raw).expanduser(),
            env_file=self._config.env_file,
        )
        object.__setattr__(updated, "_raw_values", raw_values)
        return updated

    def _build_updated_config_new(self, form_data: dict[str, Any]) -> Config:
        log_level = _h.clean_str(form_data.get("log_level")) or self._config.log_level
        model = _h.clean_str(form_data.get("model")) or self._config.agent.model

        user_count = int(_h.clean_str(form_data.get("_user_count")) or "0")
        bot_count = int(_h.clean_str(form_data.get("_bot_count")) or "0")

        users: list[UserConfig] = []
        for idx in range(user_count):
            name = _h.clean_str(form_data.get(f"user_{idx}_name"))
            if not name:
                continue
            tid_raw = _h.clean_str(form_data.get(f"user_{idx}_telegram_id"))
            tid = _h.parse_optional_int(tid_raw, field_name=f"user_{idx}_telegram_id")
            users.append(UserConfig(name=name, telegram_id=tid))

        bots: list[BotConfig] = []
        for idx in range(bot_count):
            bot_name = _h.clean_str(form_data.get(f"bot_{idx}_name"))
            if not bot_name:
                continue
            bot_model = _h.clean_str(form_data.get(f"bot_{idx}_model")) or None
            owner = _h.clean_str(form_data.get(f"bot_{idx}_owner"))
            allowed_raw = _h.clean_str(form_data.get(f"bot_{idx}_allowed"))
            allowed = (
                [s.strip() for s in allowed_raw.split(",") if s.strip()] if allowed_raw else []
            )
            bootstrap = _h.clean_str(form_data.get(f"bot_{idx}_bootstrap")) or "disabled"
            if bootstrap not in ("first_user", "disabled"):
                raise ValueError("bootstrap must be either 'first_user' or 'disabled'.")

            old_bot = self._config.bots[idx] if idx < len(self._config.bots) else None
            workspace = (
                old_bot.workspace if old_bot else WorkspaceConfig(root=Path("~").expanduser())
            )

            channels: list[ChannelConfig] = []
            raw_bots = getattr(self._config, "_raw_values", {})
            raw_bots_list = raw_bots.get("bots", []) if isinstance(raw_bots, dict) else []
            raw_bot_entry = (
                raw_bots_list[idx]
                if isinstance(raw_bots_list, list)
                and idx < len(raw_bots_list)
                and isinstance(raw_bots_list[idx], dict)
                else {}
            )
            raw_channels_list = raw_bot_entry.get("channels", [])
            if not isinstance(raw_channels_list, list):
                raw_channels_list = []
            if old_bot:
                for ch_idx, old_ch in enumerate(old_bot.channels):
                    ch_token_input = _h.clean_str(form_data.get(f"bot_{idx}_ch_{ch_idx}_token"))
                    ch_token_unchanged = ch_token_input in {"", _MASKED_TOKEN}
                    if ch_token_unchanged:
                        raw_ch = (
                            raw_channels_list[ch_idx]
                            if ch_idx < len(raw_channels_list)
                            and isinstance(raw_channels_list[ch_idx], dict)
                            else {}
                        )
                        ch_token = raw_ch.get("token", old_ch.token)
                    else:
                        ch_token = ch_token_input
                    channels.append(
                        ChannelConfig(
                            type=old_ch.type,
                            token=ch_token,
                            enabled=old_ch.enabled,
                            port=old_ch.port,
                        )
                    )

            bots.append(
                BotConfig(
                    name=bot_name,
                    model=bot_model,
                    auth=BotAuthConfig(
                        owner=owner,
                        allowed_users=allowed,
                        bootstrap=bootstrap,
                    ),
                    workspace=workspace,
                    channels=channels,
                )
            )

        raw_values = self._copy_raw_values()
        raw_values["log_level"] = log_level
        raw_values["agent"]["model"] = model
        raw_values.pop("users", None)
        raw_values.pop("bots", None)
        if self._config.env_file is not None:
            raw_values["env_file"] = self._config.env_file
        else:
            raw_values.pop("env_file", None)

        updated = Config(
            agent=AgentConfig(model=model),
            log_level=log_level,
            env_file=self._config.env_file,
            users=users,
            bots=bots,
        )
        object.__setattr__(updated, "_raw_values", raw_values)
        return updated

    def _copy_raw_values(self) -> dict[str, Any]:
        existing = getattr(self._config, "_raw_values", {})
        values = copy.deepcopy(existing) if isinstance(existing, dict) else {}
        if not isinstance(values.get("agent"), dict):
            values["agent"] = {}
        return values

    @staticmethod
    def _redirect_location(status: str, message: str, *, panel: str = "") -> str:
        base = f"/{panel}" if panel else "/"
        return base + "?" + urlencode({"status": status, "message": message})


class _BotVM:
    """Lightweight view-model for bot template rendering."""

    __slots__ = ("alias", "is_running", "model_display", "channel_types")

    def __init__(
        self,
        alias: str,
        is_running: bool,
        model_display: str,
        channel_types: list[str],
    ):
        self.alias = alias
        self.is_running = is_running
        self.model_display = model_display
        self.channel_types = channel_types


class _OAuthProviderVM:
    __slots__ = ("key", "label", "status", "detail", "action_label")

    def __init__(
        self,
        *,
        key: str,
        label: str,
        status: str,
        detail: str,
        action_label: str | None,
    ):
        self.key = key
        self.label = label
        self.status = status
        self.detail = detail
        self.action_label = action_label
