from __future__ import annotations

import base64
import json
import logging
import socket
import threading
import time
from dataclasses import asdict, dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

from .config import Settings
from .control import ControlBackend
from .mac_client import MacControlClient, MacServiceHTTPError
from .models import CommandRecord, TaskNote, TaskState
from .relay_registry import RelayDevice, RelayRegistry
from .state_store import StateStore
from .executor import LocalCommandExecutor
from .notifier import TelegramNotifier
from .agent import IntentParser, NullIntentParser, NullTaskPlanner, OpenAIIntentParser, OpenAITaskPlanner
from .transcriber import NullVoiceTranscriber, OpenAIVoiceTranscriber, VoiceTranscriber
from .worker import WorkerController

logger = logging.getLogger(__name__)

def _parse_snapshot_limit(query: dict[str, list[str]], default: int = 180) -> int:
    raw = query.get("limit", [str(default)])[0]
    try:
        return max(0, min(220, int(raw)))
    except (TypeError, ValueError):
        return default


def build_mac_backend(device: RelayDevice, timeout_seconds: float) -> MacControlClient:
    return MacControlClient(
        base_url=device.mac_service_url,
        token=device.mac_service_token,
        timeout_seconds=timeout_seconds,
    )


def build_backend(settings: Settings) -> ControlBackend:
    if settings.codexbot_mac_service_url and settings.codexbot_mac_service_token:
        return MacControlClient(
            base_url=settings.codexbot_mac_service_url,
            token=settings.codexbot_mac_service_token,
            timeout_seconds=settings.codexbot_mac_service_timeout_seconds,
        )

    store = StateStore(settings.codexbot_state_path)
    planner = (
        OpenAITaskPlanner(settings.openai_api_key, settings.codexbot_agent_model)
        if settings.openai_api_key
        else NullTaskPlanner()
    )
    executor = LocalCommandExecutor(
        workdir=settings.codexbot_workdir,
        allowed_commands=settings.allowed_commands,
        max_output_chars=settings.codexbot_max_output_chars,
    )
    notifier = TelegramNotifier(settings.telegram_bot_token, settings.allowed_user_ids)
    return WorkerController(
        store,
        executor,
        planner,
        notifier,
        poll_seconds=settings.codexbot_worker_poll_seconds,
    )


def build_transcriber(settings: Settings) -> VoiceTranscriber:
    if settings.openai_api_key:
        return OpenAIVoiceTranscriber(
            settings.openai_api_key,
            settings.codexbot_transcribe_model,
            settings.codexbot_transcribe_language,
        )
    return NullVoiceTranscriber()


def build_intent_parser(settings: Settings) -> IntentParser:
    if settings.openai_api_key:
        return OpenAIIntentParser(settings.openai_api_key, settings.codexbot_intent_model)
    return NullIntentParser()


@dataclass
class ChatResponse:
    reply: str
    state: dict
    active_task_id: str


def _safe_upload_name(filename: str) -> str:
    cleaned = "".join(ch for ch in filename if ch.isalnum() or ch in {".", "-", "_"})
    return cleaned or "attachment.bin"


def _desktop_message_rejected(payload: dict) -> bool:
    status = str(payload.get("status") or "").lower()
    return (
        payload.get("accepted") is False
        or payload.get("ok") is False
        or status in {"runtime_timeout", "runtime_degraded", "not_ready", "failed", "send_failed"}
    )


def _timeline_entries(state: TaskState) -> list[dict]:
    events: list[tuple[object, str, str, str]] = []
    for note in state.notes[-8:]:
        summary = note.result_summary or note.text
        when = note.processed_at or note.created_at
        events.append((when, "note", note.author, summary))
    for record in state.command_history[-8:]:
        events.append((record.created_at, "command", record.command, f"退出碼 {record.exit_code}"))
    events.sort(key=lambda item: item[0], reverse=True)
    return [
        {
            "at": getattr(when, "isoformat")().replace("+00:00", "Z"),
            "kind": kind,
            "title": title,
            "summary": summary,
        }
        for when, kind, title, summary in events[:12]
    ]


class AppApiHandler(BaseHTTPRequestHandler):
    backend: ControlBackend
    default_backend: ControlBackend
    intent_parser: IntentParser
    relay_registry: RelayRegistry | None
    settings: Settings
    transcriber: VoiceTranscriber
    token: str
    auth_device: RelayDevice | None = None

    def do_HEAD(self) -> None:  # noqa: N802
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", "0")
            self.end_headers()
            return
        self.send_response(HTTPStatus.NOT_FOUND)
        self.send_header("Content-Length", "0")
        self.end_headers()

    def do_GET(self) -> None:  # noqa: N802
        parsed = urlparse(self.path)
        query = parse_qs(parsed.query)
        if parsed.path == "/health":
            self._send_json({"ok": True})
            return
        if not self._authorize():
            return
        if parsed.path == "/relay/me":
            payload = self._relay_identity_payload()
            if payload is None:
                self._send_json({"error": "Relay account unavailable"}, status=HTTPStatus.NOT_FOUND)
                return
            self._send_json(payload)
            return
        if parsed.path == "/dashboard":
            try:
                state = self.backend.get_state()
                task_summaries = self.backend.list_task_summaries()
                self._send_json(
                    {
                        "active_task_id": state.task_id,
                        "active_state": state.model_dump(mode="json"),
                        "task_summaries": [task.model_dump(mode="json") for task in task_summaries],
                        "recent_tasks": [task.model_dump(mode="json") for task in task_summaries[:3]],
                        "tasks": self.backend.list_tasks(),
                        "queue": self.backend.format_queue(),
                        "progress": self.backend.progress_summary(),
                        "history": self.backend.format_command_history(),
                        "timeline": _timeline_entries(state),
                    }
                )
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/task_state":
            task_ref = query.get("task_ref", [""])[0].strip()
            try:
                state = self.backend.get_task_state(task_ref) if task_ref else self.backend.get_state()
            except FileNotFoundError:
                self._send_json({"error": f"Task not found: {task_ref}"}, status=HTTPStatus.NOT_FOUND)
                return
            self._send_json({"state": state.model_dump(mode="json")})
            return
        if parsed.path == "/tasks":
            self._send_json(
                {
                    "tasks": self.backend.list_tasks(),
                    "task_summaries": [task.model_dump(mode="json") for task in self.backend.list_task_summaries()],
                }
            )
            return
        if parsed.path == "/desktop/status":
            if not hasattr(self.backend, "get_desktop_bridge_status"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                self._send_json(self.backend.get_desktop_bridge_status())
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/usage":
            if not hasattr(self.backend, "get_desktop_usage"):
                self._send_json({"error": "Desktop usage unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                self._send_json(self.backend.get_desktop_usage())
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/runtime":
            if not hasattr(self.backend, "get_desktop_runtime"):
                self._send_json({"error": "Desktop runtime unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            thread_id = query.get("thread_id", [""])[0].strip() or None
            try:
                self._send_json(self.backend.get_desktop_runtime(thread_id))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path in {"/desktop/runtime_ready", "/desktop/runtime-ready"}:
            if not hasattr(self.backend, "get_desktop_runtime_ready"):
                self._send_json({"error": "Desktop runtime readiness unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            thread_id = query.get("thread_id", [""])[0].strip() or None
            try:
                self._send_json(self.backend.get_desktop_runtime_ready(thread_id))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/snapshot":
            if not hasattr(self.backend, "get_desktop_bridge_snapshot"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                self._send_json(self.backend.get_desktop_bridge_snapshot(limit=_parse_snapshot_limit(query)))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/thread_snapshot":
            if not hasattr(self.backend, "get_desktop_thread_snapshot"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            thread_id = query.get("thread_id", [""])[0].strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                self._send_json(self.backend.get_desktop_thread_snapshot(thread_id, limit=_parse_snapshot_limit(query)))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/build_ios_adhoc_status":
            if not hasattr(self.backend, "get_ios_adhoc_build_status"):
                self._send_json({"error": "iOS Ad Hoc build status unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            request_id = query.get("request_id", [""])[0].strip()
            if not request_id:
                self._send_json({"error": "Missing request_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                self._send_json(self.backend.get_ios_adhoc_build_status(request_id))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/file":
            if not hasattr(self.backend, "read_desktop_file"):
                self._send_json({"error": "Desktop file view unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            path = query.get("path", [""])[0].strip()
            if not path:
                self._send_json({"error": "Missing path"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                self._send_json(self.backend.read_desktop_file(path, encoding=query.get("encoding", [""])[0].strip() or None))
            except FileNotFoundError:
                self._send_json({"error": f"File not found: {path}"}, status=HTTPStatus.NOT_FOUND)
            except PermissionError as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.FORBIDDEN)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/messages":
            if not hasattr(self.backend, "get_desktop_bridge_messages"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                self._send_json(self.backend.get_desktop_bridge_messages(limit=_parse_snapshot_limit(query)))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/threads":
            if not hasattr(self.backend, "list_desktop_threads"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                self._send_json(self.backend.list_desktop_threads())
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/projects":
            if not hasattr(self.backend, "list_desktop_projects"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                force = (query.get("refresh") or query.get("force") or [""])[0] in {"1", "true", "yes"}
                self._send_json(self.backend.list_desktop_projects(force=force))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
            return
        self._send_json({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)

    def do_POST(self) -> None:  # noqa: N802
        if not self._authorize():
            return
        parsed = urlparse(self.path)
        data = self._read_json()
        if parsed.path == "/chat":
            text = str(data.get("text", "")).strip()
            if not text:
                self._send_json({"error": "Missing text"}, status=HTTPStatus.BAD_REQUEST)
                return
            response = self._handle_chat(text)
            self._send_json(asdict(response))
            return
        if parsed.path == "/task/switch":
            task_ref = str(data.get("task_ref", "")).strip()
            if not task_ref:
                self._send_json({"error": "Missing task_ref"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                state = self.backend.switch_task(task_ref)
            except FileNotFoundError:
                self._send_json({"error": f"Task not found: {task_ref}"}, status=HTTPStatus.NOT_FOUND)
                return
            self._send_json({"state": state.model_dump(mode="json")})
            return
        if parsed.path == "/transcribe":
            encoded_audio = str(data.get("audio_base64", "")).strip()
            filename = str(data.get("filename", "voice.m4a")).strip() or "voice.m4a"
            if not encoded_audio:
                self._send_json({"error": "Missing audio_base64"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                audio_bytes = base64.b64decode(encoded_audio, validate=True)
            except Exception:
                self._send_json({"error": "Invalid base64 audio payload"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                text = self.transcriber.transcribe(audio_bytes, filename=filename)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
                return
            self._send_json({"text": text})
            return
        if parsed.path == "/attachment":
            encoded_attachment = str(data.get("file_base64", "")).strip()
            filename = str(data.get("filename", "attachment.bin")).strip() or "attachment.bin"
            mime_type = str(data.get("mime_type", "application/octet-stream")).strip() or "application/octet-stream"
            thread_id = str(data.get("thread_id", "")).strip() or None
            if not encoded_attachment:
                self._send_json({"error": "Missing file_base64"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                file_bytes = base64.b64decode(encoded_attachment, validate=True)
            except Exception:
                self._send_json({"error": "Invalid base64 attachment payload"}, status=HTTPStatus.BAD_REQUEST)
                return
            if hasattr(self.backend, "analyze_desktop_attachment"):
                try:
                    payload = self.backend.analyze_desktop_attachment(
                        filename=filename,
                        mime_type=mime_type,
                        file_base64=encoded_attachment,
                        thread_id=thread_id,
                    )
                except Exception as exc:
                    self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                    return
                self._send_json(
                    {
                        "reply": str(payload.get("reply") or "").strip(),
                        "saved_path": payload.get("attachment_path"),
                        "thread_id": payload.get("thread_id"),
                        "thread_name": payload.get("thread_name"),
                        "project_title": payload.get("project_title"),
                    }
                )
                return
            uploads_dir = Settings().codexbot_state_path.parent / "uploads"
            uploads_dir.mkdir(parents=True, exist_ok=True)
            safe_name = _safe_upload_name(filename)
            target = uploads_dir / safe_name
            if target.exists():
                target = uploads_dir / f"{target.stem}-{len(file_bytes)}{target.suffix}"
            target.write_bytes(file_bytes)
            state = self.backend.add_note(
                f"已收到附件：{safe_name}\n類型：{mime_type}\n存放位置：{target}",
                author="mobile",
            )
            self._send_json(
                {
                    "reply": f"已上傳附件：{safe_name}",
                    "saved_path": str(target),
                    "state": state.model_dump(mode="json"),
                    "active_task_id": state.task_id,
                }
            )
            return
        if parsed.path == "/desktop/upload_attachment":
            encoded_attachment = str(data.get("file_base64", "")).strip()
            filename = str(data.get("filename", "attachment.bin")).strip() or "attachment.bin"
            mime_type = str(data.get("mime_type", "application/octet-stream")).strip() or "application/octet-stream"
            thread_id = str(data.get("thread_id", "")).strip() or None
            if not encoded_attachment:
                self._send_json({"error": "Missing file_base64"}, status=HTTPStatus.BAD_REQUEST)
                return
            try:
                base64.b64decode(encoded_attachment, validate=True)
            except Exception:
                self._send_json({"error": "Invalid base64 attachment payload"}, status=HTTPStatus.BAD_REQUEST)
                return
            if hasattr(self.backend, "upload_desktop_attachment"):
                try:
                    payload = self.backend.upload_desktop_attachment(
                        filename=filename,
                        mime_type=mime_type,
                        file_base64=encoded_attachment,
                        thread_id=thread_id,
                    )
                except Exception as exc:
                    self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                    return
                self._send_json(payload)
                return
            self._send_json({"error": "Desktop attachment upload unavailable"}, status=HTTPStatus.BAD_GATEWAY)
            return
        if parsed.path == "/desktop/message":
            text = str(data.get("text") or data.get("message") or "").strip()
            thread_id = str(data.get("thread_id", "")).strip() or None
            model = str(data.get("model", "")).strip() or None
            effort = str(data.get("effort", "")).strip() or None
            permission = str(data.get("permission", "")).strip() or None
            async_mode = data.get("async") is not False
            image_data_urls = [
                str(item).strip()
                for item in data.get("image_data_urls", [])
                if str(item).strip()
            ] if isinstance(data.get("image_data_urls"), list) else []
            local_image_paths = [
                str(item).strip()
                for item in data.get("local_image_paths", [])
                if str(item).strip()
            ] if isinstance(data.get("local_image_paths"), list) else []
            if not text and not image_data_urls and not local_image_paths:
                self._send_json({"error": "Missing text"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not text and (image_data_urls or local_image_paths):
                image_count = len(image_data_urls) + len(local_image_paths)
                text = "請直接閱讀並分析這張圖片。" if image_count == 1 else "請直接閱讀並分析這些圖片。"
            if not hasattr(self.backend, "send_desktop_bridge_message"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            if not async_mode and hasattr(self.backend, "get_desktop_runtime_ready"):
                try:
                    ready_payload = self.backend.get_desktop_runtime_ready(thread_id)
                except Exception as exc:
                    self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                    return
                if not ready_payload.get("ready"):
                    self._send_json(
                        {
                            "ok": False,
                            "accepted": False,
                            "status": ready_payload.get("status") or "not_ready",
                            "thread_id": ready_payload.get("thread_id") or thread_id,
                            "reason": ready_payload.get("reason") or "Codex app-server 尚未就緒。",
                            "error": ready_payload.get("reason") or "Codex app-server 尚未就緒。",
                            "recoverable": True,
                        },
                        status=HTTPStatus.CONFLICT,
                    )
                    return
            if async_mode:
                try:
                    payload = self.backend.send_desktop_bridge_message(
                        text,
                        author="mobile",
                        mirrored_to_project=False,
                        thread_id=thread_id,
                        model=model,
                        effort=effort,
                        permission=permission,
                        image_data_urls=image_data_urls or None,
                        local_image_paths=local_image_paths or None,
                        async_mode=True,
                    )
                except MacServiceHTTPError as exc:
                    if isinstance(exc.payload, dict):
                        payload = dict(exc.payload)
                        payload.setdefault("ok", False)
                        payload.setdefault("accepted", False)
                        payload.setdefault("thread_id", thread_id)
                        payload.setdefault("recoverable", True)
                        payload.setdefault("error", payload.get("reason") or exc.detail)
                        self._send_json(
                            payload,
                            status=HTTPStatus.CONFLICT if exc.status_code == HTTPStatus.CONFLICT else HTTPStatus.BAD_GATEWAY,
                        )
                        return
                    logger.exception("Desktop async message was not accepted by Mac service")
                    self._send_json(
                        {
                            "ok": False,
                            "accepted": False,
                            "status": "failed",
                            "thread_id": thread_id,
                            "error": exc.detail,
                            "recoverable": True,
                        },
                        status=HTTPStatus.BAD_GATEWAY,
                    )
                    return
                except Exception as exc:
                    logger.exception("Desktop async message was not accepted by Mac service")
                    self._send_json(
                        {
                            "ok": False,
                            "accepted": False,
                            "status": "failed",
                            "thread_id": thread_id,
                            "error": str(exc),
                            "recoverable": True,
                        },
                        status=HTTPStatus.BAD_GATEWAY,
                    )
                    return
                if isinstance(payload, dict):
                    if _desktop_message_rejected(payload):
                        payload.setdefault("ok", False)
                        payload.setdefault("accepted", False)
                        payload.setdefault("thread_id", thread_id)
                        payload.setdefault("recoverable", True)
                        payload.setdefault("error", payload.get("reason") or payload.get("error") or payload.get("reply") or "Desktop message was not accepted")
                        self._send_json(payload, status=HTTPStatus.CONFLICT)
                        return
                    payload.setdefault("ok", True)
                    payload.setdefault("accepted", True)
                    payload.setdefault("status", "queued")
                    payload.setdefault("thread_id", thread_id)
                    payload.setdefault("reply", "已送出，正在等待桌面端處理。")
                    self._send_json(payload)
                    return
                self._send_json(
                    {
                        "ok": True,
                        "accepted": True,
                        "status": "queued",
                        "thread_id": thread_id,
                        "reply": "已送出，正在等待桌面端處理。",
                    }
                )
                return
            try:
                payload = self.backend.send_desktop_bridge_message(
                    text,
                    author="mobile",
                    mirrored_to_project=False,
                    thread_id=thread_id,
                    model=model,
                    effort=effort,
                    permission=permission,
                    image_data_urls=image_data_urls or None,
                    local_image_paths=local_image_paths or None,
                )
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            if async_mode and isinstance(payload, dict):
                payload.setdefault("accepted", True)
                payload.setdefault("reply", "已送出，正在等待桌面端處理。")
            self._send_json(payload)
            return
        if parsed.path == "/desktop/interrupt":
            thread_id = str(data.get("thread_id", "")).strip() or None
            request_id = str(data.get("request_id", "")).strip() or None
            async_mode = bool(data.get("async"))
            if not hasattr(self.backend, "interrupt_desktop_turn"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            if async_mode:
                def interrupt_in_background() -> None:
                    try:
                        self.backend.interrupt_desktop_turn(thread_id, request_id=request_id, async_mode=True)
                    except Exception:
                        logger.exception("Async desktop interrupt failed")

                threading.Thread(target=interrupt_in_background, daemon=True).start()
                self._send_json(
                    {
                        "ok": True,
                        "accepted": True,
                        "thread_id": thread_id,
                        "request_id": request_id,
                        "reply": "已送出停止請求。",
                    }
                )
                return
            try:
                payload = self.backend.interrupt_desktop_turn(thread_id, request_id=request_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/rollback":
            thread_id = str(data.get("thread_id", "")).strip() or None
            async_mode = bool(data.get("async"))
            try:
                num_turns = int(data.get("num_turns") or 1)
            except (TypeError, ValueError):
                self._send_json({"error": "Invalid num_turns"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "rollback_desktop_turns"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            if async_mode:
                def rollback_in_background() -> None:
                    try:
                        self.backend.rollback_desktop_turns(thread_id, num_turns, async_mode=True)
                    except Exception:
                        logger.exception("Async desktop rollback failed")

                threading.Thread(target=rollback_in_background, daemon=True).start()
                self._send_json(
                    {
                        "ok": True,
                        "accepted": True,
                        "thread_id": thread_id,
                        "num_turns": num_turns,
                        "reply": "已送出回退請求。",
                    }
                )
                return
            try:
                payload = self.backend.rollback_desktop_turns(thread_id, num_turns)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/inject_items":
            if not hasattr(self.backend, "inject_desktop_items"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            items = data.get("items")
            if not isinstance(items, list):
                self._send_json({"error": "Missing items"}, status=HTTPStatus.BAD_REQUEST)
                return
            thread_id = str(data.get("thread_id", "")).strip() or None
            try:
                payload = self.backend.inject_desktop_items(items, thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/computer_control":
            if not hasattr(self.backend, "request_desktop_computer_control"):
                self._send_json({"error": "Desktop computer control unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            instruction = str(data.get("instruction", "")).strip() or None
            thread_id = str(data.get("thread_id", "")).strip() or None
            try:
                payload = self.backend.request_desktop_computer_control(instruction, thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/screenshot":
            if not hasattr(self.backend, "capture_desktop_screenshot"):
                self._send_json({"error": "Desktop screenshot unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.capture_desktop_screenshot()
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/build_ios_adhoc":
            if not hasattr(self.backend, "build_ios_adhoc"):
                self._send_json({"error": "iOS Ad Hoc build unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            thread_id = str(data.get("thread_id", "")).strip() or None
            project_id = str(data.get("project_id", "")).strip() or None
            cwd = str(data.get("cwd", "")).strip() or None
            scheme = str(data.get("scheme", "")).strip() or None
            async_mode = bool(data.get("async"))
            try:
                payload = self.backend.build_ios_adhoc(thread_id, project_id, cwd, scheme, async_mode=async_mode)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/restart_codex":
            if not hasattr(self.backend, "restart_desktop_codex"):
                self._send_json({"error": "Desktop Codex restart unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.restart_desktop_codex(str(data.get("thread_id", "")).strip() or None)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/restart_desktop_connection":
            if not hasattr(self.backend, "restart_desktop_connection"):
                self._send_json({"error": "Desktop connection restart unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.restart_desktop_connection()
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/restart_mac":
            self._send_json({"error": "Mac restart disabled", "message": "重啟 Mac 功能已停用。"}, status=HTTPStatus.GONE)
            return
        if parsed.path == "/desktop/approve":
            try:
                state = self.backend.approve()
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(
                {
                    "reply": "已批准待審批操作。",
                    "state": state.model_dump(mode="json"),
                    "active_task_id": state.task_id,
                }
            )
            return
        if parsed.path == "/desktop/reject":
            try:
                state = self.backend.reject_pending_command()
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(
                {
                    "reply": "已拒絕待審批操作。",
                    "state": state.model_dump(mode="json"),
                    "active_task_id": state.task_id,
                }
            )
            return
        if parsed.path == "/desktop/new_thread":
            if not hasattr(self.backend, "create_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            model = str(data.get("model", "")).strip() or None
            effort = str(data.get("effort", "")).strip() or None
            permission = str(data.get("permission", "")).strip() or None
            try:
                payload = self.backend.create_desktop_thread(
                    str(data.get("cwd", "")).strip() or None,
                    model=model,
                    effort=effort,
                    permission=permission,
                )
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/rename_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            title = str(data.get("title", data.get("name", ""))).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not title:
                self._send_json({"error": "Missing title"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "rename_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.rename_desktop_thread(thread_id, title)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/archive_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "archive_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.archive_desktop_thread(thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/unarchive_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "unarchive_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.unarchive_desktop_thread(thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/pin_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "pin_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.pin_desktop_thread(thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/unpin_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "unpin_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.unpin_desktop_thread(thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/fork_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "fork_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.fork_desktop_thread(thread_id, str(data.get("cwd", "")).strip() or None)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/new_project":
            cwd = str(data.get("cwd", "")).strip()
            if not cwd:
                self._send_json({"error": "Missing cwd"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "create_desktop_project"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.create_desktop_project(cwd)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/open_project":
            project_id = str(data.get("project_id", "")).strip()
            if not project_id:
                self._send_json({"error": "Missing project_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "open_desktop_project"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.open_desktop_project(project_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/rename_project":
            cwd = str(data.get("cwd", "")).strip()
            title = str(data.get("title", data.get("name", ""))).strip()
            if not cwd:
                self._send_json({"error": "Missing cwd"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not title:
                self._send_json({"error": "Missing title"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "rename_desktop_project"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.rename_desktop_project(cwd, title)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/remove_project":
            cwd = str(data.get("cwd", "")).strip()
            if not cwd:
                self._send_json({"error": "Missing cwd"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "remove_desktop_project"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.remove_desktop_project(cwd)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/open_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "open_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.open_desktop_thread(thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/refresh_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not hasattr(self.backend, "refresh_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.refresh_desktop_thread(thread_id or None, force=bool(data.get("force", True)))
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        if parsed.path == "/desktop/select_thread":
            thread_id = str(data.get("thread_id", "")).strip()
            if not thread_id:
                self._send_json({"error": "Missing thread_id"}, status=HTTPStatus.BAD_REQUEST)
                return
            if not hasattr(self.backend, "select_desktop_thread"):
                self._send_json({"error": "Desktop bridge unavailable"}, status=HTTPStatus.BAD_GATEWAY)
                return
            try:
                payload = self.backend.select_desktop_thread(thread_id)
            except Exception as exc:
                self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_GATEWAY)
                return
            self._send_json(payload)
            return
        self._send_json({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)

    def _handle_chat(self, text: str) -> ChatResponse:
        normalized = text.strip()
        canonical = self._canonicalize_chinese_text(normalized)
        lowered = canonical.casefold()

        if lowered in {"查看任务列表", "查看任務列表", "看看任务列表", "看看任務列表"}:
            state = self.backend.get_state()
            return self._log_chat_response(normalized, self.backend.list_tasks(), state)

        if lowered in {"看看任务状态", "看看任務狀態", "查看任务状态", "查看任務狀態"}:
            state = self.backend.get_state()
            return self._log_chat_response(normalized, self._format_state(state), state)

        if lowered in {"看看队列", "看看隊列", "查看队列", "查看隊列"}:
            state = self.backend.get_state()
            return self._log_chat_response(normalized, self.backend.format_queue(), state)

        if lowered in {"看看进度", "看看進度", "查看进度", "查看進度"}:
            state = self.backend.get_state()
            return self._log_chat_response(normalized, self.backend.progress_summary(), state)

        if lowered in {
            "最近历史",
            "最近歷史",
            "查看最近历史",
            "查看最近歷史",
            "看看最近历史",
            "看看最近歷史",
            "查看历史",
            "查看歷史",
            "看看历史",
            "看看歷史",
        }:
            state = self.backend.get_state()
            return self._log_chat_response(normalized, self.backend.format_command_history(), state)

        if lowered in {
            "最近输出",
            "最近輸出",
            "查看最近输出",
            "查看最近輸出",
            "看看最近输出",
            "看看最近輸出",
            "查看输出",
            "查看輸出",
            "看看输出",
            "看看輸出",
        }:
            state = self.backend.get_state()
            output = state.last_command_output or "目前還沒有最近輸出。"
            return self._log_chat_response(normalized, output, state)

        if lowered in {"批准命令", "批准命令", "approve"}:
            state = self.backend.approve()
            return self._log_chat_response(normalized, f"已批准。\n{self._format_state(state)}", state)

        if lowered in {"拒绝命令", "拒絕命令", "reject"}:
            state = self.backend.reject_pending_command()
            return self._log_chat_response(normalized, f"已拒絕。\n{self._format_state(state)}", state)

        if lowered.startswith("切换到任务 ") or lowered.startswith("切換到任務 "):
            task_ref = canonical.split(" ", 1)[1].strip()
            state = self.backend.switch_task(task_ref)
            return self._log_chat_response(normalized, f"已切換到 {state.title}。", state)

        if lowered.startswith("查看 ") and (" 的状态" in canonical or " 的狀態" in canonical):
            task_ref = canonical[3:].rsplit(" 的", 1)[0].strip()
            state = self.backend.get_task_state(task_ref)
            active = self.backend.get_state()
            return self._log_chat_response(normalized, self._format_state(state), active)

        if lowered.startswith("/status "):
            task_ref = canonical.split(" ", 1)[1].strip()
            state = self.backend.get_task_state(task_ref)
            active = self.backend.get_state()
            return self._log_chat_response(normalized, self._format_state(state), active)

        if lowered.startswith("继续 ") and lowered.endswith(" 任务"):
            task_ref = canonical[3:-3].strip()
            state = self.backend.switch_task(task_ref)
            state = self.backend.continue_task()
            return self._log_chat_response(normalized, f"已繼續 {state.title}。", state)

        if lowered in {"继续任务", "繼續任務"}:
            state = self.backend.continue_task()
            return self._log_chat_response(normalized, f"繼續執行中。\n{self._format_state(state)}", state)

        normalized_command = self._normalize_spoken_command(canonical)
        if normalized_command:
            state = self.backend.run_command(normalized_command)
            return self._log_chat_response(normalized, f"已按口語命令處理：{normalized_command}", state)

        if lowered.startswith("运行 ") or lowered.startswith("執行 ") or lowered.startswith("执行 "):
            command = canonical.split(" ", 1)[1].strip()
            state = self.backend.run_command(command)
            return self._log_chat_response(normalized, f"命令已入隊：{command}", state)

        if lowered.startswith("cmd:"):
            command = canonical[4:].strip()
            state = self.backend.run_command(command)
            return self._log_chat_response(normalized, f"命令已入隊：{command}", state)

        if self._should_use_model_intent_parser(canonical):
            planned = self._execute_model_intent_plan(canonical)
            if planned is not None:
                return planned

        state = self.backend.add_note(normalized, author="mobile")
        return self._log_chat_response(normalized, f"已記錄備註。\n最新狀態：{state.latest_update}", state, log_user=False)

    def _log_chat_response(self, user_text: str, reply: str, state: TaskState, log_user: bool = True) -> ChatResponse:
        if log_user:
            state = self.backend.append_chat_entry("mobile", user_text)
        state = self.backend.append_chat_entry("assistant", reply)
        return ChatResponse(reply, state.model_dump(mode="json"), state.task_id)

    def _format_state(self, state: TaskState) -> str:
        pending_items = sum(1 for note in state.notes if note.processed_at is None)
        return "\n".join(
            [
                f"任务：{state.title}",
                f"任务ID：{state.task_id}",
                f"状态：{state.status.value}",
                f"最新：{state.latest_update}",
                f"工作目录：{state.workdir or '-'}",
                f"待处理：{pending_items}",
                f"最近命令：{state.last_command or '-'}",
            ]
        )

    def _canonicalize_chinese_text(self, text: str) -> str:
        normalized = text.strip()
        replacements = {
            "任務": "任务",
            "狀態": "状态",
            "進度": "进度",
            "歷史": "历史",
            "備註": "备注",
            "隊列": "队列",
            "輸出": "输出",
            "執行": "执行",
            "繼續": "继续",
            "切換": "切换",
            "查看任務列表": "查看任务列表",
            "看看任務狀態": "看看任务状态",
        }
        for source, target in replacements.items():
            normalized = normalized.replace(source, target)
        return normalized

    def _normalize_spoken_command(self, text: str) -> str | None:
        normalized = text.strip()
        lowered = normalized.lower()
        exact_map = {
            "查看当前路径": "pwd",
            "显示当前路径": "pwd",
            "当前路径": "pwd",
            "查看目录": "ls -la",
            "列出目录": "ls -la",
            "看看目录": "ls -la",
            "运行测试": "pytest",
            "跑测试": "pytest",
            "执行测试": "pytest",
        }
        if normalized in exact_map:
            return exact_map[normalized]
        prefix_map = {
            "查看文件 ": "cat ",
            "打开文件 ": "cat ",
            "搜索 ": "rg ",
            "查找 ": "rg ",
        }
        for prefix, command_prefix in prefix_map.items():
            if normalized.startswith(prefix):
                remainder = normalized[len(prefix):].strip()
                if remainder:
                    return f"{command_prefix}{remainder}"
        if lowered in {"pwd", "ls", "ls -la", "pytest"}:
            return lowered
        return None

    def _should_use_model_intent_parser(self, text: str) -> bool:
        markers = ("如果", "然后", "並且", "并且", "同時", "同时", "顺便", "先", "再")
        return len(text) >= 12 and any(marker in text for marker in markers)

    def _execute_model_intent_plan(self, text: str) -> ChatResponse | None:
        try:
            plan = self.intent_parser.parse(self.backend.get_state(), text)
        except Exception:
            logger.exception("App API intent parsing failed")
            return None
        if not plan or not plan.steps:
            return None

        summaries: list[str] = [f"我把你的请求拆成了 {len(plan.steps)} 步："]
        for index, step in enumerate(plan.steps, start=1):
            result = self._execute_intent_step(step.action, step.text)
            summaries.append(f"{index}. {result}")
        state = self.backend.get_state()
        summaries.append(f"摘要：{plan.summary}")
        return ChatResponse("\n".join(summaries), state.model_dump(mode="json"), state.task_id)

    def _execute_intent_step(self, action: str, text: str | None) -> str:
        if action == "status":
            state = self.backend.get_state()
            return f"查看了当前状态：{state.title} / {state.status.value}"
        if action == "tasks":
            return "查看了任务列表"
        if action == "queue":
            return "查看了当前队列"
        if action == "progress":
            return "查看了当前进度"
        if action == "history":
            return "查看了最近历史"
        if action == "tail":
            return "查看了最近输出"
        if action == "pending":
            return "查看了待审批信息"
        if action == "approve":
            self.backend.approve()
            return "批准了待执行命令"
        if action == "reject":
            self.backend.reject_pending_command()
            return "拒绝了待执行命令"
        if action == "pause":
            self.backend.pause_task()
            return "暂停了当前任务"
        if action == "continue":
            self.backend.continue_task()
            return "继续了当前任务"
        if action == "create_task" and text:
            state = self.backend.create_task(text)
            return f"创建了任务：{state.title}"
        if action == "switch_task" and text:
            state = self.backend.switch_task(text)
            return f"切换到任务：{state.title}"
        if action == "set_title" and text:
            state = self.backend.set_title(text)
            return f"更新了任务标题：{state.title}"
        if action == "set_workdir" and text:
            state = self.backend.set_workdir(text)
            return f"更新了工作目录：{state.workdir}"
        if action == "note" and text:
            self.backend.add_note(text)
            return f"记录了备注：{text}"
        if text:
            self.backend.add_note(text)
            return f"保守记录备注：{text}"
        return f"忽略未知动作：{action}"

    def _authorize(self) -> bool:
        authorization = self.headers.get("Authorization", "")
        expected = f"Bearer {self.token}"
        self.auth_device = None
        self.backend = self.default_backend

        if authorization == expected:
            if self.relay_registry and self.settings.codexbot_mac_service_url and self.settings.codexbot_mac_service_token:
                device = self.relay_registry.authenticate(self.token)
                if device:
                    self.auth_device = device
                    self.backend = build_mac_backend(device, self.settings.codexbot_mac_service_timeout_seconds)
            return True

        if authorization.startswith("Bearer ") and self.relay_registry:
            token = authorization.removeprefix("Bearer ").strip()
            device = self.relay_registry.authenticate(token)
            if device:
                self.auth_device = device
                self.backend = build_mac_backend(device, self.settings.codexbot_mac_service_timeout_seconds)
                return True

        self._send_json({"error": "Unauthorized"}, status=HTTPStatus.UNAUTHORIZED)
        return False

    def _relay_identity_payload(self) -> dict | None:
        if self.auth_device:
            device = self.auth_device
            return {
                "mode": "multi_user",
                "user": {
                    "user_id": device.account.user_id,
                    "email": device.account.email,
                    "display_name": device.account.display_name,
                    "plan": device.account.plan,
                    "status": device.account.status,
                    "expires_at": device.account.expires_at,
                },
                "device": {
                    "device_id": device.device_id,
                    "name": device.name,
                    "mac_service_url": device.mac_service_url,
                    "tunnel_port": device.tunnel_port,
                    "status": device.status,
                },
            }
        if self.relay_registry:
            return {
                "mode": "single_owner_fallback",
                "user": {"user_id": "owner", "plan": "admin", "status": "active"},
                "device": {
                    "device_id": "owner-mac",
                    "mac_service_url": self.settings.codexbot_mac_service_url,
                    "tunnel_port": 18765,
                    "status": "active",
                },
            }
        return None

    def _read_json(self) -> dict:
        length = int(self.headers.get("Content-Length", "0") or "0")
        if length <= 0:
            return {}
        raw = self.rfile.read(length)
        return json.loads(raw.decode("utf-8"))

    def _send_json(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
        encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
        self.send_header("Pragma", "no-cache")
        self.send_header("Expires", "0")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        try:
            self.wfile.write(encoded)
        except (BrokenPipeError, ConnectionResetError, socket.timeout):
            logger.debug("Client disconnected before API response could be written")

    def do_OPTIONS(self) -> None:  # noqa: N802
        self.send_response(HTTPStatus.NO_CONTENT)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.end_headers()

    def log_message(self, format: str, *args) -> None:  # noqa: A003
        logger.info("app_api " + format, *args)


def main() -> None:
    settings = Settings()
    logging.basicConfig(
        level=getattr(logging, settings.codexbot_log_level.upper(), logging.INFO),
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
    )
    token = settings.codexbot_app_api_token
    if not token:
        raise RuntimeError("CODEXMOBILE_APP_API_TOKEN is required for CodexMobile API")

    backend = build_backend(settings)
    relay_registry = RelayRegistry(settings.codexbot_relay_db_path) if settings.codexbot_relay_db_path else None
    if relay_registry and settings.codexbot_app_api_token and settings.codexbot_mac_service_url and settings.codexbot_mac_service_token:
        relay_registry.ensure_owner_device(
            token=settings.codexbot_app_api_token,
            mac_service_url=settings.codexbot_mac_service_url,
            mac_service_token=settings.codexbot_mac_service_token,
            email=settings.codexbot_relay_owner_email,
            display_name="CodexMobile Owner",
            device_name="Owner Mac",
            tunnel_port=18765,
        )
    transcriber = build_transcriber(settings)
    intent_parser = build_intent_parser(settings)
    handler = type(
        "BoundAppApiHandler",
        (AppApiHandler,),
        {
            "backend": backend,
            "default_backend": backend,
            "intent_parser": intent_parser,
            "relay_registry": relay_registry,
            "settings": settings,
            "transcriber": transcriber,
            "token": token,
        },
    )
    server = ThreadingHTTPServer((settings.codexbot_app_api_host, settings.codexbot_app_api_port), handler)
    logger.info(
        "CodexMobile API listening on %s:%s",
        settings.codexbot_app_api_host,
        settings.codexbot_app_api_port,
    )
    try:
        server.serve_forever()
    finally:
        if isinstance(backend, WorkerController):
            backend.shutdown()
        server.server_close()


if __name__ == "__main__":
    main()
