#!/usr/bin/env python3
"""
PPT Master - Template and Strategist confirmation UI Server (Steps 3-4)

Lightweight Flask backend for the Default template choice and interactive
Strategist confirmation page. Stage 1 combines template selection with the
communication contract; its submit writes ``template_selection.json`` and the
stage1-confirmed ``result.json`` in one request. After the agent applies the
choice and completes ``template_handoff.json``, final Stage 2 confirms the deck
solution and production plan.

This is the default confirmation surface. The chat fallback is used only when
the user explicitly requests chat-only confirmation or the browser launch
fails; it preserves the same staged semantics.

See scripts/docs/confirm_ui.md for the round-trip data contract and schema.

Usage:
    python3 scripts/confirm_ui/server.py <project_dir>

Examples:
    python3 scripts/confirm_ui/server.py projects/my-project
    python3 scripts/confirm_ui/server.py projects/my-project --port 5051
    python3 scripts/confirm_ui/server.py projects/my-project --no-browser
    python3 scripts/confirm_ui/server.py projects/my-project --daemon
    python3 scripts/confirm_ui/server.py projects/my-project --wait-only --wait-stage stage1
    python3 scripts/confirm_ui/server.py projects/my-project --complete-template-selection
    python3 scripts/confirm_ui/server.py projects/my-project --reset-template-selection

Dependencies:
    flask>=3.0.0
"""

import argparse
import atexit
import hashlib
import json
import logging
import os
import re
import signal
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path
from typing import Optional

from flask import Flask, jsonify, request, send_from_directory

# Local — sys.path injection for sibling module (code-style.md §3)
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from console_encoding import configure_utf8_stdio  # noqa: E402
from language_tags import (  # noqa: E402
    LanguageTagError,
    language_base,
    normalize_language_tag,
)
from server_common import (  # noqa: E402
    claim_lock as _claim_lock,
    clear_lock as _clear_lock,
    find_free_port as _find_free_port,
    lock_pid as _lock_pid,
    popen_detached as _popen_detached,
    process_alive as _process_alive,
    read_lock as _read_lock,
    release_lock as _release_lock,
    validate_port as _validate_port,
)

configure_utf8_stdio()

logger = logging.getLogger('confirm_ui')

# Per-project lock file. Lives at <project_path>/.confirm_ui.lock and matches
# the *.lock entry already in the repo .gitignore. Independent of the live
# preview lock so the two surfaces never collide.
LOCK_FILE_NAME = '.confirm_ui.lock'

# Round-trip/session files, all under <project_path>/confirm_ui/.
CONFIRM_DIR_NAME = 'confirm_ui'
RECOMMENDATION_STAGE_NAMES = {
    1: 'recommendations.stage1.json',
    2: 'recommendations.stage2.json',
}
RESULT_NAME = 'result.json'
SESSION_NAME = 'session.json'
TEMPLATE_OPTIONS_NAME = 'template_options.json'
TEMPLATE_SELECTION_NAME = 'template_selection.json'
TEMPLATE_HANDOFF_NAME = 'template_handoff.json'
TEMPLATE_SCHEMA_VERSION = 1

_PALETTE_ROLES = (
    'background',
    'secondary_bg',
    'primary',
    'accent',
    'secondary_accent',
    'body_text',
)
_TYPOGRAPHY_SIZE_ROLES = ('title', 'subtitle', 'annotation')
_HEX_COLOR_RE = re.compile(r'#?(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\Z')

# Static option universe served at /api/catalogs (canvas synced live from config).
_SKILL_DIR = Path(__file__).resolve().parents[2]
_TEMPLATES_DIR = _SKILL_DIR / 'templates'
_CATALOGS_PATH = Path(__file__).resolve().parent / 'static' / 'catalogs.json'
_ICON_LIBRARY_DIR = _TEMPLATES_DIR / 'icons'
_AI_IMAGE_COMPARISON_DIR = _SKILL_DIR / 'references' / 'ai-image-comparison'
_TEMPLATE_LIBRARY_CONFIG = {
    'brand': ('brands', 'brands_index.json'),
    'style': ('styles', 'styles_index.json'),
    'layout': ('layouts', 'layouts_index.json'),
    'deck': ('decks', 'decks_index.json'),
}
_TEMPLATE_KIND_LINE_RE = re.compile(
    r'''kind\s*:\s*(?:'([^']*)'|"([^"]*)"|([A-Za-z][A-Za-z0-9_-]*))'''
    r'''\s*(?:#.*)?\Z'''
)
_ICON_PREVIEW_SAMPLES = {
    'chunk-filled': ('home', 'chart-line', 'users', 'target'),
    'tabler-filled': ('home', 'chart-dots', 'user', 'bulb'),
    'tabler-outline': ('home', 'chart-line', 'users', 'bulb'),
    'phosphor-duotone': ('house', 'chart-line', 'users', 'target'),
}

# Prefer the same memorable entry port as live preview. Normal single-project
# execution releases it between Step 4 and Step 6; concurrent projects advance
# from this base while explicit ``--port`` remains exact.
DEFAULT_PORT = 5050
PUBLIC_HOST = '127.0.0.1'
STARTUP_TIMEOUT = 10

# Default --wait budget, kept just under the 600s Bash-tool ceiling so the
# parent (waiting) command returns before the calling harness kills it. The
# detached child server keeps running on its own --timeout idle budget, so a
# slow user can still confirm after the wait returns; the caller re-checks
# result.json before falling back to chat.
WAIT_TIMEOUT_DEFAULT = 590

def _read_json_object(path: Path, retries: int = 2, delay: float = 0.08) -> dict:
    """Read a JSON object, retrying briefly around non-atomic external writes."""
    last_error: Exception = ValueError('unknown JSON read error')
    for attempt in range(retries + 1):
        try:
            data = json.loads(path.read_text(encoding='utf-8-sig'))
            if isinstance(data, dict):
                return data
            raise ValueError(f'{path} top-level JSON value must be an object')
        except (OSError, json.JSONDecodeError, ValueError) as exc:
            last_error = exc
            if attempt < retries:
                time.sleep(delay)
                continue
            raise last_error
    raise last_error


def _write_json_atomic(path: Path, data: dict) -> None:
    """Write a JSON object with replace semantics so waiters never see a partial file."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp')
    try:
        tmp.write_text(
            json.dumps(data, ensure_ascii=False, indent=2),
            encoding='utf-8',
        )
        os.replace(tmp, path)
    finally:
        try:
            tmp.unlink(missing_ok=True)
        except OSError:
            pass


def _json_sha256(data: object) -> str:
    """Return a stable SHA-256 over one JSON-compatible value."""
    canonical = json.dumps(
        data,
        ensure_ascii=False,
        sort_keys=True,
        separators=(',', ':'),
    )
    return hashlib.sha256(canonical.encode('utf-8')).hexdigest()


def _safe_template_id(template_id: object) -> bool:
    """Return whether an index key is one safe directory-segment id."""
    return (
        isinstance(template_id, str)
        and re.fullmatch(r'\w[\w.-]*', template_id) is not None
    )


def _template_design_spec_path(workspace_root: Path) -> Path:
    """Return the current or legacy Design Spec for one workspace root."""
    current = workspace_root / 'templates' / 'design_spec.md'
    if current.is_file():
        return current
    legacy = workspace_root / 'design_spec.md'
    if legacy.is_file():
        return legacy
    raise ValueError(
        'template workspace is missing templates/design_spec.md '
        f'or legacy design_spec.md: {workspace_root}'
    )


def _template_kind_from_spec(spec_path: Path) -> str:
    """Read one supported top-level ``kind`` from Design Spec frontmatter."""
    try:
        lines = spec_path.read_text(encoding='utf-8-sig').splitlines()
    except OSError as exc:
        raise ValueError(f'cannot read template Design Spec {spec_path}: {exc}') from exc
    if not lines or lines[0] != '---':
        raise ValueError(f'{spec_path} must start with YAML frontmatter')
    try:
        frontmatter_end = lines.index('---', 1)
    except ValueError as exc:
        raise ValueError(f'{spec_path} has unterminated YAML frontmatter') from exc

    declared_kind = None
    for line_number, line in enumerate(lines[1:frontmatter_end], start=2):
        if re.match(r'^\s*kind\s*:', line) is None:
            continue
        if line != line.lstrip():
            raise ValueError(
                f'{spec_path}:{line_number} kind must be a top-level frontmatter field'
            )
        match = _TEMPLATE_KIND_LINE_RE.fullmatch(line)
        if match is None:
            raise ValueError(
                f'{spec_path}:{line_number} has an invalid kind declaration'
            )
        if declared_kind is not None:
            raise ValueError(f'{spec_path} frontmatter declares kind more than once')
        declared_kind = next(value for value in match.groups() if value is not None)

    if declared_kind is None:
        raise ValueError(f'{spec_path} frontmatter must declare kind')
    if declared_kind not in _TEMPLATE_LIBRARY_CONFIG:
        supported = ', '.join(_TEMPLATE_LIBRARY_CONFIG)
        raise ValueError(
            f'{spec_path} frontmatter kind must be one of {supported}; '
            f'got {declared_kind!r}'
        )
    return declared_kind


def _read_template_options_input(confirm_dir: Path) -> tuple[dict, list[Path]]:
    """Read and validate the agent-authored Step-3 template input."""
    options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
    data = _read_json_object(options_file)
    if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
        raise ValueError(
            f'{TEMPLATE_OPTIONS_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
        )
    if data.get('phase') != 'template':
        raise ValueError(f'{TEMPLATE_OPTIONS_NAME} phase must be template')
    if data.get('default_mode') not in {'free_design', 'templates'}:
        raise ValueError(
            f'{TEMPLATE_OPTIONS_NAME} default_mode must be free_design or templates'
        )
    if 'lang' in data and (
        not isinstance(data['lang'], str) or not data['lang'].strip()
    ):
        raise ValueError(f'{TEMPLATE_OPTIONS_NAME} lang must be a non-empty string')
    if 'explicit_workspace_roots' not in data:
        raise ValueError(
            f'{TEMPLATE_OPTIONS_NAME} must include explicit_workspace_roots'
        )
    raw_roots = data['explicit_workspace_roots']
    if not isinstance(raw_roots, list):
        raise ValueError(
            f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots must be an array'
        )
    if raw_roots and data['default_mode'] != 'templates':
        raise ValueError(
            f'{TEMPLATE_OPTIONS_NAME} default_mode must be templates when '
            'explicit_workspace_roots is non-empty'
        )

    roots = []
    seen = set()
    for index, raw_root in enumerate(raw_roots):
        if not isinstance(raw_root, str) or not raw_root.strip():
            raise ValueError(
                f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
                'must be a non-empty string'
            )
        candidate = Path(raw_root)
        if not candidate.is_absolute():
            raise ValueError(
                f'{TEMPLATE_OPTIONS_NAME} explicit_workspace_roots[{index}] '
                'must be an absolute path'
            )
        try:
            root = candidate.resolve()
        except (OSError, RuntimeError) as exc:
            raise ValueError(
                f'cannot resolve explicit workspace root {raw_root}: {exc}'
            ) from exc
        canonical = str(root)
        if canonical in seen:
            raise ValueError(
                f'{TEMPLATE_OPTIONS_NAME} contains duplicate workspace root: {canonical}'
            )
        if not root.is_dir():
            raise ValueError(f'explicit workspace root is not a directory: {canonical}')
        _template_design_spec_path(root)
        seen.add(canonical)
        roots.append(root)
    return data, roots


def _build_template_library() -> tuple[dict, dict[str, dict], dict[str, dict], dict]:
    """Build indexed library groups without scanning template directories."""
    library = {}
    candidates = {}
    registered_roots = {}
    index_contracts = {}
    for kind, (directory_name, index_name) in _TEMPLATE_LIBRARY_CONFIG.items():
        kind_dir = (_TEMPLATES_DIR / directory_name).resolve()
        index_path = kind_dir / index_name
        index_data = _read_json_object(index_path)
        index_contracts[kind] = index_data
        group = []
        for template_id, metadata in index_data.items():
            if not _safe_template_id(template_id):
                raise ValueError(
                    f'{index_path} contains unsafe template id: {template_id!r}'
                )
            if not isinstance(metadata, dict):
                raise ValueError(
                    f'{index_path} entry {template_id!r} must be an object'
                )
            workspace_root = (kind_dir / template_id).resolve()
            if workspace_root.parent != kind_dir:
                raise ValueError(
                    f'{index_path} entry {template_id!r} does not resolve to a '
                    f'direct child of {kind_dir}'
                )
            if not workspace_root.is_dir():
                raise ValueError(
                    f'{index_path} entry {template_id!r} workspace does not exist: '
                    f'{workspace_root}'
                )
            spec_path = workspace_root / 'templates' / 'design_spec.md'
            if not spec_path.is_file():
                raise ValueError(
                    f'{index_path} entry {template_id!r} is missing {spec_path}'
                )
            declared_kind = _template_kind_from_spec(spec_path)
            if declared_kind != kind:
                raise ValueError(
                    f'{index_path} entry {template_id!r} declares kind '
                    f'{declared_kind!r}, expected {kind!r}'
                )
            summary = metadata.get('summary', '')
            if not isinstance(summary, str):
                raise ValueError(
                    f'{index_path} entry {template_id!r} summary must be a string'
                )
            key = f'library:{kind}:{template_id}'
            if key in candidates:
                raise ValueError(f'duplicate template candidate key: {key}')
            candidate = {
                'key': key,
                'source': 'library',
                'kind': kind,
                'id': template_id,
                'label': template_id,
                'summary': summary,
                'workspace_root': str(workspace_root),
            }
            canonical_root = candidate['workspace_root']
            if canonical_root in registered_roots:
                raise ValueError(
                    f'duplicate registered workspace root: {canonical_root}'
                )
            group.append(candidate)
            candidates[key] = candidate
            registered_roots[canonical_root] = candidate
        library[kind] = group
    return library, candidates, registered_roots, index_contracts


def _build_template_options(confirm_dir: Path) -> tuple[dict, dict[str, dict]]:
    """Return the browser contract and its server-owned candidate whitelist."""
    source, explicit_roots = _read_template_options_input(confirm_dir)
    library, candidates, registered_roots, index_contracts = _build_template_library()
    explicit = []
    suggested_keys = []
    for root in explicit_roots:
        canonical_root = str(root)
        registered = registered_roots.get(canonical_root)
        if registered is not None:
            suggested_keys.append(registered['key'])
            continue
        digest = hashlib.sha256(canonical_root.encode('utf-8')).hexdigest()
        key = f'explicit:{digest}'
        if key in candidates:
            raise ValueError(f'duplicate template candidate key: {key}')
        kind = _template_kind_from_spec(_template_design_spec_path(root))
        candidate = {
            'key': key,
            'source': 'explicit',
            'kind': kind,
            'label': root.name or canonical_root,
            'workspace_root': canonical_root,
        }
        explicit.append(candidate)
        candidates[key] = candidate
        suggested_keys.append(key)

    # One supplied exact root is an unambiguous convenience default. Multiple
    # roots are candidates for the single-select controls, not an instruction
    # to select all of them.
    preselected_keys = suggested_keys if len(suggested_keys) == 1 else []

    response = {
        'schema_version': TEMPLATE_SCHEMA_VERSION,
        'phase': 'template',
        'default_mode': source['default_mode'],
        'library': library,
        'explicit': explicit,
        'preselected_keys': preselected_keys,
    }
    if 'lang' in source:
        response['lang'] = source['lang'].strip()
    response['options_sha256'] = _json_sha256({
        'schema_version': TEMPLATE_SCHEMA_VERSION,
        'phase': 'template',
        'default_mode': response['default_mode'],
        'lang': response.get('lang'),
        'explicit_workspace_roots': [str(root) for root in explicit_roots],
        'library_indexes': index_contracts,
        'library': library,
        'explicit': explicit,
        'preselected_keys': preselected_keys,
    })
    return response, candidates


def _template_selection_from_candidate(candidate: dict) -> dict:
    """Project one trusted browser candidate into the persisted selection."""
    selection = {
        'source': candidate['source'],
        'kind': candidate['kind'],
    }
    if candidate['source'] == 'library':
        selection['id'] = candidate['id']
    selection['workspace_root'] = candidate['workspace_root']
    return selection


def _template_selection_sha256(
    mode: str,
    selections: list[dict],
    options_sha256: str,
) -> str:
    """Bind one resolved choice to the candidate/options contract it used."""
    return _json_sha256({
        'mode': mode,
        'selections': selections,
        'options_sha256': options_sha256,
    })


def _validate_template_selection(data: dict) -> None:
    """Validate the server-owned template-selection receipt shape."""
    expected_fields = {
        'schema_version',
        'phase',
        'status',
        'mode',
        'selections',
        'options_sha256',
        'selection_sha256',
        'confirmed_at',
    }
    if set(data) != expected_fields:
        raise ValueError(f'{TEMPLATE_SELECTION_NAME} has invalid fields')
    if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
        raise ValueError(
            f'{TEMPLATE_SELECTION_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
        )
    if data.get('phase') != 'template':
        raise ValueError(f'{TEMPLATE_SELECTION_NAME} phase must be template')
    if data.get('status') != 'confirmed':
        raise ValueError(f'{TEMPLATE_SELECTION_NAME} status must be confirmed')
    mode = data.get('mode')
    if mode not in {'free_design', 'templates'}:
        raise ValueError(
            f'{TEMPLATE_SELECTION_NAME} mode must be free_design or templates'
        )
    selections = data.get('selections')
    if not isinstance(selections, list):
        raise ValueError(f'{TEMPLATE_SELECTION_NAME} selections must be an array')
    if mode == 'free_design' and selections:
        raise ValueError('free_design cannot carry template selections')
    if mode == 'templates' and not selections:
        raise ValueError('templates mode requires at least one selection')

    options_sha256 = data.get('options_sha256')
    selection_sha256 = data.get('selection_sha256')
    if not isinstance(options_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', options_sha256):
        raise ValueError(f'{TEMPLATE_SELECTION_NAME} options_sha256 is invalid')
    if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256):
        raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 is invalid')

    seen_roots = set()
    seen_library_kinds = set()
    explicit_count = 0
    for index, selection in enumerate(selections):
        if not isinstance(selection, dict):
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} selections[{index}] must be an object'
            )
        source = selection.get('source')
        if source not in {'library', 'explicit'}:
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid source'
            )
        kind = selection.get('kind')
        if kind not in _TEMPLATE_LIBRARY_CONFIG:
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid kind'
            )
        expected_keys = {'source', 'kind', 'workspace_root'}
        if source == 'library':
            expected_keys.add('id')
            if kind in seen_library_kinds:
                raise ValueError(
                    'template selection allows at most one library workspace '
                    f'for kind {kind!r}'
                )
            seen_library_kinds.add(kind)
            if not _safe_template_id(selection.get('id')):
                raise ValueError(
                    f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid id'
                )
        else:
            explicit_count += 1
            if explicit_count > 1:
                raise ValueError(
                    'template selection allows at most one explicit workspace'
                )
        if set(selection) != expected_keys:
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} selections[{index}] has invalid fields'
            )
        workspace_root = selection.get('workspace_root')
        if not isinstance(workspace_root, str) or not workspace_root:
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
                'workspace_root must be a non-empty string'
            )
        root = Path(workspace_root)
        if not root.is_absolute() or str(root.resolve()) != workspace_root:
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} selections[{index}] '
                'workspace_root must be a canonical absolute path'
            )
        if workspace_root in seen_roots:
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} contains duplicate workspace root: '
                f'{workspace_root}'
            )
        seen_roots.add(workspace_root)
    if not isinstance(data.get('confirmed_at'), str) or not data['confirmed_at']:
        raise ValueError(
            f'{TEMPLATE_SELECTION_NAME} confirmed_at must be a non-empty string'
        )
    expected_selection_sha256 = _template_selection_sha256(
        mode,
        selections,
        options_sha256,
    )
    if selection_sha256 != expected_selection_sha256:
        raise ValueError(f'{TEMPLATE_SELECTION_NAME} selection_sha256 does not match')


def _read_template_selection(selection_file: Path) -> dict:
    """Read a selection and revalidate it against current indexed options."""
    data = _read_json_object(selection_file)
    _validate_template_selection(data)
    options_file = selection_file.parent / TEMPLATE_OPTIONS_NAME
    if not _is_newer(selection_file, options_file):
        raise ValueError(
            f'{TEMPLATE_SELECTION_NAME} must be confirmed after the current '
            f'{TEMPLATE_OPTIONS_NAME}'
        )
    options, candidates = _build_template_options(selection_file.parent)
    if data['options_sha256'] != options['options_sha256']:
        raise ValueError(
            f'{TEMPLATE_SELECTION_NAME} options_sha256 no longer matches current options'
        )
    available_selections = {
        _json_sha256(_template_selection_from_candidate(candidate))
        for candidate in candidates.values()
    }
    for selection in data['selections']:
        if _json_sha256(selection) not in available_selections:
            raise ValueError(
                f'{TEMPLATE_SELECTION_NAME} references an unavailable candidate'
            )
    return data


def _validate_template_handoff(data: dict) -> None:
    """Validate the agent-owned Step-3 completion receipt shape."""
    expected_fields = {
        'schema_version',
        'phase',
        'status',
        'mode',
        'selection_sha256',
        'completed_at',
    }
    if set(data) != expected_fields:
        raise ValueError(f'{TEMPLATE_HANDOFF_NAME} has invalid fields')
    if type(data.get('schema_version')) is not int or data['schema_version'] != TEMPLATE_SCHEMA_VERSION:
        raise ValueError(
            f'{TEMPLATE_HANDOFF_NAME} schema_version must be {TEMPLATE_SCHEMA_VERSION}'
        )
    if data.get('phase') != 'template':
        raise ValueError(f'{TEMPLATE_HANDOFF_NAME} phase must be template')
    if data.get('status') != 'ready':
        raise ValueError(f'{TEMPLATE_HANDOFF_NAME} status must be ready')
    if data.get('mode') not in {'free_design', 'templates'}:
        raise ValueError(
            f'{TEMPLATE_HANDOFF_NAME} mode must be free_design or templates'
        )
    selection_sha256 = data.get('selection_sha256')
    if not isinstance(selection_sha256, str) or not re.fullmatch(r'[0-9a-f]{64}', selection_sha256):
        raise ValueError(f'{TEMPLATE_HANDOFF_NAME} selection_sha256 is invalid')
    if not isinstance(data.get('completed_at'), str) or not data['completed_at']:
        raise ValueError(
            f'{TEMPLATE_HANDOFF_NAME} completed_at must be a non-empty string'
        )


def _read_template_handoff(project_path: Path, handoff_file: Path) -> dict:
    """Read a handoff and bind it to the current selection and installed state."""
    data = _read_json_object(handoff_file)
    _validate_template_handoff(data)
    selection_file = handoff_file.parent / TEMPLATE_SELECTION_NAME
    selection = _read_template_selection(selection_file)
    if not _is_newer(handoff_file, selection_file):
        raise ValueError(
            f'{TEMPLATE_HANDOFF_NAME} must be completed after the current '
            f'{TEMPLATE_SELECTION_NAME}'
        )
    if data['mode'] != selection['mode']:
        raise ValueError(f'{TEMPLATE_HANDOFF_NAME} mode does not match selection')
    if data['selection_sha256'] != selection['selection_sha256']:
        raise ValueError(
            f'{TEMPLATE_HANDOFF_NAME} selection_sha256 does not match selection'
        )
    if data['mode'] == 'templates':
        installed_spec = project_path / 'templates' / 'design_spec.md'
        if not installed_spec.is_file():
            raise ValueError(
                f'{TEMPLATE_HANDOFF_NAME} requires installed template spec: '
                f'{installed_spec}'
            )
    return data


def _complete_template_selection(project_path: Path) -> int:
    """Write the agent-owned handoff after Stage 1 and template application."""
    confirm_dir = project_path / CONFIRM_DIR_NAME
    selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
    if not selection_file.exists():
        logger.error('%s not found — the user must confirm Stage 1 first', selection_file)
        return 1
    try:
        selection = _read_template_selection(selection_file)
    except (OSError, json.JSONDecodeError, ValueError) as exc:
        logger.error('cannot complete template selection: %s', exc)
        return 1

    result_file = confirm_dir / RESULT_NAME
    if _result_stage(result_file) != 'stage1':
        logger.error(
            'cannot complete template selection before Stage 1 writes a '
            'stage1-confirmed result'
        )
        return 1
    if selection['mode'] == 'templates':
        installed_spec = project_path / 'templates' / 'design_spec.md'
        if not installed_spec.is_file():
            logger.error(
                'cannot complete template selection before template apply writes %s',
                installed_spec,
            )
            return 1

    handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
    if handoff_file.exists():
        try:
            existing = _read_template_handoff(project_path, handoff_file)
        except (OSError, json.JSONDecodeError, ValueError):
            existing = None
        if (
            existing is not None
            and existing['selection_sha256'] == selection['selection_sha256']
            and _is_newer(handoff_file, result_file)
        ):
            logger.info('template selection already complete: %s', handoff_file)
            return 0

    handoff = {
        'schema_version': TEMPLATE_SCHEMA_VERSION,
        'phase': 'template',
        'status': 'ready',
        'mode': selection['mode'],
        'selection_sha256': selection['selection_sha256'],
        'completed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
    }
    _validate_template_handoff(handoff)
    _write_json_atomic(handoff_file, handoff)
    logger.info('template selection handoff written to %s', handoff_file)
    return 0


def _reset_template_selection(confirm_dir: Path) -> int:
    """Remove one-run template-selection artifacts before a fresh UI lifecycle."""
    removed = []
    for filename in (
        TEMPLATE_HANDOFF_NAME,
        TEMPLATE_SELECTION_NAME,
        TEMPLATE_OPTIONS_NAME,
    ):
        path = confirm_dir / filename
        try:
            path.unlink()
            removed.append(str(path))
        except FileNotFoundError:
            continue
        except OSError as exc:
            logger.error('cannot remove %s: %s', path, exc)
            return 1
    if removed:
        logger.info('reset template selection artifacts: %s', ', '.join(removed))
    else:
        logger.info('template selection artifacts already absent')
    return 0


def _stage1_ready_error(confirm_dir: Path) -> Optional[str]:
    """Return why the combined template/Stage-1 page cannot be exposed."""
    options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
    if not options_file.exists():
        return f'{TEMPLATE_OPTIONS_NAME} not found'
    try:
        _build_template_options(confirm_dir)
    except (OSError, json.JSONDecodeError, ValueError) as exc:
        return f'invalid template options: {exc}'

    stage1_file = confirm_dir / RECOMMENDATION_STAGE_NAMES[1]
    if not stage1_file.exists():
        return f'{stage1_file.name} not found'
    try:
        stage1_data = _read_json_object(stage1_file)
        if _recommendation_stage(stage1_data) != 1:
            return f'{stage1_file.name} does not declare stage1'
    except (OSError, json.JSONDecodeError, ValueError) as exc:
        return f'cannot validate Stage 1 recommendations: {exc}'

    result_file = confirm_dir / RESULT_NAME
    if result_file.exists():
        if not _is_newer(options_file, result_file):
            return f'{TEMPLATE_OPTIONS_NAME} must be newer than the prior {RESULT_NAME}'
        if not _is_newer(stage1_file, result_file):
            return f'{stage1_file.name} must be newer than the prior {RESULT_NAME}'
    return None


def _stage2_ready_error(
    project_path: Path,
    confirm_dir: Path,
    recommendations_file: Optional[Path] = None,
) -> Optional[str]:
    """Return why final Stage 2 cannot follow the confirmed template choice."""
    selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
    if not selection_file.exists():
        return f'{TEMPLATE_SELECTION_NAME} not found after Stage 1 confirmation'
    try:
        _read_template_selection(selection_file)
    except (OSError, json.JSONDecodeError, ValueError) as exc:
        return f'invalid template selection: {exc}'

    result_file = confirm_dir / RESULT_NAME
    if _result_stage(result_file) != 'stage1':
        return f'{RESULT_NAME} does not contain a stage1-confirmed result'

    handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
    if not handoff_file.exists():
        return f'{TEMPLATE_HANDOFF_NAME} not found after template application'
    try:
        _read_template_handoff(project_path, handoff_file)
    except (OSError, json.JSONDecodeError, ValueError) as exc:
        return f'invalid template handoff: {exc}'
    if not _is_newer(handoff_file, result_file):
        return f'{TEMPLATE_HANDOFF_NAME} must be newer than the Stage 1 {RESULT_NAME}'

    if recommendations_file is not None and not _is_newer(
        recommendations_file,
        handoff_file,
    ):
        return f'{recommendations_file.name} must be newer than {TEMPLATE_HANDOFF_NAME}'
    return None


def _resolve_template_confirmation(
    payload: dict,
    candidates: dict[str, dict],
    options_sha256: str,
) -> dict:
    """Resolve browser keys against the current server-owned candidate set."""
    required_fields = {'mode', 'selection_keys'}
    if set(payload) != required_fields:
        raise ValueError('template confirmation accepts only mode and selection_keys')
    mode = payload.get('mode')
    if mode not in {'free_design', 'templates'}:
        raise ValueError('mode must be free_design or templates')
    selection_keys = payload.get('selection_keys')
    if not isinstance(selection_keys, list):
        raise ValueError('selection_keys must be an array')
    if any(not isinstance(key, str) or not key for key in selection_keys):
        raise ValueError('selection_keys must contain non-empty strings')
    if len(selection_keys) != len(set(selection_keys)):
        raise ValueError('selection_keys must not contain duplicates')
    if mode == 'free_design' and selection_keys:
        raise ValueError('free_design cannot carry selection_keys')
    if mode == 'templates' and not selection_keys:
        raise ValueError('templates mode requires at least one selection key')

    unknown_keys = [key for key in selection_keys if key not in candidates]
    if unknown_keys:
        raise ValueError(
            'selection_keys contains unavailable candidate keys: '
            + ', '.join(unknown_keys)
        )
    selections = sorted([
        _template_selection_from_candidate(candidates[key])
        for key in selection_keys
    ], key=lambda item: (
        item['source'],
        item.get('kind', ''),
        item.get('id', ''),
        item['workspace_root'],
    ))
    selection_sha256 = _template_selection_sha256(
        mode,
        selections,
        options_sha256,
    )
    receipt = {
        'schema_version': TEMPLATE_SCHEMA_VERSION,
        'phase': 'template',
        'status': 'confirmed',
        'mode': mode,
        'selections': selections,
        'options_sha256': options_sha256,
        'selection_sha256': selection_sha256,
        'confirmed_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
    }
    _validate_template_selection(receipt)
    return receipt


def _confirmation_launch_error(confirm_dir: Path) -> Optional[str]:
    """Return why the current Default UI lifecycle cannot launch."""
    options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
    result_file = confirm_dir / RESULT_NAME
    result_stage = _result_stage(result_file)
    fresh_template_options = _fresh_template_restart(confirm_dir)
    if (
        result_file.exists()
        and result_stage is None
        and not fresh_template_options
    ):
        return (
            f'{RESULT_NAME} is not a current stage1/final receipt — reset the '
            f'template selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
            'starting a new run'
        )
    if (
        result_stage == 'final'
        and not fresh_template_options
    ):
        return (
            'the previous Confirm UI run is complete — reset the template '
            f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before starting a new one'
        )
    if options_file.exists():
        try:
            _build_template_options(confirm_dir)
            selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
            if result_stage == 'stage1' and not selection_file.exists():
                return (
                    f'{TEMPLATE_SELECTION_NAME} not found for the confirmed '
                    'Stage 1 result'
                )
            if selection_file.exists():
                _read_template_selection(selection_file)
        except (OSError, json.JSONDecodeError, ValueError) as exc:
            return f'invalid template selection input: {exc}'
        if result_stage is None or fresh_template_options:
            stage1_error = _stage1_ready_error(confirm_dir)
            if stage1_error:
                return f'Stage 1 is not ready: {stage1_error}'
        return None
    return f'{options_file} not found — write template options before launch'


def _server_url(port: int, path: str = '') -> str:
    """Return the loopback URL shown to users and used by readiness probes."""
    suffix = path if path.startswith('/') or not path else f'/{path}'
    return f'http://{PUBLIC_HOST}:{port}{suffix}'


def _wait_for_server_ready(
    port: int,
    proc: subprocess.Popen,
    project_path: Path,
    timeout: int = STARTUP_TIMEOUT,
) -> bool:
    """Wait until this project's detached confirm server is accepting requests."""
    deadline = time.time() + timeout
    last_error = ''
    health_url = _server_url(port, '/api/health')
    while time.time() < deadline:
        returncode = proc.poll()
        if returncode is not None:
            logger.error('confirm UI exited during startup (code=%s)', returncode)
            return False
        try:
            with urllib.request.urlopen(health_url, timeout=1) as resp:
                data = json.load(resp)
                if (
                    resp.status == 200
                    and isinstance(data, dict)
                    and data.get('service') == 'confirm_ui'
                    and data.get('project') == str(project_path)
                    and data.get('pid') == proc.pid
                ):
                    return True
                last_error = 'health response belongs to another service or project'
        except (OSError, ValueError, urllib.error.URLError) as exc:
            last_error = str(exc)
        time.sleep(0.2)
    logger.error(
        'confirm UI did not become ready at %s within %ss%s',
        health_url,
        timeout,
        f' (last error: {last_error})' if last_error else '',
    )
    return False


def _launch_background_server(
    project_path: Path,
    *,
    preferred_port: int,
    exact_port: bool,
    idle_timeout: int,
    open_browser: bool,
) -> tuple[subprocess.Popen, int, Path]:
    """Start the confirm server child and wait until it is reachable."""
    confirm_dir = project_path / CONFIRM_DIR_NAME
    confirm_dir.mkdir(parents=True, exist_ok=True)
    log_path = confirm_dir / 'server.log'
    port = preferred_port if exact_port else _find_free_port(preferred_port)
    cmd = [
        sys.executable,
        str(Path(__file__).resolve()),
        str(project_path),
        '--port',
        str(port),
        '--timeout',
        str(idle_timeout),
        '--no-browser',
    ]
    with log_path.open('a', encoding='utf-8') as log:
        proc = _popen_detached(
            cmd,
            stdout=log,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            logger=logger,
        )
    logger.info('log: %s', log_path)
    if not _wait_for_server_ready(port, proc, project_path):
        if proc.poll() is None:
            proc.terminate()
        raise RuntimeError(f'confirm UI failed to become reachable: {_server_url(port)}')
    _sync_session_state(confirm_dir, server_port=port, event='server-ready')
    url = _server_url(port)
    logger.info('started confirm UI in background: %s (pid=%s)', url, proc.pid)
    if open_browser:
        webbrowser.open(url)
    return proc, port, log_path


def _live_lock(lock_file: Path) -> Optional[dict]:
    """Return a live lock; stale entries are overwritten by the recovered child."""
    existing = _read_lock(lock_file)
    if not existing:
        return None
    if _process_alive(_lock_pid(existing)):
        return existing
    return None


def _preferred_recovery_port(lock_file: Path, fallback: int) -> int:
    """Prefer a stale lock's port so an already-open browser can reconnect."""
    existing = _read_lock(lock_file)
    try:
        port = int((existing or {}).get('port', 0) or 0)
        return _validate_port(port) if port else fallback
    except (TypeError, ValueError):
        return fallback


def _open_browser_async(url: str, delay: float = 0.4) -> None:
    """Open the browser after Flask has had a moment to bind its socket."""
    def _open() -> None:
        time.sleep(delay)
        webbrowser.open(url)

    threading.Thread(target=_open, daemon=True).start()


def _wait_for_result(
    result_file: Path,
    proc: subprocess.Popen,
    started_at: float,
    timeout: int,
    expected_stage: str,
) -> int:
    """Wait until this launch writes a fresh result file or the server exits."""
    logger.info('waiting for browser confirmation...')
    deadline = None if timeout <= 0 else time.time() + timeout
    while True:
        if result_file.exists():
            try:
                if result_file.stat().st_mtime >= started_at:
                    actual_stage = _result_stage(result_file)
                    if actual_stage != expected_stage:
                        logger.error(
                            'confirmation stage mismatch: expected %s, found %s',
                            expected_stage,
                            actual_stage or 'invalid/absent',
                        )
                        return 2
                    logger.info('confirmation received: %s', result_file)
                    try:
                        proc.wait(timeout=3)
                    except subprocess.TimeoutExpired:
                        pass
                    return 0
            except OSError:
                pass

        returncode = proc.poll()
        if returncode is not None:
            logger.error('confirm UI exited before a fresh result was written')
            return returncode or 1

        if deadline is not None and time.time() >= deadline:
            logger.error(
                'timed out waiting for browser confirmation — the page is still '
                'open; re-check %s before falling back to chat', result_file,
            )
            return 124

        time.sleep(0.5)


def _result_stage(result_file: Path) -> Optional[str]:
    """Return the current result stage (Stage 1 or final), or None."""
    if not result_file.is_file():
        return None
    try:
        data = _read_json_object(result_file)
    except (OSError, json.JSONDecodeError, ValueError):
        return None
    stage = _stage_key(data.get('stage'))
    status = data.get('status')
    if stage == 'stage1' and status == 'stage1-confirmed':
        return stage
    if stage == 'final' and status == 'confirmed':
        return stage
    return None


def _stage_key(value: object) -> Optional[str]:
    """Normalize the two current recommendation/result stage names."""
    if value is None:
        return None
    raw = str(value).strip().lower()
    if raw == 'stage1':
        return 'stage1'
    if raw == 'stage2':
        return 'stage2'
    if raw == 'final':
        return 'final'
    return None


def _recommendation_stage(data: dict) -> int:
    """Return a recommendation payload's declared stage."""
    stage = _stage_key(data.get('stage'))
    if stage == 'stage1':
        return 1
    if stage == 'stage2':
        return 2
    return 0


def _stage_name(number: Optional[int]) -> Optional[str]:
    """Return the canonical stage key for a stage number."""
    if number == 1:
        return 'stage1'
    if number == 2:
        return 'stage2'
    return None


def _result_stage_number(stage: Optional[str]) -> int:
    """Return result progression: stage1=1, final=2."""
    if stage == 'stage1':
        return 1
    if stage == 'final':
        return 2
    return 0


def _expected_recommendation_stage(result_stage: Optional[str]) -> int:
    """Return the recommendation stage that follows the current result."""
    if result_stage in {'stage1', 'final'}:
        return 2
    return 1


def _stage_recommendations_path(confirm_dir: Path, stage_number: int) -> Path:
    """Return the stage-specific recommendation path for one handoff."""
    return confirm_dir / RECOMMENDATION_STAGE_NAMES[stage_number]


def _is_newer(path: Path, baseline: Path) -> bool:
    """Return whether ``path`` was authored after ``baseline``."""
    try:
        return path.stat().st_mtime_ns > baseline.stat().st_mtime_ns
    except OSError:
        return False


def _fresh_template_restart(confirm_dir: Path) -> bool:
    """Return whether fresh template options start a new UI session."""
    return _is_newer(
        confirm_dir / TEMPLATE_OPTIONS_NAME,
        confirm_dir / RESULT_NAME,
    )


def _active_recommendations_path(confirm_dir: Path) -> Path:
    """Resolve the recommendation file for the current two-stage session."""
    if _fresh_template_restart(confirm_dir):
        return _stage_recommendations_path(confirm_dir, 1)
    result_stage = _result_stage(confirm_dir / RESULT_NAME)
    expected_stage = _expected_recommendation_stage(result_stage)
    return _stage_recommendations_path(confirm_dir, expected_stage)


def _read_active_recommendations(
    confirm_dir: Path,
    *,
    retries: int = 2,
) -> tuple[Path, dict]:
    """Read and validate the recommendation payload active for this stage."""
    rec_file = _active_recommendations_path(confirm_dir)
    data = _read_json_object(rec_file, retries=retries)
    for stage_number, filename in RECOMMENDATION_STAGE_NAMES.items():
        if rec_file.name != filename:
            continue
        actual_stage = _recommendation_stage(data)
        if actual_stage != stage_number:
            raise ValueError(
                f'{filename} must declare stage={_stage_name(stage_number)}, '
                f'found {_stage_name(actual_stage) or "absent"}'
            )
        if stage_number == 2:
            result_file = confirm_dir / RESULT_NAME
            if _result_stage(result_file) == 'stage1':
                if not _is_newer(rec_file, result_file):
                    raise ValueError(
                        f'{filename} must be authored after the current '
                        'Stage-1 confirmation'
                    )
            production_error = _stage2_production_recommendations_error(data)
            if production_error:
                raise ValueError(production_error)
        break
    return rec_file, data


def _template_confirmation_required(project_path: Path) -> bool:
    """Return whether the confirmed project state has an active template."""
    confirm_dir = project_path / CONFIRM_DIR_NAME
    handoff = _read_template_handoff(
        project_path,
        confirm_dir / TEMPLATE_HANDOFF_NAME,
    )
    return handoff['mode'] == 'templates'


def _template_stage2_error(
    recommendations: dict,
    *,
    template_required: bool,
) -> Optional[str]:
    """Require the natural-language template plan in template Stage 2."""
    if template_required and 'template_application' not in recommendations:
        return (
            'template Stage 2 recommendations must include '
            'template_application.value'
        )
    return None


def _localized_text_present(candidate: dict, field: str) -> bool:
    """Return whether a candidate carries non-empty localized prose."""
    return any(
        isinstance(candidate.get(key), str) and bool(candidate[key].strip())
        for key in (field, f'{field}_zh', f'{field}_en', f'{field}_ja')
    )


def _recommended_image_usage(recommendations: dict):
    """Return the Stage 2 image-source recommendation in either schema."""
    recommend = recommendations.get('recommend')
    usage = recommend.get('image_usage') if isinstance(recommend, dict) else None
    if usage is None:
        usage = recommendations.get('image_usage')
        if isinstance(usage, dict):
            usage = usage.get('value')
    return usage


def _uses_ai_images(recommendations: dict) -> bool:
    """Return whether Stage 2 proposes AI-generated images."""
    usage = _recommended_image_usage(recommendations)
    return 'ai' in usage if isinstance(usage, list) else usage == 'ai'


def _stage2_production_recommendations_error(
    recommendations: dict,
) -> Optional[str]:
    """Require every production control in current Stage 2 recommendations."""
    recommend = recommendations.get('recommend')
    if not isinstance(recommend, dict):
        recommend = {}
    for field in ('formula_policy', 'generation_mode'):
        value = recommend.get(field)
        if not isinstance(value, str) or not value.strip():
            return (
                'Stage 2 recommendations must include non-empty '
                f'recommend.{field}'
            )
    refine_spec = recommendations.get('refine_spec')
    if (
        not isinstance(refine_spec, dict)
        or not isinstance(refine_spec.get('value'), bool)
    ):
        return 'Stage 2 recommendations must include refine_spec.value as a boolean'
    if _uses_ai_images(recommendations):
        image_ai_path = recommend.get('image_ai_path')
        if not isinstance(image_ai_path, str) or not image_ai_path.strip():
            return (
                'Stage 2 recommendations must include non-empty '
                'recommend.image_ai_path when image_usage includes ai'
            )
    return None


def _stage2_production_result_error(result: dict) -> Optional[str]:
    """Require every user-confirmed production control in the final payload."""
    for field in ('formula_policy', 'generation_mode'):
        value = result.get(field)
        if not isinstance(value, str) or not value.strip():
            return f'final Stage 2 payload must include non-empty {field}'
    if not isinstance(result.get('refine_spec'), bool):
        return 'final Stage 2 payload must include refine_spec as a boolean'
    if _uses_ai_images(result):
        image_ai_path = result.get('image_ai_path')
        if not isinstance(image_ai_path, str) or not image_ai_path.strip():
            return (
                'final Stage 2 payload must include non-empty image_ai_path '
                'when image_usage includes ai'
            )
    return None


def _palette_error(color: object, label: str) -> Optional[str]:
    """Validate one complete user-facing palette."""
    if not isinstance(color, dict):
        return f'{label} must be an object'
    palette = color.get('palette')
    if not isinstance(palette, dict):
        palette = color
    for role in _PALETTE_ROLES:
        value = palette.get(role)
        if role == 'body_text' and value is None:
            value = palette.get('text')
        if not isinstance(value, str) or not _HEX_COLOR_RE.fullmatch(value.strip()):
            return f'{label}.palette.{role} must be a HEX color'
    return None


def _positive_number(value: object) -> bool:
    """Return whether a JSON value is a positive finite number."""
    try:
        number = float(value)
    except (TypeError, ValueError):
        return False
    return number > 0 and number != float('inf')


def _is_english_language(language: object) -> bool:
    """Return whether a recommendation language is an English locale."""
    if not isinstance(language, str):
        return False
    try:
        return language_base(language) == 'en'
    except LanguageTagError:
        return False


def _recommendation_language(recommendations: dict) -> object:
    """Return the deck's main language without conflating it with UI ``lang``."""
    value = (
        recommendations.get('primary_language')
        or recommendations.get('content_language')
        or recommendations.get('language')
    )
    if isinstance(value, dict):
        return value.get('value') or value.get('id') or value.get('code') or ''
    return value


def _primary_language_error(recommendations: dict) -> Optional[str]:
    """Require and canonicalize the staged content-language source of truth."""
    return _canonicalize_primary_language(recommendations, required=True)


def _canonicalize_primary_language(
    recommendations: dict,
    *,
    required: bool,
) -> Optional[str]:
    """Write a canonical primary language into one recommendation/result object."""
    value = _recommendation_language(recommendations)
    if not isinstance(value, str) or not value.strip():
        if required:
            return (
                'Stage 1 recommendations must declare a valid primary_language '
                'BCP-47 tag; lang controls only the Confirm UI language'
            )
        return None
    try:
        recommendations['primary_language'] = normalize_language_tag(value)
    except LanguageTagError as exc:
        return f'invalid primary_language: {exc}'
    return None


def _typography_font_value(
    font: dict,
    field: str,
    *,
    english_primary: bool,
) -> object:
    """Return a canonical typography font value, accepting its language-aware alias."""
    legacy_field = 'latin' if field == 'english' or english_primary else 'cjk'
    value = font.get(field)
    if not isinstance(value, str) or not value.strip():
        value = font.get(legacy_field)
    return value


def _typography_error(
    typography: object,
    label: str,
    *,
    require_sizes: bool,
    main_language: object = '',
) -> Optional[str]:
    """Validate one complete user-facing typography recommendation or choice."""
    if not isinstance(typography, dict):
        return f'{label} must be an object'
    english_primary = _is_english_language(main_language)
    for role in ('heading', 'body'):
        font = typography.get(role)
        if not isinstance(font, dict):
            return f'{label}.{role} must be an object'
        fields = (('primary', 'latin' if english_primary else 'cjk'),)
        if not english_primary:
            fields += (('english', 'latin'),)
        for field, legacy_field in fields:
            value = _typography_font_value(
                font,
                field,
                english_primary=english_primary,
            )
            if not isinstance(value, str) or not value.strip():
                return (
                    f'{label}.{role}.{field} '
                    f'(or legacy {legacy_field}) must be non-empty'
                )
        if not isinstance(font.get('css'), str) or not font['css'].strip():
            return f'{label}.{role}.css must be non-empty'
    if not _positive_number(typography.get('body_size')):
        return f'{label}.body_size must be a positive number'
    if not require_sizes:
        return None
    sizes = typography.get('sizes')
    if not isinstance(sizes, dict):
        return f'{label}.sizes must be an object'
    for role in _TYPOGRAPHY_SIZE_ROLES:
        if not _positive_number(sizes.get(role)):
            return f'{label}.sizes.{role} must be a positive number'
    return None


def _typography_signature(
    typography: dict,
    *,
    main_language: object,
) -> tuple[str, ...]:
    """Return the language-relevant font choices that distinguish one candidate."""
    english_primary = _is_english_language(main_language)
    fields = ('primary',) if english_primary else ('primary', 'english')
    values = []
    for role in ('heading', 'body'):
        font = typography[role]
        for field in fields:
            value = _typography_font_value(
                font,
                field,
                english_primary=english_primary,
            )
            values.append(str(value).strip().casefold())
    return tuple(values)


def _typography_candidates_fixed_error(
    candidates: list,
    *,
    main_language: object,
) -> Optional[str]:
    """Reject contradictions in an explicitly fixed typography contract."""
    fixed = [
        isinstance(candidate, dict) and candidate.get('fixed') is True
        for candidate in candidates
    ]
    if any(fixed):
        if not all(fixed):
            return 'typography.fixed must be true on every candidate or omitted'
        signatures = [
            _typography_signature(
                candidate,
                main_language=main_language,
            )
            for candidate in candidates
        ]
        if any(signature != signatures[0] for signature in signatures[1:]):
            return 'fixed typography candidates must repeat the same font combination'
        return None
    return None


def _candidate_list(spec: object) -> list:
    """Return candidates from the current or legacy recommendation shape."""
    if not isinstance(spec, dict):
        return []
    candidates = spec.get('candidates')
    if not isinstance(candidates, list):
        candidates = spec.get('options')
    return candidates if isinstance(candidates, list) else []


def _stage2_design_directions_error(
    recommendations: dict,
    *,
    main_language: object = '',
) -> Optional[str]:
    """Require three complete coordinated Stage 2 design systems."""
    main_language = main_language or _recommendation_language(recommendations)
    directions = recommendations.get('design_directions')
    if isinstance(directions, dict):
        candidates = _candidate_list(directions)
        if len(candidates) < 3:
            return 'Stage 2 design_directions must include at least 3 candidates'
        typography_candidates = []
        for index, candidate in enumerate(candidates, start=1):
            label = f'design_directions.candidates[{index - 1}]'
            if not isinstance(candidate, dict):
                return f'{label} must be an object'
            if not _localized_text_present(candidate, 'name'):
                return f'{label} requires a non-empty localized name'
            for field in ('visual_style', 'icons'):
                if not isinstance(candidate.get(field), str) or not candidate[field].strip():
                    return f'{label}.{field} must be non-empty'
            error = _palette_error(candidate.get('color'), f'{label}.color')
            if error:
                return error
            error = _typography_error(
                candidate.get('typography'),
                f'{label}.typography',
                require_sizes=False,
                main_language=main_language,
            )
            if error:
                return error
            typography_candidates.append(candidate['typography'])
            if _uses_ai_images(recommendations):
                image_strategy = candidate.get('image_strategy')
                if not isinstance(image_strategy, dict) or not str(
                    image_strategy.get('rendering') or ''
                ).strip():
                    return f'{label}.image_strategy.rendering must be non-empty'
        return _typography_candidates_fixed_error(
            typography_candidates,
            main_language=main_language,
        )

    # Legacy staged files remain readable, but they must still provide three
    # complete color combinations and at least one complete typography choice.
    colors = _candidate_list(recommendations.get('color'))
    if len(colors) < 3:
        return 'Stage 2 recommendations must include 3 complete color candidates'
    for index, color in enumerate(colors):
        error = _palette_error(color, f'color.candidates[{index}]')
        if error:
            return error
    typography = _candidate_list(recommendations.get('typography'))
    if not typography:
        return 'Stage 2 recommendations must include typography candidates'
    if main_language and len(typography) < 3:
        return 'Stage 2 recommendations must include 3 typography candidates'
    for index, candidate in enumerate(typography):
        error = _typography_error(
            candidate,
            f'typography.candidates[{index}]',
            require_sizes=False,
            main_language=main_language,
        )
        if error:
            return error
    if main_language:
        return _typography_candidates_fixed_error(
            typography,
            main_language=main_language,
        )
    return None


def _stage2_custom_candidates_error(recommendations: dict) -> Optional[str]:
    """Require visible AI-authored custom alternatives in new Stage 2 files."""
    candidates = recommendations.get('custom_candidates')
    if not isinstance(candidates, dict):
        return 'Stage 2 recommendations must include custom_candidates'

    for field in ('mode', 'visual_style'):
        candidate = candidates.get(field)
        if not isinstance(candidate, dict):
            return f'custom_candidates.{field} must be an object'
        for prose_field in ('name', 'behavior'):
            if not _localized_text_present(candidate, prose_field):
                return (
                    f'custom_candidates.{field} requires non-empty localized '
                    f'{prose_field}'
                )

    if not _uses_ai_images(recommendations):
        return None

    image_candidate = candidates.get('image_strategy')
    if not isinstance(image_candidate, dict):
        return 'custom_candidates.image_strategy must be an object when image_usage includes ai'
    if image_candidate.get('rendering') != 'custom':
        return 'custom_candidates.image_strategy.rendering must be custom'
    for prose_field in ('name', 'visual', 'mood', 'behavior'):
        if not _localized_text_present(image_candidate, prose_field):
            return (
                'custom_candidates.image_strategy requires non-empty localized '
                f'{prose_field}'
            )
    return None


def _submission_stage_error(
    confirm_dir: Path,
    submitted_stage: Optional[str],
    *,
    recommendations_file: Path,
    recommendations: dict,
    template_required: bool,
) -> Optional[str]:
    """Reject a confirmation that does not match the staged recommendation."""
    rec_stage_number = _recommendation_stage(recommendations)
    if rec_stage_number == 0:
        return 'recommendations must declare stage1 or stage2'

    if rec_stage_number == 1:
        language_error = _primary_language_error(recommendations)
        if language_error:
            return language_error

    if rec_stage_number == 2:
        try:
            previous_result = _read_json_object(confirm_dir / RESULT_NAME)
        except (OSError, json.JSONDecodeError, ValueError):
            previous_result = {}
        language_source = (
            previous_result
            if _recommendation_language(previous_result)
            else recommendations
        )
        language_error = _canonicalize_primary_language(
            language_source,
            required=True,
        )
        if language_error:
            return language_error
        main_language = _recommendation_language(language_source)
        recommendation_error = _template_stage2_error(
            recommendations,
            template_required=template_required,
        )
        if recommendation_error:
            return recommendation_error
        recommendation_error = _stage2_custom_candidates_error(recommendations)
        if recommendation_error:
            return recommendation_error
        recommendation_error = _stage2_design_directions_error(
            recommendations,
            main_language=main_language,
        )
        if recommendation_error:
            return recommendation_error

    allowed_submissions = {
        1: {'stage1'},
        2: {'final'},
    }
    if submitted_stage not in allowed_submissions[rec_stage_number]:
        expected = 'final' if rec_stage_number == 2 else 'stage1'
        return (
            f'confirmation stage mismatch: {recommendations_file.name} is '
            f'{_stage_name(rec_stage_number)}, so the submitted stage must be '
            f'{expected}'
        )

    previous_stage = (
        None
        if _fresh_template_restart(confirm_dir)
        else _result_stage(confirm_dir / RESULT_NAME)
    )
    allowed_predecessors = {
        1: {None},
        2: {'stage1'},
    }
    if previous_stage not in allowed_predecessors[rec_stage_number]:
        expected_previous = 'stage1' if rec_stage_number == 2 else 'no prior result'
        return (
            f'confirmation predecessor mismatch: {_stage_name(rec_stage_number)} '
            f'requires a confirmed {expected_previous} result, found '
            f'{previous_stage or "absent"}'
        )
    return None


def _custom_selection_error(result: dict) -> Optional[str]:
    """Require behavior prose whenever a creative custom choice is selected."""
    if result.get('mode') == 'custom' and not str(
        result.get('mode_behavior') or ''
    ).strip():
        return 'mode=custom requires non-empty mode_behavior'
    if result.get('visual_style') == 'custom' and not str(
        result.get('visual_style_behavior') or ''
    ).strip():
        return 'visual_style=custom requires non-empty visual_style_behavior'
    image_strategy = result.get('image_strategy')
    if isinstance(image_strategy, dict) and image_strategy.get('rendering') == 'custom':
        behavior = image_strategy.get('behavior') or image_strategy.get('custom')
        if not str(behavior or '').strip():
            return 'image_strategy.rendering=custom requires non-empty behavior'
    return None


def _stage2_solution_error(
    result: dict,
    *,
    main_language: object = '',
) -> Optional[str]:
    """Reject a Stage 2/final payload with an incomplete design system."""
    production_error = _stage2_production_result_error(result)
    if production_error:
        return production_error

    color = result.get('color')
    color_error = _palette_error(color, 'color')
    color_custom = (
        isinstance(color, dict)
        and color.get('name') == 'custom'
        and str(color.get('custom') or '').strip()
    )
    if color_error and not color_custom:
        return color_error

    typography = result.get('typography')
    typography_error = _typography_error(
        typography,
        'typography',
        require_sizes=True,
        main_language=main_language,
    )
    if typography_error:
        return typography_error

    if _uses_ai_images(result):
        image_strategy = result.get('image_strategy')
        if not isinstance(image_strategy, dict) or not str(
            image_strategy.get('rendering') or ''
        ).strip():
            return 'image_usage includes ai, so image_strategy.rendering must be non-empty'
        rendering = image_strategy['rendering'].strip()
        if rendering != 'custom' and rendering not in _ai_rendering_ids():
            return f'image_strategy.rendering is not a known preset: {rendering}'
    return None


def _normalize_custom_selections(result: dict) -> None:
    """Keep custom prose only for the creative choices actually selected."""
    if result.get('mode') != 'custom':
        result.pop('mode_behavior', None)
    if result.get('visual_style') != 'custom':
        result.pop('visual_style_behavior', None)

    image_strategy = result.get('image_strategy')
    if not isinstance(image_strategy, dict):
        return
    legacy_behavior = image_strategy.pop('custom', None)
    if image_strategy.get('rendering') == 'custom':
        if not image_strategy.get('behavior') and legacy_behavior:
            image_strategy['behavior'] = legacy_behavior
        return
    image_strategy.pop('behavior', None)


def _expected_result_stage(confirm_dir: Path) -> str:
    """Return the result stage expected from the current recommendations."""
    try:
        _, recommendations = _read_active_recommendations(confirm_dir)
    except (OSError, json.JSONDecodeError, ValueError):
        return 'final'
    return {
        1: 'stage1',
        2: 'final',
    }.get(_recommendation_stage(recommendations), 'final')


def _file_version(path: Path) -> Optional[float]:
    """Return a cheap file version for polling state, or None when absent."""
    try:
        return path.stat().st_mtime
    except OSError:
        return None


def _read_session(confirm_dir: Path) -> dict:
    """Read session.json if present, returning an object."""
    session_file = confirm_dir / SESSION_NAME
    if not session_file.exists():
        return {}
    try:
        return _read_json_object(session_file)
    except (OSError, json.JSONDecodeError, ValueError):
        return {}


def _build_session_state(
    confirm_dir: Path,
    *,
    server_port: Optional[int] = None,
    event: Optional[str] = None,
) -> dict:
    """Derive the resumable Confirm UI state from disk artifacts."""
    previous = _read_session(confirm_dir)
    rec_file = _active_recommendations_path(confirm_dir)
    result_file = confirm_dir / RESULT_NAME

    rec_stage_number = 0
    rec_stage = None
    rec_error = None
    if rec_file.exists():
        try:
            rec_file, rec_data = _read_active_recommendations(confirm_dir)
            rec_stage_number = _recommendation_stage(rec_data)
            rec_stage = _stage_name(rec_stage_number)
        except (OSError, json.JSONDecodeError, ValueError) as exc:
            rec_error = str(exc)

    fresh_restart = _fresh_template_restart(confirm_dir)
    result_stage = None if fresh_restart else _result_stage(result_file)
    result_stage_number = _result_stage_number(result_stage)

    if result_stage == 'final':
        expected_stage_number = None
        status = 'done'
        current_stage = 'final'
    elif result_stage == 'stage1':
        expected_stage_number = 2
        ready = rec_stage_number == 2
        status = 'ready_user' if ready else 'waiting_agent'
        current_stage = 'stage2' if ready else 'stage1'
    else:
        expected_stage_number = 1
        ready = rec_stage_number == 1
        status = 'ready_user' if ready else 'waiting_agent'
        current_stage = 'stage1'

    session = {
        'phase': 'strategist',
        'status': status,
        'current_stage': current_stage,
        'expected_stage': _stage_name(expected_stage_number),
        'expected_stage_number': expected_stage_number,
        'recommendation_stage': rec_stage,
        'recommendation_stage_number': rec_stage_number,
        'recommendation_file': rec_file.name,
        'recommendation_version': _file_version(rec_file),
        'recommendation_error': rec_error,
        'result_stage': result_stage,
        'result_stage_number': result_stage_number,
        'result_version': _file_version(result_file),
        'server_port': server_port or previous.get('server_port'),
        'event': event or previous.get('event') or 'derived',
    }

    options_file = confirm_dir / TEMPLATE_OPTIONS_NAME
    selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
    handoff_file = confirm_dir / TEMPLATE_HANDOFF_NAME
    session.update({
        'template_options_file': TEMPLATE_OPTIONS_NAME,
        'template_options_version': _file_version(options_file),
        'template_selection_file': TEMPLATE_SELECTION_NAME,
        'template_selection_version': _file_version(selection_file),
        'template_handoff_file': TEMPLATE_HANDOFF_NAME,
        'template_handoff_version': _file_version(handoff_file),
    })

    if result_stage == 'final':
        session.update({
            'template_status': 'ready',
            'template_error': None,
        })
        return session

    if result_stage is None:
        ready_error = _stage1_ready_error(confirm_dir)
        if ready_error:
            session.update({
                'status': 'error',
                'template_status': 'error',
                'template_error': ready_error,
            })
            if not session.get('recommendation_error'):
                session['recommendation_error'] = ready_error
            return session
        session.update({
            'template_status': 'ready_user',
            'template_error': None,
        })
        return session

    ready_error = _stage2_ready_error(
        confirm_dir.parent,
        confirm_dir,
        rec_file if rec_stage_number == 2 else None,
    )
    if ready_error:
        session.update({
            'status': 'waiting_agent',
            'current_stage': 'stage1',
            'expected_stage': 'stage2',
            'expected_stage_number': 2,
            'template_status': 'waiting_agent',
            'template_error': ready_error,
        })
        return session
    session.update({
        'template_status': 'ready',
        'template_error': None,
    })
    return session


def _write_session_state(confirm_dir: Path, session: dict) -> None:
    """Persist session.json only when stable state changes."""
    previous = _read_session(confirm_dir)
    comparable_previous = dict(previous)
    comparable_current = dict(session)
    comparable_previous.pop('updated_at', None)
    comparable_current.pop('updated_at', None)
    if comparable_previous == comparable_current:
        return
    session = dict(session)
    session['updated_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
    _write_json_atomic(confirm_dir / SESSION_NAME, session)


def _sync_session_state(
    confirm_dir: Path,
    *,
    server_port: Optional[int] = None,
    event: Optional[str] = None,
) -> dict:
    """Derive and persist the current session state."""
    session = _build_session_state(
        confirm_dir,
        server_port=server_port,
        event=event,
    )
    _write_session_state(confirm_dir, session)
    return session


# Earlier-stage choices are not rendered on later pages, so their values live
# only in browser STATE and would be lost on an in-run refresh. Fold them from
# result.json into Stage-2 recommendations so the same live run resumes from the
# user's actual communication contract and complete deck-solution choices.
_CONTRACT_RECOMMEND_KEYS = (
    'canvas',
)
_CONTRACT_VALUE_KEYS = (
    'audience',
    'communication_intent',
    'audience_outcome',
    'core_message',
    'delivery_context',
    'artifact_afterlife',
    'content_divergence',
)
_PROACTIVE_EXECUTION_DEFAULTS = {
    'proactive_speaker_notes': True,
    'proactive_custom_animations': False,
    'proactive_narration_audio': False,
}
_LOCKED_RECOMMENDATIONS_KEY = '_locked_recommendations'


def _resolve_proactive_execution_values(
    source: dict,
) -> tuple[dict[str, bool], Optional[str]]:
    """Resolve proactive-execution booleans with backward-compatible defaults."""
    values = {}
    for key, default in _PROACTIVE_EXECUTION_DEFAULTS.items():
        if key not in source:
            values[key] = default
            continue
        raw_value = source[key]
        if isinstance(raw_value, dict):
            if 'value' not in raw_value or not isinstance(raw_value['value'], bool):
                return {}, f'{key}.value must be a boolean'
            raw_value = raw_value['value']
        elif not isinstance(raw_value, bool):
            return {}, f'{key} must be a boolean'
        values[key] = raw_value
    return values, None


def _normalize_proactive_execution_result(
    result: dict,
    defaults: dict[str, bool],
) -> Optional[str]:
    """Write the independent raw confirmation booleans to the final result."""
    values = {}
    for key, default in defaults.items():
        value = result.get(key, default)
        if not isinstance(value, bool):
            return f'{key} must be a boolean'
        values[key] = value
    result.update(values)
    return None


def _merge_confirmed_choices(data: dict, result_file: Path) -> None:
    """Fold already-confirmed choices into later-stage recommendations."""
    try:
        res = _read_json_object(result_file)
    except (OSError, json.JSONDecodeError, ValueError):
        return
    if _result_stage(result_file) != 'stage1':
        return
    recommend = data.setdefault('recommend', {})
    if not isinstance(recommend, dict):
        recommend = data['recommend'] = {}
    main_language = _recommendation_language(res)
    if main_language:
        try:
            data['primary_language'] = normalize_language_tag(main_language)
        except LanguageTagError:
            # Keep the invalid legacy value visible to the API boundary below,
            # which returns a user-facing contract error instead of hiding it.
            data['primary_language'] = main_language
    for key in _CONTRACT_RECOMMEND_KEYS:
        if res.get(key) not in (None, ''):
            recommend[key] = res[key]
    for key in _CONTRACT_VALUE_KEYS:
        if key in res:
            data[key] = {'value': res.get(key) or ''}


def _apply_locked_recommendations(
    result: dict,
    recommendations_file: Path,
    previous_result_file: Path,
    *,
    carry_previous: bool,
) -> dict:
    """Restore profile-locked fields and return locks for staged carry-over."""
    # This marker is server-owned; never accept a client-supplied carry-over map.
    result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
    locked_values = {}
    previous = {}
    if carry_previous:
        try:
            previous = _read_json_object(previous_result_file)
        except (OSError, json.JSONDecodeError, ValueError):
            previous = {}
    previous_locks = previous.get(_LOCKED_RECOMMENDATIONS_KEY)
    if isinstance(previous_locks, dict):
        locked_values.update(previous_locks)
    for key in _PROACTIVE_EXECUTION_DEFAULTS:
        locked_values.pop(key, None)

    try:
        recommendations = _read_json_object(recommendations_file)
        recommendations_loaded = True
    except (OSError, json.JSONDecodeError, ValueError):
        recommendations = {}
        recommendations_loaded = False

    # Stage 1 starts a new contract and therefore replaces any stale locks left
    # by an earlier run. Later stages inherit those locks across server restarts.
    if recommendations_loaded and _recommendation_stage(recommendations) == 1:
        locked_values = {}
    for key, field in recommendations.items():
        if key in _PROACTIVE_EXECUTION_DEFAULTS:
            continue
        if not isinstance(field, dict) or field.get('locked') is not True:
            continue
        if 'value' in field:
            locked_values[key] = field['value']
    for key, value in locked_values.items():
        result[key] = value
    return locked_values


def _wait_only_for_result(
    result_file: Path,
    lock_file: Path,
    timeout: int,
    target_stage: str = 'final',
) -> int:
    """Attach to an already-running confirm server and wait for a target stage.

    No child is launched here: the page is open from the preceding ``--daemon``
    launch, so liveness is tracked via the recorded pid, not a ``proc`` handle.
    Only the stage guard is used (no mtime gate), because intermediate submits
    may happen before this wait command is issued.
    """
    logger.info('waiting for browser confirmation stage=%s...', target_stage)
    deadline = None if timeout <= 0 else time.time() + timeout
    while True:
        result_status = _wait_result_status(result_file, target_stage)
        if result_status is not None:
            return result_status

        confirm_dir = result_file.parent
        if target_stage == 'stage1':
            readiness_error = _stage1_ready_error(confirm_dir)
        else:
            recommendations_file = _active_recommendations_path(confirm_dir)
            readiness_error = _stage2_ready_error(
                confirm_dir.parent,
                confirm_dir,
                recommendations_file,
            )
        if readiness_error:
            logger.error(
                'confirmation stage=%s is not ready: %s',
                target_stage,
                readiness_error,
            )
            return 1

        lock = _read_lock(lock_file)
        pid = _lock_pid(lock)
        if not pid or not _process_alive(pid):
            logger.error('confirm server is no longer running before stage=%s was confirmed', target_stage)
            return 1

        if deadline is not None and time.time() >= deadline:
            logger.error(
                'timed out waiting for confirmation stage=%s — the page may still '
                'be open; re-check %s before falling back to chat', target_stage, result_file,
            )
            return 124

        time.sleep(0.5)


def _wait_result_status(
    result_file: Path,
    target_stage: str,
) -> Optional[int]:
    """Return a terminal wait status when the persisted result resolves the target."""
    if _fresh_template_restart(result_file.parent):
        return None
    current_stage = _result_stage(result_file)
    if current_stage == target_stage:
        if target_stage == 'stage1':
            try:
                _read_template_selection(
                    result_file.parent / TEMPLATE_SELECTION_NAME,
                )
            except (OSError, json.JSONDecodeError, ValueError) as exc:
                logger.error(
                    'Stage 1 result has no valid template selection: %s',
                    exc,
                )
                return 2
        logger.info('confirmation stage=%s received: %s', target_stage, result_file)
        return 0
    if _result_stage_number(current_stage) > _result_stage_number(target_stage):
        logger.error(
            'confirmation skipped expected stage=%s and advanced to %s',
            target_stage,
            current_stage,
        )
        return 2
    return None


def _shutdown_existing(lock_file: Path) -> int:
    """Stop a confirm server left running for this project (idempotent).

    Step 4 always calls this on exit so the page never lingers on its selected
    port. Tries a graceful ``/api/shutdown`` first, falls back to killing the
    recorded pid, then clears the lock. A no-op when nothing is running.
    """
    existing = _read_lock(lock_file)
    if not existing:
        logger.info('no confirm server running — nothing to stop')
        return 0
    pid = _lock_pid(existing)
    port = existing.get('port')
    if not _process_alive(pid):
        _clear_lock(lock_file)
        logger.info('confirm server already stopped; cleared stale lock')
        return 0
    # Graceful first: the server flushes and releases its own lock.
    if port:
        try:
            req = urllib.request.Request(
                f'http://127.0.0.1:{port}/api/shutdown',
                data=b'{"reason": "step4-cleanup"}',
                headers={'Content-Type': 'application/json'},
                method='POST',
            )
            urllib.request.urlopen(req, timeout=3)
        except OSError:
            pass  # server may already be exiting; fall through to the kill path
    for _ in range(20):  # up to ~2s for the graceful exit to land
        if not _process_alive(pid):
            break
        time.sleep(0.1)
    if _process_alive(pid):
        try:
            os.kill(pid, signal.SIGTERM)
        except OSError:
            pass
    _clear_lock(lock_file)
    logger.info('confirm server stopped (pid=%s)', pid)
    return 0


def _build_catalogs() -> dict:
    """Return the static catalog set with the canvas list synced live from
    ``config.CANVAS_FORMATS`` — the single source of truth for canvas formats —
    so the confirm page can never drift from the pipeline's real formats. The
    set of formats and their dimensions come from config; trilingual labels and
    use text are kept from catalogs.json (with a plain fallback for any new id).
    """
    data = json.loads(_CATALOGS_PATH.read_text(encoding='utf-8'))
    try:
        import config  # scripts/ is on sys.path (injected at import time)
        formats = config.CANVAS_FORMATS
    except (ImportError, AttributeError):  # missing module/attr → static canvas
        return data
    existing = {
        c.get('id'): c
        for c in data.get('canvas', [])
        if isinstance(c, dict) and c.get('id')
    }
    canvas = []
    for cid, fmt in formats.items():
        entry = dict(existing.get(cid, {}))
        entry['id'] = cid
        entry['dim'] = fmt.get('dimensions', entry.get('dim', ''))
        if not entry.get('label'):
            name = fmt.get('name', cid)
            entry['label'] = name
            entry.setdefault('label_zh', name)
            entry.setdefault('label_en', name)
        if not entry.get('use_en') and fmt.get('use_case'):
            entry['use_en'] = fmt['use_case']
        canvas.append(entry)
    data['canvas'] = canvas
    return data


def _icon_preview_svg(library: str, name: str) -> str:
    """Read a trusted sample SVG from the bundled icon templates."""
    icon_path = _ICON_LIBRARY_DIR / library / f'{name}.svg'
    raw = icon_path.read_text(encoding='utf-8')
    raw = re.sub(r'<\?xml[^>]*>\s*', '', raw)
    raw = re.sub(r'<!--.*?-->\s*', '', raw, flags=re.S)
    return raw.strip()


def _build_icon_previews() -> dict:
    previews = {}
    for library, names in _ICON_PREVIEW_SAMPLES.items():
        items = []
        for name in names:
            try:
                items.append({'name': name, 'svg': _icon_preview_svg(library, name)})
            except OSError as exc:
                logger.warning('icon preview sample missing: %s/%s (%s)', library, name, exc)
        previews[library] = items
    return previews


def _ai_comparison_items(kind: str) -> list[dict[str, str]]:
    manifest = _AI_IMAGE_COMPARISON_DIR / kind / '_manifest.json'
    if not manifest.exists():
        return []
    data = json.loads(manifest.read_text(encoding='utf-8'))
    items = []
    for item in data.get('items', []):
        filename = item.get('filename')
        if not isinstance(filename, str) or not filename.endswith('.png'):
            continue
        if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename):
            continue
        if not (_AI_IMAGE_COMPARISON_DIR / kind / filename).exists():
            continue
        item_id = Path(filename).stem
        items.append({
            'id': item_id,
            'label': item.get('type') or item_id,
            'filename': filename,
            'purpose': item.get('purpose') or '',
            'alt_text': item.get('alt_text') or '',
        })
    return items


def _ai_rendering_ids() -> set[str]:
    """Return the rendering presets exposed by the confirmation UI."""
    return {item['id'] for item in _ai_comparison_items('rendering')}


def _build_ai_image_comparison() -> dict:
    return {
        'rendering': _ai_comparison_items('rendering'),
    }


# --- app --------------------------------------------------------------------

def create_app(
    project_dir: str,
    idle_timeout: int = 900,
    lock_file: Optional[Path] = None,
    server_port: Optional[int] = None,
) -> Flask:
    """Create and configure the Flask app for a given project directory."""
    project_path = Path(project_dir).resolve()
    confirm_dir = project_path / CONFIRM_DIR_NAME

    app = Flask(__name__, static_folder='static', static_url_path='/static')
    app.config['PROJECT_PATH'] = project_path
    app.config['CONFIRM_DIR'] = confirm_dir
    app.config['LOCK_FILE'] = lock_file
    app.config['SERVER_PORT'] = server_port
    app.config['LAST_REQUEST_TIME'] = time.time()

    @app.before_request
    def _update_activity():
        app.config['LAST_REQUEST_TIME'] = time.time()

    def _exit_with_lock_release(code: int = 0) -> None:
        lf = app.config.get('LOCK_FILE')
        if lf is not None:
            _release_lock(lf)
        os._exit(code)

    def _idle_watchdog():
        if idle_timeout <= 0:
            return
        while True:
            time.sleep(10)
            elapsed = time.time() - app.config['LAST_REQUEST_TIME']
            if elapsed > idle_timeout:
                logger.info('idle for %ds, shutting down', idle_timeout)
                _exit_with_lock_release(0)

    watchdog = threading.Thread(target=_idle_watchdog, daemon=True)
    watchdog.start()

    @app.route('/api/shutdown', methods=['POST'])
    def shutdown():
        data = request.get_json(silent=True) or {}
        reason = data.get('reason') or 'shutdown'

        def _stop():
            time.sleep(0.5)  # let HTTP response flush before killing the process
            logger.info('shutting down (%s)', reason)
            _exit_with_lock_release(0)
        threading.Thread(target=_stop, daemon=True).start()
        return jsonify({'status': 'ok'})

    @app.route('/')
    def index():
        return send_from_directory(app.static_folder, 'index.html')

    @app.route('/api/health')
    def health():
        """Expose a cheap readiness probe for the daemon launcher."""
        rec_file = _active_recommendations_path(confirm_dir)
        rec_ok = False
        stage = None
        if rec_file.exists():
            try:
                rec_file, rec_data = _read_active_recommendations(
                    confirm_dir,
                    retries=0,
                )
                rec_ok = True
                stage = _recommendation_stage(rec_data)
            except (OSError, json.JSONDecodeError, ValueError):
                rec_ok = False
        resp = jsonify({
            'status': 'ok',
            'service': 'confirm_ui',
            'pid': os.getpid(),
            'project': str(project_path),
            'recommendations': rec_ok,
            'stage': stage,
            'session': _build_session_state(
                confirm_dir,
                server_port=app.config.get('SERVER_PORT'),
            ),
        })
        resp.headers['Cache-Control'] = 'no-store'
        return resp

    @app.route('/api/session')
    def get_session():
        """Expose the derived template/Strategist wizard state for polling."""
        session = _sync_session_state(
            confirm_dir,
            server_port=app.config.get('SERVER_PORT'),
            event='poll',
        )
        resp = jsonify(session)
        resp.headers['Cache-Control'] = 'no-store'
        return resp

    @app.route('/api/catalogs')
    def get_catalogs():
        """Serve the option universe; canvas is synced live from config.py so
        the static catalogs.json copy can never drift from the real formats."""
        try:
            resp = jsonify(_build_catalogs())
            resp.headers['Cache-Control'] = 'no-store'
            return resp
        except (OSError, json.JSONDecodeError) as exc:
            return jsonify({'error': f'invalid catalogs.json: {exc}'}), 500

    @app.route('/api/icon-previews')
    def get_icon_previews():
        """Serve real sample icons from templates/icons for the icon chooser."""
        resp = jsonify(_build_icon_previews())
        resp.headers['Cache-Control'] = 'no-store'
        return resp

    @app.route('/api/ai-image-comparison')
    def get_ai_image_comparison_manifest():
        """Serve generated-image rendering references for the current UI."""
        try:
            resp = jsonify(_build_ai_image_comparison())
            resp.headers['Cache-Control'] = 'no-store'
            return resp
        except (OSError, json.JSONDecodeError) as exc:
            return jsonify({'error': f'invalid ai-image-comparison manifest: {exc}'}), 500

    @app.route('/ai-image-comparison/<kind>/<filename>')
    def get_ai_image_comparison(kind: str, filename: str):
        """Serve rendering images for generated-image strategy candidates."""
        if kind != 'rendering':
            return jsonify({'error': 'invalid comparison kind'}), 404
        if not re.fullmatch(r'[A-Za-z0-9_.-]+\.png', filename or ''):
            return jsonify({'error': 'invalid comparison filename'}), 404
        return send_from_directory(_AI_IMAGE_COMPARISON_DIR / kind, filename)

    @app.route('/api/recommendations')
    def get_recommendations():
        """Serve the Strategist-authored recommendations for this project."""
        result_file = confirm_dir / RESULT_NAME
        if (
            _result_stage(result_file) == 'final'
            and not _fresh_template_restart(confirm_dir)
        ):
            return jsonify({
                'error': (
                    'the current Confirm UI run is complete; reset the template '
                    f'selection and write fresh {TEMPLATE_OPTIONS_NAME} before '
                    'starting another'
                ),
            }), 409
        rec_file = _active_recommendations_path(confirm_dir)
        if not rec_file.exists():
            return jsonify({'error': f'{rec_file.name} not found'}), 404
        try:
            rec_file, data = _read_active_recommendations(confirm_dir)
        except (OSError, json.JSONDecodeError, ValueError) as exc:
            return jsonify({
                'error': f'invalid current recommendation file: {exc}',
            }), 400
        rec_stage_number = _recommendation_stage(data)
        if rec_stage_number == 1:
            stage1_error = _stage1_ready_error(confirm_dir)
            if stage1_error:
                return jsonify({
                    'error': f'Stage 1 is not ready: {stage1_error}',
                }), 409
            try:
                template_options, _ = _build_template_options(confirm_dir)
            except (OSError, json.JSONDecodeError, ValueError) as exc:
                return jsonify({
                    'error': f'invalid template options: {exc}',
                }), 409
            data['template_options'] = template_options
            template_required = False
        else:
            stage2_error = _stage2_ready_error(
                project_path,
                confirm_dir,
                rec_file,
            )
            if stage2_error:
                return jsonify({
                    'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
                }), 409
            try:
                template_required = _template_confirmation_required(project_path)
            except (OSError, json.JSONDecodeError, ValueError) as exc:
                return jsonify({
                    'error': f'cannot determine active template mode: {exc}',
                }), 409
        # Later stages render only downstream sections, so fold earlier confirmed
        # choices from result.json back in. An in-run refresh then re-inits from
        # the user's choices instead of catalog defaults.
        if rec_stage_number >= 2 and result_file.exists():
            _merge_confirmed_choices(data, result_file)
        language_error = _canonicalize_primary_language(
            data,
            required=True,
        )
        if language_error:
            return jsonify({'error': language_error}), 409
        if not template_required:
            data.pop('template_application', None)
        if rec_stage_number == 2:
            recommendation_error = _template_stage2_error(
                data,
                template_required=template_required,
            )
            if recommendation_error:
                return jsonify({'error': recommendation_error}), 409
            recommendation_error = _stage2_custom_candidates_error(data)
            if recommendation_error:
                return jsonify({'error': recommendation_error}), 409
            recommendation_error = _stage2_design_directions_error(data)
            if recommendation_error:
                return jsonify({'error': recommendation_error}), 409
        if rec_stage_number == 2:
            proactive_values, proactive_error = (
                _resolve_proactive_execution_values(data)
            )
            if proactive_error:
                return jsonify({'error': proactive_error}), 409
            for key, value in proactive_values.items():
                data[key] = {'value': value}
        # Template application is authored by Strategist from the installed
        # workspace and current content. Never expose legacy mode fields as
        # user-facing confirmation controls.
        recommend = data.get('recommend')
        if isinstance(recommend, dict):
            recommend.pop('template_reuse_scope', None)
            recommend.pop('template_adherence', None)
        data.pop('template_reuse_scope', None)
        data.pop('template_adherence', None)
        # The page polls this endpoint after each confirmation until the AI
        # creates the next stage file, so it must never be cached.
        resp = jsonify(data)
        resp.headers['Cache-Control'] = 'no-store'
        return resp

    @app.route('/api/confirm', methods=['POST'])
    def confirm():
        """Persist the user's final choices to result.json for the AI to read."""
        payload = request.get_json(silent=True)
        if not isinstance(payload, dict):
            return jsonify({'error': 'invalid payload'}), 400
        confirm_dir.mkdir(parents=True, exist_ok=True)
        result = dict(payload)
        template_selection_payload = result.pop('template_selection', None)
        result_file = confirm_dir / RESULT_NAME
        raw_stage = result.get('stage')
        stage = _stage_key(raw_stage)
        if raw_stage is not None and stage is None:
            return jsonify({'error': 'invalid confirmation stage'}), 400
        try:
            rec_file, current_recommendations = _read_active_recommendations(
                confirm_dir,
            )
        except (OSError, json.JSONDecodeError, ValueError) as exc:
            return jsonify({
                'error': (
                    'cannot confirm without valid current recommendations: '
                    f'{exc}'
                ),
            }), 409
        rec_stage_number = _recommendation_stage(current_recommendations)
        selection_receipt = None
        selection_file = confirm_dir / TEMPLATE_SELECTION_NAME
        write_selection = False
        if rec_stage_number == 1:
            stage1_error = _stage1_ready_error(confirm_dir)
            if stage1_error:
                return jsonify({
                    'error': f'Stage 1 is not ready: {stage1_error}',
                }), 409
            if not isinstance(template_selection_payload, dict):
                return jsonify({
                    'error': (
                        'Stage 1 payload must include template_selection with '
                        'mode and selection_keys'
                    ),
                }), 400
            try:
                template_options, template_candidates = _build_template_options(
                    confirm_dir,
                )
                selection_receipt = _resolve_template_confirmation(
                    template_selection_payload,
                    template_candidates,
                    template_options['options_sha256'],
                )
            except (OSError, json.JSONDecodeError, ValueError) as exc:
                return jsonify({
                    'error': f'invalid Stage 1 template selection: {exc}',
                }), 400
            template_required = selection_receipt['mode'] == 'templates'
            if selection_file.exists():
                try:
                    existing_selection = _read_template_selection(selection_file)
                except (OSError, json.JSONDecodeError, ValueError) as exc:
                    return jsonify({
                        'error': (
                            f'existing template selection is invalid: {exc}; '
                            'the agent must run --reset-template-selection'
                        ),
                    }), 409
                if (
                    existing_selection['selection_sha256']
                    != selection_receipt['selection_sha256']
                ):
                    return jsonify({
                        'error': (
                            'Stage 1 already has a different template selection; '
                            'the agent must run --reset-template-selection'
                        ),
                    }), 409
            else:
                write_selection = True
        else:
            if template_selection_payload is not None:
                return jsonify({
                    'error': 'template_selection is accepted only in Stage 1',
                }), 400
            stage2_error = _stage2_ready_error(
                project_path,
                confirm_dir,
                rec_file,
            )
            if stage2_error:
                return jsonify({
                    'error': f'Stage 2 is waiting for template handoff: {stage2_error}',
                }), 409
            try:
                template_required = _template_confirmation_required(project_path)
            except (OSError, json.JSONDecodeError, ValueError) as exc:
                return jsonify({
                    'error': f'cannot determine active template mode: {exc}',
                }), 409
        stage_error = _submission_stage_error(
            confirm_dir,
            stage,
            recommendations_file=rec_file,
            recommendations=current_recommendations,
            template_required=template_required,
        )
        if stage_error:
            return jsonify({'error': stage_error}), 409
        custom_error = _custom_selection_error(result)
        if custom_error:
            return jsonify({'error': custom_error}), 400
        previous_result = {}
        if rec_stage_number >= 2:
            try:
                previous_result = _read_json_object(result_file)
            except (OSError, json.JSONDecodeError, ValueError):
                pass
        main_language = None
        if rec_stage_number > 0:
            language_source = (
                previous_result
                if _recommendation_language(previous_result)
                else current_recommendations
            )
            if not _recommendation_language(language_source):
                language_source = result
            language_error = _canonicalize_primary_language(
                language_source,
                required=True,
            )
            if language_error:
                return jsonify({'error': language_error}), 409
            main_language = _recommendation_language(language_source)
            if main_language:
                result['primary_language'] = main_language
            else:
                result.pop('primary_language', None)
        if rec_stage_number == 2:
            solution_error = _stage2_solution_error(
                result,
                main_language=main_language,
            )
            if solution_error:
                return jsonify({'error': solution_error}), 400
        if rec_stage_number == 2:
            proactive_defaults, proactive_recommendation_error = (
                _resolve_proactive_execution_values(current_recommendations)
            )
            if proactive_recommendation_error:
                return jsonify({
                    'error': proactive_recommendation_error,
                }), 409
            proactive_result_error = _normalize_proactive_execution_result(
                result,
                proactive_defaults,
            )
            if proactive_result_error:
                return jsonify({'error': proactive_result_error}), 400
        _normalize_custom_selections(result)
        locked_values = _apply_locked_recommendations(
            result,
            rec_file,
            result_file,
            carry_previous=rec_stage_number > 1,
        )
        if rec_stage_number == 1 or not template_required:
            result.pop('template_application', None)
            locked_values.pop('template_application', None)
        result.pop('template_reuse_scope', None)
        result.pop('template_adherence', None)
        if stage == 'stage1':
            result['stage'] = 'stage1'
            result['status'] = 'stage1-confirmed'
            if locked_values:
                result[_LOCKED_RECOMMENDATIONS_KEY] = locked_values
        else:
            result.pop(_LOCKED_RECOMMENDATIONS_KEY, None)
            result['stage'] = 'final'
            result['status'] = 'confirmed'
        result['confirmed_at'] = time.strftime('%Y-%m-%dT%H:%M:%S')
        if write_selection and selection_receipt is not None:
            _write_json_atomic(selection_file, selection_receipt)
        _write_json_atomic(result_file, result)
        _sync_session_state(
            confirm_dir,
            server_port=app.config.get('SERVER_PORT'),
            event=f'{result["stage"]}-submitted',
        )
        logger.info('%s confirmation written to %s', result['stage'], result_file)
        return jsonify({'status': 'ok'})

    return app


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description='PPT Master template and Strategist confirmation UI',
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument('project_dir', help='Path to project directory')
    parser.add_argument(
        '--port', type=int, default=None,
        help=f'Exact port to listen on (default: first free port from {DEFAULT_PORT})',
    )
    parser.add_argument('--no-browser', action='store_true', help='Do not auto-open browser')
    parser.add_argument(
        '--daemon', action='store_true',
        help='Start the server in the background; combine with --wait to block until confirmation',
    )
    parser.add_argument(
        '--wait', action='store_true',
        help='With --daemon, wait until the active result.json stage is written',
    )
    parser.add_argument(
        '--wait-only', action='store_true',
        help='Attach to the confirm server for this project and wait for an '
             'already-open page to write the requested receipt. If it is '
             'already persisted, return without recovery; otherwise recover a '
             'dead server on the recorded/default port so browser polling can resume.',
    )
    parser.add_argument(
        '--wait-stage', default='final', metavar='{stage1,final}',
        help='Wait for this result.json stage (default: final). Use stage1 '
             'after opening the combined template/communication page.',
    )
    parser.add_argument(
        '--wait-timeout', type=int, default=WAIT_TIMEOUT_DEFAULT,
        help=f'Seconds the wait caller blocks before returning (default: {WAIT_TIMEOUT_DEFAULT}; '
             '0 = no limit). Kept under the caller\'s tool timeout; the detached server lives on.',
    )
    parser.add_argument(
        '--timeout', type=int, default=900,
        help='Server idle timeout in seconds (default: 900; 0 = disabled)',
    )
    parser.add_argument(
        '--shutdown', action='store_true',
        help='Stop a confirm server left running for this project, then exit '
             '(idempotent). Run at the end of Step 4 so the page never lingers '
             'on its selected port before live preview starts.',
    )
    parser.add_argument(
        '--complete-template-selection', action='store_true',
        help='Agent-only: after Stage 1, bind its template selection to a ready '
             'handoff. Template mode requires <project>/templates/design_spec.md.',
    )
    parser.add_argument(
        '--reset-template-selection', action='store_true',
        help='Agent-only: remove exactly template_options.json, '
             'template_selection.json, and template_handoff.json before a '
             'fresh one-run UI lifecycle.',
    )
    return parser


def main(argv: Optional[list[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    logging.basicConfig(
        level=logging.INFO,
        format='[%(asctime)s] [%(levelname)s] confirm_ui: %(message)s',
        datefmt='%H:%M:%S',
    )

    if args.port is not None:
        try:
            args.port = _validate_port(args.port)
        except ValueError as exc:
            logger.error('%s', exc)
            return 2

    project_path = Path(args.project_dir).resolve()
    if not project_path.is_dir():
        logger.error('%s is not a directory', project_path)
        return 1
    wait_stage = _stage_key(str(args.wait_stage).strip().lower())
    if wait_stage not in {'stage1', 'final'}:
        logger.error('--wait-stage must be stage1 or final')
        return 2

    template_control = (
        args.complete_template_selection
        or args.reset_template_selection
    )
    if template_control and (
        args.daemon or args.wait or args.wait_only or args.shutdown
    ):
        logger.error(
            '--complete-template-selection/--reset-template-selection cannot be combined '
            'with server, wait, or shutdown actions'
        )
        return 2
    if args.complete_template_selection and args.reset_template_selection:
        logger.error(
            '--complete-template-selection and --reset-template-selection are '
            'mutually exclusive'
        )
        return 2
    if args.complete_template_selection:
        return _complete_template_selection(project_path)
    if args.reset_template_selection:
        return _reset_template_selection(project_path / CONFIRM_DIR_NAME)

    # Step 4 cleanup: stop any lingering confirm server and exit. Independent of
    # recommendation files (the page may never have been confirmed).
    if args.shutdown:
        return _shutdown_existing(project_path / LOCK_FILE_NAME)

    # Staged wait: attach to the server launched by --daemon and block until
    # the page writes the requested Strategist receipt.
    if args.wait_only:
        lock_file = project_path / LOCK_FILE_NAME
        confirm_dir = project_path / CONFIRM_DIR_NAME
        result_file = confirm_dir / RESULT_NAME
        wait_status = _wait_result_status(result_file, wait_stage)
        if wait_status is not None:
            return wait_status
        if wait_stage == 'stage1':
            readiness_error = _stage1_ready_error(confirm_dir)
        else:
            recommendations_file = _active_recommendations_path(confirm_dir)
            readiness_error = _stage2_ready_error(
                project_path,
                confirm_dir,
                recommendations_file,
            )
        if readiness_error:
            logger.error(
                'confirmation stage=%s is not ready: %s',
                wait_stage,
                readiness_error,
            )
            return 1
        if not _live_lock(lock_file):
            launch_error = _confirmation_launch_error(confirm_dir)
            if launch_error:
                logger.error('%s', launch_error)
                return 1
            exact_port = args.port is not None
            recovery_port = (
                args.port
                if exact_port
                else _preferred_recovery_port(lock_file, DEFAULT_PORT)
            )
            try:
                _, actual_port, _ = _launch_background_server(
                    project_path,
                    preferred_port=recovery_port,
                    exact_port=exact_port,
                    idle_timeout=args.timeout,
                    open_browser=False,
                )
            except RuntimeError as exc:
                logger.error('%s', exc)
                return 1
            if actual_port != recovery_port and not args.no_browser:
                webbrowser.open(_server_url(actual_port))
            logger.info(
                'recovered confirm UI for wait-only at %s; the browser polling should resume',
                _server_url(actual_port),
            )
        return _wait_only_for_result(
            result_file,
            lock_file,
            args.wait_timeout,
            wait_stage,
        )

    confirm_dir = project_path / CONFIRM_DIR_NAME
    launch_error = _confirmation_launch_error(confirm_dir)
    if launch_error:
        logger.error('%s', launch_error)
        return 1

    if args.daemon:
        lock_file = project_path / LOCK_FILE_NAME
        existing = _read_lock(lock_file)
        if existing and _process_alive(_lock_pid(existing)):
            existing_pid = existing.get('pid', '?')
            existing_port = existing.get('port', '?')
            logger.error(
                'confirm UI is already running for this project '
                '(pid=%s, port=%s). Open http://%s:%s',
                existing_pid, existing_port, PUBLIC_HOST, existing_port,
            )
            return 1

        confirm_dir = project_path / CONFIRM_DIR_NAME
        result_file = confirm_dir / RESULT_NAME
        expected_stage = _expected_result_stage(confirm_dir)
        started_at = time.time()
        try:
            proc, port, _ = _launch_background_server(
                project_path,
                preferred_port=args.port if args.port is not None else DEFAULT_PORT,
                exact_port=args.port is not None,
                idle_timeout=args.timeout,
                open_browser=not args.no_browser,
            )
        except RuntimeError as exc:
            logger.error('%s', exc)
            return 1
        if args.wait:
            return _wait_for_result(
                result_file,
                proc,
                started_at,
                args.wait_timeout,
                expected_stage,
            )
        return 0

    try:
        port = args.port if args.port is not None else _find_free_port(DEFAULT_PORT)
    except RuntimeError as exc:
        logger.error('%s', exc)
        return 1

    # Per-project mutual exclusion: refuse duplicate launches. Stale locks
    # (dead pid) are overwritten by _claim_lock.
    lock_file = project_path / LOCK_FILE_NAME
    existing = _claim_lock(lock_file, port)
    if existing:
        existing_pid = existing.get('pid', '?')
        existing_port = existing.get('port', '?')
        logger.error(
            'confirm UI is already running for this project '
            '(pid=%s, port=%s). Open http://%s:%s, or run: kill %s',
            existing_pid, existing_port, PUBLIC_HOST, existing_port, existing_pid,
        )
        return 1
    atexit.register(_release_lock, lock_file)

    def _on_sigterm(signum: int, _frame) -> None:
        logger.info('received signal %s, exiting', signum)
        sys.exit(0)
    try:
        signal.signal(signal.SIGTERM, _on_sigterm)
    except (ValueError, OSError):
        pass

    app = create_app(
        str(project_path),
        idle_timeout=args.timeout,
        lock_file=lock_file,
        server_port=port,
    )

    url = _server_url(port)
    if not args.no_browser:
        _open_browser_async(url)

    logger.info('running at %s', url)
    logger.info('project: %s', project_path)
    logger.info('idle timeout: %ds (0 = disabled)', args.timeout)
    app.run(host=PUBLIC_HOST, port=port, debug=False)
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
