"""
AI 蓝图生成服务：描述 + Excel(+可选图片) → AppBlueprint draft
无 LLM Key 时走启发式规则，保证可离线测试。
领域无关：不写死库存/沉降等业务字段，由表头规范化 + 类型推断 + 蓝图 widgets 驱动。
"""

from __future__ import annotations

import asyncio
import hashlib
import io
import json
import re
import unicodedata
from pathlib import Path
from typing import Any

from fastapi import FastAPI, File, Form, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from openpyxl import load_workbook

from generation_rules import (
    DEFAULT_ACTION_LABELS,
    compose_effective_prompt,
    parse_action_bar,
    wants_screenshot_layout,
)
from html_layout import layout_summary_text, merge_layout_into_ui_hints, parse_layout_file
from llm import enhance_blueprint_with_llm, list_providers, load_ai_config, understand_images
from fidelity_loop import run_fidelity_loop
from generate_log import GenerateTrace, ensure_logging
from demo_fixtures import file_response, fixtures_manifest, read_prompt_text
from preview_store import create_preview, get_preview
import os

ensure_logging()

# 优先加载仓库根 .env，其次 ai-service/.env（容错编码，避免整服务起不来）
_root = Path(__file__).resolve().parents[1]


def _safe_load_dotenv(path: Path, *, override: bool = False) -> None:
    if not path.is_file():
        return
    raw = path.read_bytes()
    text: str | None = None
    for enc in ("utf-8-sig", "utf-8", "gbk", "cp936", "latin-1"):
        try:
            text = raw.decode(enc)
            break
        except UnicodeDecodeError:
            continue
    if text is None:
        print(f"WARN: cannot decode env file {path}, skip")
        return
    for line in text.splitlines():
        s = line.strip()
        if not s or s.startswith("#") or "=" not in s:
            continue
        key, _, val = s.partition("=")
        key = key.strip()
        val = val.strip().strip('"').strip("'")
        if not key:
            continue
        if override or key not in os.environ:
            os.environ[key] = val
    # 若曾用非 utf-8 读入，尝试写回 utf-8，避免下次再炸
    try:
        if raw[:3] != b"\xef\xbb\xbf":
            # 仅当原文件不是合法 utf-8 时重写
            try:
                raw.decode("utf-8")
            except UnicodeDecodeError:
                path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
                print(f"WARN: rewrote {path} as utf-8")
    except OSError:
        pass


_safe_load_dotenv(_root / ".env")
_safe_load_dotenv(Path(__file__).resolve().parent / ".env", override=True)

app = FastAPI(title="aijianzhan-ai", version="0.1.0")
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

_SYSTEM_FIELDS = {"id", "tenant_id", "created_by", "created_at", "updated_at"}


def slugify(text: str, fallback: str = "app") -> str:
    """通用标识符：驼峰/点号 → snake_case；纯中文用稳定短哈希，无业务词表。"""
    original = text or ""
    text = unicodedata.normalize("NFKC", original)
    text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text)
    text = text.lower()
    text = text.replace("-", "_").replace(".", "_")
    text = re.sub(r"[^a-z0-9_]+", "_", text)
    text = re.sub(r"_+", "_", text).strip("_")
    if not text or not re.match(r"^[a-z]", text):
        # 列名用 col_N；应用 slug 用短哈希，避免依赖中文词表
        if re.match(r"^col_\d+$", fallback):
            text = fallback
        else:
            h = hashlib.sha1(original.encode("utf-8")).hexdigest()[:8]
            text = f"{fallback}_{h}"
    return text[:48]


def to_field_name(header: str, idx: int) -> str:
    raw = slugify(header, fallback=f"col_{idx}")
    return raw if re.match(r"^[a-z]", raw) else f"col_{idx}"


def infer_app_name(prompt: str) -> str:
    """从需求描述提取应用显示名。"""
    p = prompt or ""
    for pat in (
        r"应用名称[推荐为是：:\s]*[「『\"]?([^」』\"\n，。；]{2,32})",
        r"[「『](.+?)[」』]",
        r"生成[一个]?(?:业务)?应用[：:\s]*([^\n，。；]{2,32})",
    ):
        m = re.search(pat, p)
        if m:
            name = m.group(1).strip()
            if name and name not in {"Excel", "xlsx", "CSV"}:
                return name[:32]
    for line in p.splitlines():
        line = line.strip("# ").strip()
        if 4 <= len(line) <= 32 and not line.startswith("http"):
            return line[:32]
    return "智能应用"


def infer_item_label(prompt: str, sheet_label: str) -> str:
    """业务对象称呼：提示词显式指定 > Excel 表名 >「记录」。"""
    p = prompt or ""
    m = re.search(r"(?:对象|实体|记录)名[称]?[推荐为是：:\s]*([^\n，。；]{1,16})", p)
    if m:
        return m.group(1).strip()[:16]
    if sheet_label and re.search(r"[\u4e00-\u9fff]", sheet_label):
        lab = re.sub(r"(表|列表|台账|数据|信息)$", "", sheet_label).strip()
        if lab and lab.lower() not in {"sheet1", "sheet"}:
            return lab[:16]
    if sheet_label and sheet_label.lower() not in {"sheet1", "sheet", "数据"}:
        return sheet_label[:16]
    return "记录"


def parse_ui_hints(prompt: str, image_count: int, html_count: int = 0) -> dict[str, Any]:
    """从用户需求提取展示元信息；截图/HTML 忠实策略由 generation_rules 决定。"""
    p = prompt or ""
    hints: dict[str, Any] = {"ui": {}}
    if wants_screenshot_layout(p, image_count, html_count):
        hints["ui_preset"] = "screenshot_faithful"

    m = re.search(r"平台抬头[：:]\s*(.+)", p)
    if m:
        hints["ui"]["platform_title"] = m.group(1).strip().splitlines()[0][:64]
    m = re.search(r"系统名称[：:]\s*(.+)", p)
    if m:
        hints["app_name"] = m.group(1).strip().splitlines()[0][:64]
    m = re.search(r"应用名称推荐[：:]\s*(.+)", p)
    if m and not hints.get("app_name"):
        hints["app_name"] = m.group(1).strip().splitlines()[0][:64]
    m = re.search(r"推荐 slug[：:]\s*`?([a-z][a-z0-9_]{1,47})`?", p)
    if m:
        hints["slug"] = m.group(1)
    m = re.search(r"工程上下文(?:示例)?[：:]\s*(.+)", p)
    if m:
        ctx = m.group(1).strip().splitlines()[0]
        ctx = re.split(r"[（(]", ctx, 1)[0].strip()
        hints["project_context"] = ctx[:120]
    # 常见「XX平台」抬头
    if "platform_title" not in hints["ui"]:
        m = re.search(r"([^\n]{2,20}管理平台)", p)
        if m:
            hints["ui"]["platform_title"] = m.group(1).strip()

    # 仅当提示词明文写出时才采纳（不写死行业）
    if "点击" in p and "查看" in p:
        m = re.search(r"(点击[^。\n]{2,40})", p)
        if m:
            hints["ui"]["filter_hint"] = m.group(1).strip()

    # 单选选项：全部 / A / B / C 或 **A / B / C**
    m = re.search(r"(?:全部\s*[/／]\s*)([^。\n]{2,40})", p)
    if m and ("单选" in p or "筛选" in p or "类型" in p):
        opts = [x.strip().strip("*` ") for x in re.split(r"[/／、，,]", m.group(1)) if x.strip()]
        opts = [o for o in opts if o and o != "全部" and len(o) <= 12][:8]
        if len(opts) >= 2:
            hints["ui"]["section_options"] = opts
            hints["filter_style"] = "section_radios"

    # 页面标题：仅匹配「标题：xxx」或明确业务页名行
    for key, pat in (
        ("list_title", r"(?:列表标题|列表页)[：:]\s*(.+)"),
        ("create_title", r"(?:新增页|表单标题|创建页)[：:]\s*(.+)"),
        ("dash_title", r"(?:看板标题|看板页)[：:]\s*(.+)"),
    ):
        m = re.search(pat, p)
        if m:
            hints[key] = m.group(1).strip().splitlines()[0][:64]

    action_bar = parse_action_bar(p)
    if action_bar:
        hints["actions"] = action_bar["actions"]
        hints["action_labels"] = action_bar["action_labels"]

    return hints


def wants_dashboard(prompt: str, image_count: int, html_count: int = 0) -> bool:
    p = prompt or ""
    keys = ("图表", "看板", "统计", "概览", "dashboard", "折线", "曲线", "纵断", "KPI", "可视化")
    if any(k.lower() in p.lower() if k.isascii() else k in p for k in keys):
        return True
    return image_count > 0 or html_count > 0


def wants_line_chart(prompt: str) -> bool:
    p = prompt or ""
    return any(k in p for k in ("折线", "曲线", "纵断", "趋势", "line chart", "时序"))


def build_dashboard_widgets(
    fields: list[dict[str, Any]],
    filters: list[str],
    list_cols: list[str],
    entity_name: str,
    item_label: str,
    prompt: str,
) -> list[dict[str, Any]]:
    """按字段类型与提示词组装 widgets；标题优先来自提示词/HTML，否则用通用名。"""
    usable = [f for f in fields if f["name"] not in _SYSTEM_FIELDS]
    names = {f["name"] for f in usable}
    numeric = [f for f in usable if f["type"] in {"int", "bigint", "decimal"}]
    categorical = [
        f["name"]
        for f in usable
        if f.get("enum_values") or f["type"] == "enum" or f["name"] in filters
    ]
    x_candidates = [f["name"] for f in usable if f["type"] in {"string", "enum", "date", "datetime"}]
    p = prompt or ""

    def pick_title(*cands: str, default: str) -> str:
        for c in cands:
            if c and c in p:
                # 取含该关键词的短句
                m = re.search(rf"([^\n：:]{{0,10}}{re.escape(c)}[^\n：:]{{0,16}})", p)
                if m:
                    t = m.group(1).strip(" -—|｜")
                    if 2 <= len(t) <= 32:
                        return t
                return c
        return default

    # 多数值 + 类目/编码轴 → 堆叠看板（图 + 可选状态条 + 表），不绑定行业
    stacked = (
        len(numeric) >= 2
        and (x_candidates or filters)
        and (
            wants_line_chart(p)
            or "看板" in p
            or "示意图" in p
            or "监督" in p
            or "status_strip" in p
            or bool({"chainage", "dkilo"} & names)
        )
    )

    if stacked:
        x_field = next((n for n in ("chainage", "dkilo", "code", "name") if n in names), None)
        if not x_field:
            x_field = x_candidates[0] if x_candidates else (list_cols[0] if list_cols else "id")
        metrics = [f["name"] for f in numeric[:4]]
        # 优先常见度量名（若存在）
        preferred = [n for n in (
            "value", "cjl_value", "amount", "qty", "score", "count",
            "design_value", "design_settlement_mm", "cum_value", "cum_settlement_mm",
            "pred_value", "pred_settlement_mm",
        ) if n in names]
        if preferred:
            # 主度量优先观测值，设计/累积作辅系列
            primary = next(
                (n for n in ("cjl_value", "value", "cum_value", "cum_settlement_mm") if n in preferred),
                preferred[0],
            )
            rest = [n for n in preferred if n != primary][:3]
            metrics = [primary] + rest
        widgets: list[dict[str, Any]] = [
            {
                "type": "line_chart",
                "title": pick_title("示意图", "趋势图", "曲线", default="趋势图"),
                "x_field": x_field,
                "group_by": x_field,
                "metrics": metrics,
                "metric": metrics[0] if metrics else "count",
                "y_unit": "mm" if any("mm" in n or n.endswith("_value") for n in metrics) else "",
                "entity": entity_name,
            },
        ]
        if "before_day" in names and "next_day" in names:
            widgets.append(
                {
                    "type": "status_strip",
                    "title": pick_title("监督", "状态条", "进度", default="状态"),
                    "label_field": x_field,
                    "value_field": "before_day",
                    "secondary_field": "next_day",
                    "warn_field": next((n for n in ("overdue_days", "exceed_days", "warn_flag") if n in names), ""),
                    "variant": "stacked_days",
                    "cycle_days": 30,
                    "entity": entity_name,
                }
            )
        elif "supervise_days" in names or "status_days" in names or "days" in names:
            value_f = next((n for n in ("supervise_days", "status_days", "days") if n in names), "supervise_days")
            widgets.append(
                {
                    "type": "status_strip",
                    "title": pick_title("状态条", "进度", "监督", default="状态"),
                    "label_field": x_field,
                    "value_field": value_f,
                    "warn_field": next((n for n in ("overdue_days", "exceed_days", "warn_flag") if n in names), ""),
                    "entity": entity_name,
                }
            )
        table_cols = list_cols[:6] or [f["name"] for f in usable[:6]]
        widgets.append(
            {
                "type": "table",
                "title": pick_title("明细", "列表", "异常", default=f"{item_label}明细"),
                "entity": entity_name,
                "columns": table_cols,
                "filter_field": next((n for n in ("exceed_mm", "status", "flag") if n in names), ""),
                "filter_op": "gt" if "exceed_mm" in names else "eq",
                "filter_value": 0 if "exceed_mm" in names else "",
            }
        )
        return widgets

    widgets = [
        {"type": "kpi", "title": f"{item_label}数量", "metric": "count", "entity": entity_name},
    ]

    if wants_line_chart(prompt) or (len(numeric) >= 2 and x_candidates):
        x_field = x_candidates[0] if x_candidates else (list_cols[0] if list_cols else "id")
        metrics = [f["name"] for f in numeric[:4]]
        widgets.append(
            {
                "type": "line_chart",
                "title": "趋势图",
                "x_field": x_field,
                "group_by": x_field,
                "metrics": metrics,
                "metric": metrics[0] if metrics else "count",
                "entity": entity_name,
            }
        )

    g = categorical[0] if categorical else (filters[0] if filters else None)
    if g:
        chart_type = "bar_chart" if wants_line_chart(prompt) else "pie_chart"
        widgets.append(
            {
                "type": chart_type,
                "title": "分布",
                "metric": "count",
                "group_by": g,
                "entity": entity_name,
            }
        )
    elif not any(w["type"] == "line_chart" for w in widgets):
        g2 = list_cols[0] if list_cols else "id"
        widgets.append(
            {
                "type": "pie_chart",
                "title": "分布",
                "metric": "count",
                "group_by": g2,
                "entity": entity_name,
            }
        )
    return widgets


_ALLOWED_TYPES = {
    "string", "text", "int", "bigint", "decimal", "boolean",
    "date", "datetime", "enum", "json", "file_ref",
}
_TYPE_ALIAS = {
    "float": "decimal", "float32": "decimal", "float64": "decimal",
    "double": "decimal", "number": "decimal", "numeric": "decimal",
    "integer": "int", "int32": "int", "int64": "bigint", "long": "bigint",
    "bool": "boolean", "varchar": "string", "str": "string",
    "timestamp": "datetime", "timestamptz": "datetime",
}


def looks_like_faithful_dashboard(draft: dict[str, Any]) -> bool:
    """看板是否应按截图壳渲染：已标 preset，或为「图+条/表」分区结构（不限行业）。"""
    for p in draft.get("pages") or []:
        if not isinstance(p, dict) or p.get("type") != "dashboard":
            continue
        lay = p.get("layout") or {}
        if not isinstance(lay, dict):
            continue
        if lay.get("preset") in {"screenshot_faithful", "ops_monitor"}:
            return True
        widgets = lay.get("widgets") or []
        types = {str(w.get("type") or "") for w in widgets if isinstance(w, dict)}
        # 截图类看板常见：主图 +（状态条或明细表），而非仅 KPI
        if "line_chart" in types and ("status_strip" in types or "table" in types):
            return True
        if "bar_chart" in types and "table" in types and "kpi" in types and len(types) >= 3:
            return True
    return False


def apply_screenshot_faithful(draft: dict[str, Any], *, force: bool = False) -> dict[str, Any]:
    """通用：写入 ui_preset / layout.preset，并补齐主图/监督条默认，避免主图区空白。"""
    if not force and not looks_like_faithful_dashboard(draft):
        return draft
    meta = draft.setdefault("meta", {})
    meta["ui_preset"] = "screenshot_faithful"
    ui = meta.get("ui") if isinstance(meta.get("ui"), dict) else {}
    meta["ui"] = ui

    # 从主实体字段推断主图取值/颜色/设计曲线（领域无关：按常见后缀与颜色字段）
    fields: list[dict[str, Any]] = []
    for ent in draft.get("entities") or []:
        if isinstance(ent, dict) and ent.get("fields"):
            fields = list(ent["fields"])
            break
    names = {str(f.get("name") or "") for f in fields if isinstance(f, dict)}

    def first(*cands: str) -> str:
        for c in cands:
            if c in names:
                return c
        return ""

    if not ui.get("value_field"):
        vf = first("cjl_value", "value", "cum_value", "cum_settlement_mm", "amount", "qty", "score")
        if vf:
            ui["value_field"] = vf
    if not ui.get("color_field"):
        cf = first("cjl_color", "color", "mark_color")
        if cf:
            ui["color_field"] = cf
    if not ui.get("design_field"):
        df = first("design_value", "design_settlement_mm", "target", "design")
        if df:
            ui["design_field"] = df
    if not ui.get("category_field"):
        cat = first("section_type", "category", "type", "kind")
        if cat:
            ui["category_field"] = cat
    # 截图还原默认着色测点图（有取值字段时）；显式 line/multi_series 除外
    style = str(ui.get("chart_style") or "").lower()
    if style not in {"line", "multi_series", "line_chart", "section_marks", "colored_marks"}:
        if ui.get("value_field") or first("cjl_value", "value"):
            ui["chart_style"] = "section_marks"
    if not ui.get("y_unit") and (
        ui.get("value_field", "").endswith("_mm")
        or "settlement" in ui.get("value_field", "")
        or ui.get("value_field") in {"cjl_value", "cum_value", "design_value"}
    ):
        ui["y_unit"] = "mm"
    if not ui.get("y_axis_label") and ui.get("y_unit") == "mm":
        ui["y_axis_label"] = "沉降量"
    if "invert_y" not in ui and ui.get("chart_style") in {"section_marks", "colored_marks"}:
        ui["invert_y"] = True
    if not ui.get("legend_items"):
        # 用单选选项生成占位图例色，避免图例全空
        radios = ui.get("filter_radios") or ui.get("section_options") or []
        palette = ["#8000FF", "#214080", "#868e96", "#0f766e", "#e8590c"]
        items = []
        for i, lab in enumerate(radios):
            if not lab or lab in {"全部", "所有", "all", "All"}:
                continue
            items.append({"label": f"{lab}累计", "color": palette[i % len(palette)]})
        if ui.get("design_field"):
            items.append({"label": "设计曲线", "color": ui.get("design_series_color") or "#e8590c"})
        if items:
            ui["legend_items"] = items

    for p in draft.get("pages") or []:
        if not isinstance(p, dict):
            continue
        if p.get("type") == "dashboard":
            lay = p.setdefault("layout", {})
            if isinstance(lay, dict):
                lay["preset"] = "screenshot_faithful"
                # 截图主区通常无顶部 KPI 卡片条；有图/条/表分区时去掉 kpi 以免破坏还原
                widgets = [w for w in (lay.get("widgets") or []) if isinstance(w, dict)]
                types = {w.get("type") for w in widgets}
                if ("line_chart" in types or "bar_chart" in types) and (
                    "status_strip" in types or "table" in types
                ):
                    widgets = [w for w in widgets if w.get("type") != "kpi"]
                # 补齐主图/监督条字段，避免渲染侧整段跳过
                x_fallback = first("chainage", "dkilo", "code", "name") or (
                    next(
                        (
                            str(f.get("name"))
                            for f in fields
                            if isinstance(f, dict)
                            and f.get("type") in {"string", "enum", "date"}
                            and f.get("name") not in _SYSTEM_FIELDS
                        ),
                        "id",
                    )
                )
                for w in widgets:
                    if w.get("type") in {"line_chart", "bar_chart"}:
                        w.setdefault("x_field", x_fallback)
                        w.setdefault("group_by", w.get("x_field") or x_fallback)
                        if not w.get("metrics") and not w.get("metric"):
                            m0 = ui.get("value_field") or first(
                                "cjl_value", "value", "cum_value", "cum_settlement_mm"
                            )
                            if m0:
                                w["metric"] = m0
                                w["metrics"] = [m0]
                                if ui.get("design_field"):
                                    w["metrics"] = [m0, ui["design_field"]]
                        if ui.get("y_unit"):
                            w.setdefault("y_unit", ui["y_unit"])
                    if w.get("type") == "status_strip":
                        w.setdefault("label_field", x_fallback)
                        if "before_day" in names and "next_day" in names:
                            w.setdefault("value_field", "before_day")
                            w.setdefault("secondary_field", "next_day")
                            w.setdefault("variant", "stacked_days")
                            w.setdefault("cycle_days", 30)
                        elif not w.get("value_field"):
                            w["value_field"] = first(
                                "supervise_days", "status_days", "before_day", "days"
                            ) or "supervise_days"
                lay["widgets"] = widgets
            title = str(p.get("title") or "")
            # 去掉套话「概览」，保留业务名；不改写成固定行业标题
            if title.endswith("概览") and len(title) > 2:
                p["title"] = title[: -len("概览")].rstrip() or title
                if not str(p["title"]).endswith("看板"):
                    p["title"] = f"{p['title']}看板" if p["title"] else "看板"
        if p.get("type") == "list":
            lay = p.setdefault("layout", {})
            if isinstance(lay, dict) and not lay.get("filter_style"):
                if ui.get("filter_style") or ui.get("section_options") or ui.get("filter_radios"):
                    lay["filter_style"] = ui.get("filter_style") or "section_radios"
    return draft


def harden_draft(draft: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
    """发布前收口：非法 type/空结构回退，避免 LLM 润色后无法 publish。"""
    if not isinstance(draft, dict) or not draft.get("entities"):
        return fallback
    draft.setdefault("version", "1.0")
    draft.setdefault("storage", fallback.get("storage") or {"mode": "schema_per_app", "engine": "postgres"})
    draft.setdefault("security", fallback.get("security") or {"visibility": "private", "roles": [], "row_policies": []})
    draft.setdefault("apis", fallback.get("apis"))
    draft.setdefault("pages", fallback.get("pages") or [])
    meta = draft.setdefault("meta", {})
    if not meta.get("slug"):
        meta["slug"] = (fallback.get("meta") or {}).get("slug") or "app"
    meta["slug"] = slugify(str(meta["slug"]), fallback="app")
    if not meta.get("name"):
        meta["name"] = (fallback.get("meta") or {}).get("name") or meta["slug"]
    entity_map: dict[str, str] = {}
    for ent in draft["entities"]:
        old = str(ent.get("name") or "")
        if not ent.get("name"):
            ent["name"] = "record"
        ent["name"] = slugify(str(ent["name"]), fallback="record")
        ent["table"] = slugify(str(ent.get("table") or ent["name"]), fallback=ent["name"])
        if old:
            entity_map[old] = ent["name"]
            entity_map[slugify(old, fallback=ent["name"])] = ent["name"]
        ent.setdefault("primary_key", "id")
        fields = ent.get("fields") or []
        if not fields:
            return fallback
        names = set()
        for i, f in enumerate(fields):
            fname = slugify(str(f.get("name") or f"col_{i}"), fallback=f"col_{i}")
            if fname in names:
                fname = f"{fname}_{i}"
            names.add(fname)
            f["name"] = fname
            t = str(f.get("type") or "string").lower().strip()
            t = _TYPE_ALIAS.get(t, t)
            if t not in _ALLOWED_TYPES:
                t = "string"
            f["type"] = t
        if not any(f.get("name") == ent["primary_key"] for f in fields):
            fields.insert(0, {
                "name": ent["primary_key"],
                "label": "ID",
                "type": "bigint",
                "nullable": False,
                "ui": {"widget": "hidden", "listable": False},
            })
            ent["fields"] = fields

    def resolve_entity(ref: Any) -> str:
        key = str(ref or "")
        if key in entity_map:
            return entity_map[key]
        sn = slugify(key, fallback="")
        if sn in entity_map:
            return entity_map[sn]
        return sn or key

    apis = draft.get("apis") or {}
    for r in apis.get("resources") or []:
        if isinstance(r, dict):
            r["entity"] = resolve_entity(r.get("entity"))
            path = str(r.get("path") or "").lstrip("/")
            r["path"] = "/" + (slugify(path, fallback="items") if path else "items")
    for p in draft.get("pages") or []:
        if isinstance(p, dict) and p.get("entity"):
            p["entity"] = resolve_entity(p.get("entity"))
    for rp in (draft.get("security") or {}).get("row_policies") or []:
        if isinstance(rp, dict) and rp.get("entity"):
            rp["entity"] = resolve_entity(rp.get("entity"))

    # 截图忠实：LLM 不得抹掉；有图或分区结构时强制恢复（不限行业）
    fb_meta = fallback.get("meta") or {}
    fb_src = fb_meta.get("source") or {}
    force_faithful = (
        fb_meta.get("ui_preset") == "screenshot_faithful"
        or fb_src.get("screenshot_faithful") is True
        or bool(fb_src.get("image_refs"))
        or looks_like_faithful_dashboard(fallback)
        or looks_like_faithful_dashboard(draft)
    )
    if force_faithful:
        if fb_meta.get("platform_title"):
            meta["platform_title"] = fb_meta["platform_title"]
        if fb_meta.get("ui"):
            meta["ui"] = {**(fb_meta.get("ui") or {}), **(meta.get("ui") or {})}
        if fb_meta.get("project_context") and not meta.get("project_context"):
            meta["project_context"] = fb_meta["project_context"]
        # 保留用户/启发式名称，禁止被润色成套话
        fb_name = str(fb_meta.get("name") or "")
        cur_name = str(meta.get("name") or "")
        if fb_name and (not cur_name or "数据管理" in cur_name or cur_name.endswith("概览")):
            meta["name"] = fb_name
        apply_screenshot_faithful(draft, force=True)

    # 功能按键文案：LLM 不得抹掉用户/截图还原的 labels
    for p, fp in zip(draft.get("pages") or [], fallback.get("pages") or []):
        if not isinstance(p, dict) or not isinstance(fp, dict):
            continue
        flay = fp.get("layout") or {}
        if not isinstance(flay, dict):
            continue
        lay = p.setdefault("layout", {})
        if not isinstance(lay, dict):
            continue
        if flay.get("action_labels"):
            merged = dict(flay.get("action_labels") or {})
            merged.update(lay.get("action_labels") or {})
            for k, v in (flay.get("action_labels") or {}).items():
                merged[k] = v
            lay["action_labels"] = merged
        if flay.get("actions") and (
            not lay.get("actions") or len(lay.get("actions") or []) < 2
        ):
            lay["actions"] = list(flay["actions"])
        if flay.get("preset") == "screenshot_faithful":
            lay["preset"] = "screenshot_faithful"
        if flay.get("filter_style") and not lay.get("filter_style"):
            lay["filter_style"] = flay["filter_style"]
    return draft


def infer_type(values: list[Any]) -> tuple[str, list[str] | None, float]:
    samples = [v for v in values if v is not None and str(v).strip() != ""]
    if not samples:
        return "string", None, 0.4
    strs = [str(v).strip() for v in samples]
    uniq = sorted(set(strs))
    # 高基数字段不当 enum，避免导入新值失败
    if len(uniq) <= 8 and len(uniq) <= max(2, int(len(strs) * 0.25)) and len(strs) >= 4:
        if all(len(u) <= 32 for u in uniq):
            return "enum", uniq[:50], 0.85
    int_ok = True
    dec_ok = True
    for s in strs:
        try:
            if "." in s or "e" in s.lower():
                float(s)
                int_ok = False
            else:
                int(s)
        except ValueError:
            int_ok = False
            try:
                float(s)
            except ValueError:
                dec_ok = False
                break
    if int_ok:
        return "int", None, 0.9
    if dec_ok:
        return "decimal", None, 0.88
    if all(re.match(r"^\d{4}-\d{2}-\d{2}", s) for s in strs):
        return "datetime", None, 0.8
    avg_len = sum(len(s) for s in strs) / len(strs)
    if avg_len > 80:
        return "text", None, 0.7
    return "string", None, 0.75


def parse_excel(content: bytes) -> dict[str, Any]:
    wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
    # 默认首表（不按行业表名挑选）
    ws = wb[wb.sheetnames[0]] if wb.sheetnames else wb.active
    rows = list(ws.iter_rows(values_only=True))
    if not rows:
        return {"sheets": []}
    headers = [str(h).strip() if h is not None else f"col_{i}" for i, h in enumerate(rows[0])]
    data_rows = rows[1:2001]  # 最多采 2000 行做类型推断 / 蓝图
    cols: dict[str, list[Any]] = {h: [] for h in headers}
    sample_rows: list[list[Any]] = []
    for r in data_rows:
        if r is None or all(c is None or str(c).strip() == "" for c in r):
            continue
        vals = []
        for i, h in enumerate(headers):
            v = r[i] if i < len(r) else None
            cols[h].append(v)
            vals.append(v)
        sample_rows.append(vals)
    inferred = {}
    for h in headers:
        t, enum_vals, _ = infer_type(cols[h])
        inferred[h] = {"type": t, "enum_values": enum_vals}
    return {
        "sheets": [
            {
                "name": ws.title,
                "headers": headers,
                "sample_rows": sample_rows[:5],
                "row_dicts": [
                    {headers[i]: (r[i] if i < len(r) else None) for i in range(len(headers))}
                    for r in sample_rows[:500]
                ],
                "inferred": inferred,
                "columns": cols,
                "row_count": len(sample_rows),
            }
        ]
    }


def _chart_rows_from_json(data: dict[str, Any]) -> list[dict[str, Any]]:
    """展开并行数组图表 JSON（mcljChartData / cljdChartData）→ 行字典。不写死行业文案。"""
    mclj = data.get("mcljChartData") or {}
    cljd = data.get("cljdChartData") or {}
    dkilos = list(mclj.get("dkilo") or [])
    cjls = list(mclj.get("cjl") or [])
    sjcjls = list(mclj.get("sjcjl") or [])
    workinfos = list(cljd.get("workinfo_kilo") or [])
    befores = list(cljd.get("beforeDay") or cljd.get("before_day") or [])
    nexts = list(cljd.get("nextDay") or cljd.get("next_day") or [])
    freqs = list(cljd.get("frequency") or [])
    # 颜色 → 分组N（按出现顺序）；不臆造路基/桥梁等业务词
    color_to_group: dict[str, str] = {}
    rows: list[dict[str, Any]] = []
    for i, dkilo in enumerate(dkilos):
        parts = str(dkilo).split("_")
        point_code = parts[0] if parts else str(dkilo)
        mileage_m = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
        chainage = ""
        if mileage_m is not None:
            km, rem = divmod(mileage_m, 1000)
            chainage = f"DK{km}+{rem:03d}"
        cjl = cjls[i] if i < len(cjls) else {}
        if not isinstance(cjl, dict):
            cjl = {"value": cjl, "color": ""}
        try:
            cjl_value = float(str(cjl.get("value") or 0).replace(",", ""))
        except ValueError:
            cjl_value = 0.0
        color = str(cjl.get("color") or "").strip().lower()
        try:
            curve = abs(float(sjcjls[i])) if i < len(sjcjls) else 0.0
        except (TypeError, ValueError):
            curve = 0.0
        design = 3.0 if curve >= 10 else (curve or 3.0)
        cum_abs = abs(cjl_value)
        exceed = round(max(0.0, cum_abs - design), 2)
        if color and color not in color_to_group:
            color_to_group[color] = f"分组{len(color_to_group) + 1}"
        section = color_to_group.get(color) or f"分组{(i % 4) + 1}"
        before = float(befores[i]) if i < len(befores) else 0.0
        nxt = float(nexts[i]) if i < len(nexts) else 0.0
        freq = float(freqs[i]) if i < len(freqs) else 0.0
        rows.append(
            {
                "dkilo": str(dkilo),
                "chainage": chainage or str(dkilo),
                "point_code": point_code,
                "section_type": section,
                "worksite": f"分组{i // 40 + 1}",
                "value": cjl_value,
                "color": color,
                "cjl_value": cjl_value,
                "cjl_color": color,
                "design_value": design,
                "cum_value": cjl_value,
                "pred_value": round(cjl_value * 0.9, 2),
                "exceed_mm": exceed,
                "exceed_days": int(round(exceed)) if exceed > 0 else 0,
                "supervise_days": int(round(before)) if before > 1 else 28,
                "overdue_days": max(0, int(round(nxt / 30)) if nxt > 50 else 0),
                "before_day": before,
                "next_day": nxt,
                "frequency": freq,
                "workinfo_kilo": workinfos[i] if i < len(workinfos) else "",
                "status": "异常" if exceed > 0 else "正常",
            }
        )
    return rows


def _flatten_json_object(obj: dict[str, Any], prefix: str = "") -> dict[str, Any]:
    out: dict[str, Any] = {}
    for k, v in obj.items():
        key = slugify(f"{prefix}_{k}" if prefix else str(k), fallback="field")
        if isinstance(v, dict) and "value" in v:
            out[key] = v.get("value")
            if "color" in v:
                out[f"{key}_color"] = v.get("color")
        elif isinstance(v, dict):
            out.update(_flatten_json_object(v, key))
        elif isinstance(v, list):
            out[key] = json.dumps(v, ensure_ascii=False)
        else:
            out[key] = v
    return out


def _rows_to_sheet(name: str, row_dicts: list[dict[str, Any]], *, link_field: str | None = None, parent: str | None = None) -> dict[str, Any]:
    if not row_dicts:
        return {"name": name, "headers": [], "sample_rows": [], "inferred": {}, "columns": {}, "row_count": 0}
    headers = list(row_dicts[0].keys())
    for r in row_dicts[1:]:
        for k in r:
            if k not in headers:
                headers.append(k)
    # 用全量做类型推断（上限 2000）
    use = row_dicts[:2000]
    cols: dict[str, list[Any]] = {h: [r.get(h) for r in use] for h in headers}
    inferred = {}
    for h in headers:
        t, enum_vals, _ = infer_type(cols[h])
        inferred[h] = {"type": t, "enum_values": enum_vals}
    sheet: dict[str, Any] = {
        "name": name,
        "headers": headers,
        "sample_rows": [[r.get(h) for h in headers] for r in use[:5]],
        "row_dicts": use[:500],
        "inferred": inferred,
        "columns": cols,
        "source": "json",
        "row_count": len(row_dicts),
    }
    if link_field:
        sheet["link_field"] = link_field
    if parent:
        sheet["parent_entity"] = parent
    return sheet


def parse_json_table(content: bytes) -> dict[str, Any]:
    """JSON → 一张或多张关联表（sheets），按结构拆实体。"""
    text = content.decode("utf-8-sig")
    data = json.loads(text)
    sheets: list[dict[str, Any]] = []

    if isinstance(data, dict) and ("mcljChartData" in data or "cljdChartData" in data):
        points = _chart_rows_from_json(data)
        primary = "chart_points"
        sheets.append(_rows_to_sheet(primary, points))
        # 并行状态数组拆成关联表（按 dkilo 关联）
        cljd = data.get("cljdChartData") or {}
        mclj = data.get("mcljChartData") or {}
        dkilos = list(mclj.get("dkilo") or [])
        workinfos = list(cljd.get("workinfo_kilo") or [])
        befores = list(cljd.get("beforeDay") or cljd.get("before_day") or [])
        nexts = list(cljd.get("nextDay") or cljd.get("next_day") or [])
        freqs = list(cljd.get("frequency") or [])
        superv = []
        for i, dk in enumerate(dkilos):
            superv.append(
                {
                    "dkilo": str(dk),
                    "workinfo_kilo": workinfos[i] if i < len(workinfos) else "",
                    "before_day": float(befores[i]) if i < len(befores) else 0,
                    "next_day": float(nexts[i]) if i < len(nexts) else 0,
                    "frequency": float(freqs[i]) if i < len(freqs) else 0,
                    "supervise_days": int(round(float(befores[i]))) if i < len(befores) and float(befores[i]) > 1 else 28,
                }
            )
        if superv:
            sheets.append(
                _rows_to_sheet(
                    "status_series",
                    superv,
                    link_field="dkilo",
                    parent=primary,
                )
            )
        exceed = [
            {
                "dkilo": r["dkilo"],
                "worksite": r.get("worksite"),
                "point_code": r.get("point_code"),
                "design_value": r.get("design_value"),
                "cum_value": r.get("cum_value"),
                "exceed_mm": r.get("exceed_mm"),
                "exceed_days": r.get("exceed_days"),
                "status": r.get("status"),
            }
            for r in points
            if float(r.get("exceed_mm") or 0) > 0
        ]
        if exceed:
            sheets.append(
                _rows_to_sheet(
                    "alert_points",
                    exceed,
                    link_field="dkilo",
                    parent=primary,
                )
            )
        return {"sheets": sheets, "relations": [
            {"from": "status_series", "to": primary, "on": "dkilo"},
            {"from": "alert_points", "to": primary, "on": "dkilo"},
        ]}

    if isinstance(data, list) and data and all(isinstance(x, dict) for x in data):
        return {"sheets": [_rows_to_sheet("records", [_flatten_json_object(x) for x in data])]}

    if isinstance(data, dict):
        # 多个 list[object] → 多实体；尝试 *_id 关联
        list_keys = [
            k for k, v in data.items()
            if isinstance(v, list) and v and isinstance(v[0], dict)
        ]
        if len(list_keys) >= 1:
            for k in list_keys:
                rows = [_flatten_json_object(x) for x in data[k]]
                sheets.append(_rows_to_sheet(slugify(k, fallback="records"), rows))
            relations = []
            names = [s["name"] for s in sheets]
            for s in sheets:
                for h in s.get("headers") or []:
                    if h.endswith("_id") or h.endswith("id") and h != "id":
                        base = h[:-3] if h.endswith("_id") else h
                        # 匹配其他实体名
                        for other in names:
                            if other != s["name"] and (base in other or other.startswith(base)):
                                relations.append({"from": s["name"], "to": other, "on": h})
                                s["link_field"] = h
                                s["parent_entity"] = other
                    # 同名字段关联
                    for other in sheets:
                        if other["name"] == s["name"]:
                            continue
                        common = set(s.get("headers") or []) & set(other.get("headers") or [])
                        common -= {"id", "created_at", "updated_at", "tenant_id"}
                        if len(common) == 1:
                            on = next(iter(common))
                            relations.append({"from": s["name"], "to": other["name"], "on": on})
                            s.setdefault("link_field", on)
                            s.setdefault("parent_entity", other["name"])
            return {"sheets": sheets, "relations": relations}

        for key in ("items", "rows", "data", "records", "list"):
            if isinstance(data.get(key), list) and data[key] and isinstance(data[key][0], dict):
                return {
                    "sheets": [
                        _rows_to_sheet(
                            slugify(key, fallback="records"),
                            [_flatten_json_object(x) for x in data[key]],
                        )
                    ]
                }
        return {"sheets": [_rows_to_sheet("records", [_flatten_json_object(data)])]}

    return {"sheets": []}


def parse_tabular_upload(filename: str, content: bytes) -> dict[str, Any]:
    name = (filename or "").lower()
    # 首页抓包：key/value（菜单 HTML + 图表 JSON + 统计 JSON …）
    if name.endswith(".config") or name in {"url.config", "url_config", "capture.config"}:
        from url_capture import parse_url_config

        cap = parse_url_config(content)
        warnings = list(cap.get("warnings") or [])
        chart = cap.get("chart_json")
        if not chart:
            return {
                "sheets": [],
                "capture": cap,
                "warnings": warnings + ["url.config 中未找到图表 JSON（mcljChartData/cljdChartData）"],
            }
        raw = json.dumps(chart, ensure_ascii=False).encode("utf-8")
        meta = parse_json_table(raw)
        meta["capture"] = cap
        meta["warnings"] = warnings
        meta.setdefault("ui_hints", {})
        if cap.get("nav_items"):
            meta["ui_hints"]["nav_items"] = cap["nav_items"]
        stats = cap.get("stats_json") or {}
        if isinstance(stats, dict) and stats:
            meta["ui_hints"]["stats_raw"] = stats
        if cap.get("exceed_json") is None and any(
            p.get("empty") and "gonghou" in (p.get("action") or "").lower()
            for p in (cap.get("pairs") or [])
        ):
            warnings.append(
                "hightchartGonghouCjlCX 返回为空：超限表由主图表数据按 exceed 条件推导（非原接口）"
            )
            meta["warnings"] = warnings
        return meta
    if name.endswith(".json"):
        return parse_json_table(content)
    if name.endswith(".csv"):
        # 简易 CSV：首行表头
        text = content.decode("utf-8-sig")
        import csv as _csv

        reader = _csv.DictReader(io.StringIO(text))
        rows = list(reader)[:5000]
        if not rows:
            return {"sheets": []}
        headers = list(rows[0].keys())
        use = rows[:2000]
        cols: dict[str, list[Any]] = {h: [r.get(h) for r in use] for h in headers}
        inferred = {h: {"type": infer_type(cols[h])[0], "enum_values": infer_type(cols[h])[1]} for h in headers}
        return {
            "sheets": [
                {
                    "name": "csv_import",
                    "headers": headers,
                    "sample_rows": [[r.get(h) for h in headers] for r in use[:5]],
                    "inferred": inferred,
                    "columns": cols,
                    "source": "csv",
                    "row_count": len(rows),
                }
            ]
        }
    return parse_excel(content)



def _fields_from_sheet(sheet: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str], list[str], list[str], list[float]]:
    """从一张 sheet 推断字段定义。"""
    headers = sheet.get("headers") or []
    inferred = sheet.get("inferred") or {}
    cols = sheet.get("columns") or {}
    fields: list[dict[str, Any]] = [
        {
            "name": "id",
            "label": "ID",
            "type": "bigint",
            "nullable": False,
            "ui": {"widget": "hidden", "listable": False},
        }
    ]
    filters: list[str] = []
    sorts: list[str] = []
    list_cols: list[str] = []
    confidences: list[float] = []
    for i, h in enumerate(headers):
        fname = to_field_name(h, i)
        if fname == "id":
            fname = f"col_{i}"
        info = inferred.get(h, {"type": "string", "enum_values": None})
        ftype = info["type"]
        _, _, c = infer_type(cols.get(h, []))
        confidences.append(c)
        field: dict[str, Any] = {
            "name": fname,
            "label": str(h)[:64],
            "type": ftype,
            "nullable": True,
            "max_length": 128 if ftype == "string" else None,
            "ui": {
                "widget": "select" if ftype == "enum" else "input",
                "listable": True,
                "filterable": ftype in {"enum", "string"},
                "sortable": ftype in {"int", "decimal", "datetime", "date", "string"},
                "width": "md",
            },
            "from_excel": {
                "column": h,
                "sample_values": [str(x) for x in cols.get(h, [])[:3] if x is not None],
            },
        }
        if ftype == "string":
            uniq = sorted(
                {str(v).strip() for v in cols.get(h, []) if v is not None and str(v).strip() != ""}
            )
            id_like = any(x in fname for x in ("code", "dkilo", "chainage", "kilo", "id", "name", "color"))
            if not id_like and 1 < len(uniq) <= 8 and len(uniq) <= max(2, int(len(cols.get(h, [])) * 0.25)):
                ftype = "enum"
                field["type"] = "enum"
                field["enum_values"] = uniq
                field["ui"]["widget"] = "select"
                field["ui"]["filterable"] = True
        if ftype == "enum" and info.get("enum_values"):
            field["enum_values"] = info["enum_values"][:24]
            field["nullable"] = True
            field["ui"]["widget"] = "select"
        if ftype == "decimal":
            field["precision"] = 12
            field["scale"] = 2
            field["ui"]["widget"] = "number"
        if ftype == "int":
            field["ui"]["widget"] = "number"
        if ftype == "datetime":
            field["ui"]["widget"] = "datetime"
        if ftype == "date":
            field["ui"]["widget"] = "datepicker"
        field = {k: v for k, v in field.items() if v is not None}
        fields.append(field)
        list_cols.append(fname)
        if field["ui"].get("filterable"):
            filters.append(fname)
        if field["ui"].get("sortable"):
            sorts.append(fname)
    return fields, filters, sorts, list_cols, confidences



def build_blueprint(
    prompt: str,
    excel_meta: dict[str, Any] | None,
    storage_mode: str,
    image_count: int,
    html_count: int = 0,
) -> tuple[dict[str, Any], list[str], float]:
    warnings: list[str] = []
    confidences: list[float] = []

    app_name = infer_app_name(prompt or "")
    slug = slugify(app_name, fallback="app")

    entities: list[dict[str, Any]] = []
    resources: list[dict[str, Any]] = []
    pages: list[dict[str, Any]] = []
    primary_entity = "record"
    primary_fields: list[dict[str, Any]] = []
    primary_filters: list[str] = []
    primary_list_cols: list[str] = []
    label = "数据"

    sheets = (excel_meta or {}).get("sheets") or []
    relations = (excel_meta or {}).get("relations") or []

    if sheets:
        for si, sheet in enumerate(sheets):
            ename = slugify(sheet.get("name") or f"record_{si}", fallback=f"record_{si}")
            if ename in {"sheet1", "sheet", "sheet_1"}:
                ename = "record" if si == 0 else f"record_{si}"
            fields, filters, sorts, list_cols, confs = _fields_from_sheet(sheet)
            confidences.extend(confs)
            ent: dict[str, Any] = {
                "name": ename,
                "table": ename,
                "label": str(sheet.get("name") or ename)[:32],
                "primary_key": "id",
                "fields": fields,
                "indexes": [],
            }
            if sheet.get("parent_entity") and sheet.get("link_field"):
                ent["relation"] = {
                    "parent": slugify(str(sheet["parent_entity"]), fallback="record"),
                    "on": to_field_name(str(sheet["link_field"]), 0),
                }
            entities.append(ent)
            path = f"/{ename}" if ename.endswith("s") else f"/{ename}s"
            resources.append(
                {
                    "entity": ename,
                    "path": path,
                    "operations": ["list", "get", "create", "update", "delete", "import", "export"],
                    "list": {
                        "default_page_size": 50,
                        "max_page_size": 2000,
                        "allowed_filters": filters[:12],
                        "allowed_sorts": sorts[:12],
                    },
                }
            )
            if si == 0:
                primary_entity = ename
                primary_fields = fields
                primary_filters = filters
                primary_list_cols = list_cols
                label = ent["label"]
                rc = sheet.get("row_count")
                if rc:
                    warnings.append(f"主表 {ename} 识别到约 {rc} 行，发布后请导入完整 xlsx/json")
            else:
                pages.append(
                    {
                        "id": f"{ename}_list",
                        "title": f"{ent['label']}",
                        "route": f"/{ename}",
                        "type": "list",
                        "entity": ename,
                        "layout": {
                            "columns": list_cols[:8],
                            "filters": filters[:5],
                            "actions": ["create", "edit", "delete", "export", "import", "refresh"],
                            "action_labels": dict(DEFAULT_ACTION_LABELS),
                        },
                    }
                )
        if relations:
            warnings.append(f"已按 JSON 字段关联生成 {len(relations)} 条表关系")
    else:
        warnings.append("未上传数据文件，已生成通用文本字段草案")
        confidences.append(0.55)
        primary_fields = [
            {"name": "id", "label": "ID", "type": "bigint", "nullable": False, "ui": {"widget": "hidden", "listable": False}},
            {"name": "title", "label": "标题", "type": "string", "nullable": False, "max_length": 128, "ui": {"widget": "input", "listable": True, "sortable": True}},
            {"name": "content", "label": "内容", "type": "text", "nullable": True, "ui": {"widget": "textarea", "listable": True}},
            {"name": "status", "label": "状态", "type": "enum", "nullable": True, "enum_values": ["草稿", "已发布"], "ui": {"widget": "select", "listable": True, "filterable": True}},
        ]
        primary_filters = ["status"]
        primary_list_cols = ["title", "content", "status"]
        entities = [{"name": "record", "table": "record", "label": "数据", "primary_key": "id", "fields": primary_fields, "indexes": []}]
        resources = [{
            "entity": "record",
            "path": "/records",
            "operations": ["list", "get", "create", "update", "delete", "import", "export"],
            "list": {"default_page_size": 50, "max_page_size": 2000, "allowed_filters": primary_filters, "allowed_sorts": ["title"]},
        }]

    if image_count > 0 and wants_screenshot_layout(prompt or "", image_count, html_count):
        warnings.append(f"已收到 {image_count} 张截图：展示页将按截图分区还原")
        confidences.append(0.75)
    elif image_count > 0:
        warnings.append(f"已收到 {image_count} 张截图（用户要求不按截图布局）")
        confidences.append(0.7)
    if html_count > 0:
        warnings.append(f"已收到 {html_count} 个页面 HTML/MHTML：文案与控件优先按页面源码还原")
        confidences.append(0.8)

    ui_hints = parse_ui_hints(prompt or "", image_count, html_count)
    has_dashboard = (
        wants_dashboard(prompt or "", image_count, html_count)
        or image_count > 0
        or html_count > 0
        or bool(ui_hints.get("ui_preset"))
    )

    confidence = sum(confidences) / len(confidences) if confidences else 0.6
    if prompt and excel_meta:
        confidence = min(0.95, confidence + 0.05)

    if ui_hints.get("app_name"):
        app_name = ui_hints["app_name"]
    if ui_hints.get("slug"):
        slug = slugify(str(ui_hints["slug"]), fallback=slug)

    item_label = infer_item_label(prompt or "", label)
    list_title = ui_hints.get("list_title") or f"{item_label}列表"
    create_title = ui_hints.get("create_title") or f"新增{item_label}"
    dash_title = ui_hints.get("dash_title") or f"{item_label}看板"

    filter_style = ui_hints.get("filter_style") or "default"
    # 有截图或用户要求还原 → 通用截图忠实（不绑定行业默认文案）
    faithful = (
        ui_hints.get("ui_preset") == "screenshot_faithful"
        or wants_screenshot_layout(prompt or "", image_count, html_count)
    )
    layout_preset = "screenshot_faithful" if faithful else "default"
    if faithful:
        ui_hints.setdefault("ui", {})
        filter_style = ui_hints.get("filter_style") or filter_style
        if ui_hints.get("app_name"):
            app_name = ui_hints["app_name"]
        if ui_hints.get("list_title"):
            list_title = ui_hints["list_title"]
        if ui_hints.get("create_title"):
            create_title = ui_hints["create_title"]
        if ui_hints.get("dash_title"):
            dash_title = ui_hints["dash_title"]
        elif dash_title.endswith("概览"):
            dash_title = dash_title.replace("概览", "看板")

    # 从数据字段补齐筛选（不写死行业词：有 enum 才出单选，有 worksite 类字段才出下拉）
    ui_block = ui_hints.setdefault("ui", {})
    field_by_name = {f["name"]: f for f in primary_fields}
    if not ui_block.get("section_options") and not ui_block.get("filter_radios"):
        for cand in ("section_type", "category", "type", "kind", "status"):
            f = field_by_name.get(cand)
            ev = (f or {}).get("enum_values") or []
            if len(ev) >= 2:
                ui_block["section_options"] = list(ev)[:8]
                ui_block["filter_radios"] = list(ev)[:8]
                ui_block["filter_style"] = "section_radios"
                ui_block["radio_field"] = cand
                filter_style = "section_radios"
                break
    if not ui_block.get("select_field") and not ui_block.get("filter_select_field"):
        for cand in ("worksite", "site", "store", "warehouse", "dept", "org"):
            if cand in field_by_name:
                ui_block["select_field"] = cand
                break
    if ui_hints.get("ui", {}).get("filter_hint"):
        pass
    elif "点击" in (prompt or "") and "查看" in (prompt or ""):
        m = re.search(r"(点击[^。\n]{2,40})", prompt or "")
        if m:
            ui_block.setdefault("filter_hint", m.group(1).strip())

    list_actions = ui_hints.get("actions") or ["create", "edit", "delete", "export", "import", "refresh"]
    action_labels = dict(DEFAULT_ACTION_LABELS)
    if ui_hints.get("action_labels"):
        action_labels.update(ui_hints["action_labels"])

    # 列表列：优先通用 list_cols；若提示词/字段含常用业务列再穿插（不写死行业）
    preferred_cols = [c for c in primary_list_cols if c][:8]
    preferred_filters = primary_filters[:5]
    if ui_hints.get("filter_style") == "section_radios" and "section_type" in primary_filters:
        preferred_filters = [f for f in ("section_type", "worksite", "status") if f in primary_filters] or preferred_filters

    main_pages = [
        {
            "id": f"{primary_entity}_list",
            "title": list_title,
            "route": f"/{primary_entity}",
            "type": "list",
            "entity": primary_entity,
            "layout": {
                "columns": preferred_cols,
                "filters": preferred_filters,
                "actions": list_actions,
                "action_labels": action_labels,
                "filter_style": filter_style,
            },
        },
        {
            "id": f"{primary_entity}_create",
            "title": create_title,
            "route": f"/{primary_entity}/create",
            "type": "form_create",
            "entity": primary_entity,
            "layout": {
                "form_fields": [f["name"] for f in primary_fields if f["name"] not in _SYSTEM_FIELDS],
                "actions": ["create"],
                "action_labels": {k: action_labels.get(k, DEFAULT_ACTION_LABELS.get(k, k)) for k in ("create", "save", "cancel")},
            },
        },
    ]
    if has_dashboard:
        dash_widgets = build_dashboard_widgets(
            primary_fields, primary_filters, primary_list_cols, primary_entity, item_label, prompt or ""
        )
        if faithful:
            # 有截图时去掉顶栏 KPI，避免破坏截图主区结构
            types = {w.get("type") for w in dash_widgets}
            if ("line_chart" in types or "bar_chart" in types) and (
                "status_strip" in types or "table" in types
            ):
                dash_widgets = [w for w in dash_widgets if w.get("type") != "kpi"]
        main_pages.append(
            {
                "id": f"{primary_entity}_dashboard",
                "title": dash_title,
                "route": f"/{primary_entity}/dashboard",
                "type": "dashboard",
                "entity": primary_entity,
                "layout": {
                    "preset": layout_preset,
                    "widgets": dash_widgets,
                },
            }
        )
    pages = main_pages + pages

    draft = {
        "version": "1.0",
        "meta": {
            "name": app_name,
            "slug": slug,
            "description": (prompt or "")[:500],
            "locale": "zh-CN",
            "source": {"prompt": prompt or ""},
            "confidence": round(confidence, 2),
            "ui_preset": layout_preset if faithful else (ui_hints.get("ui_preset") or "default"),
            "project_context": ui_hints.get("project_context") or "",
            "platform_title": (ui_hints.get("ui") or {}).get("platform_title") or "",
            "ui": ui_hints.get("ui") or {},
        },
        "storage": {"mode": storage_mode or "schema_per_app", "engine": "postgres"},
        "entities": entities,
        "apis": {"base_path": f"/api/v1/apps/{slug}", "resources": resources},
        "pages": pages,
        "security": {
            "visibility": "private",
            "roles": [
                {
                    "name": "owner",
                    "permissions": [
                        "app.read", "app.write", "app.admin",
                        "row.create", "row.read", "row.update", "row.delete", "row.export", "row.import",
                    ],
                },
                {"name": "viewer", "permissions": ["app.read", "row.read", "row.export"]},
            ],
            "row_policies": [{"entity": e["name"], "rule": "tenant_isolated"} for e in entities],
        },
        "seed": {"import_excel": bool(excel_meta), "max_rows": 5000},
        "relations": relations,
    }
    return draft, warnings, round(confidence, 2)


@app.get("/health")
def health():
    return {"ok": True}


@app.get("/api/v1/demo/fixtures")
def demo_fixtures():
    """测试素材清单（控制台一键预填用）。"""
    return fixtures_manifest()


@app.get("/api/v1/demo/prompt")
def demo_prompt():
    try:
        text = read_prompt_text()
    except Exception as e:  # noqa: BLE001
        return {"ok": False, "prompt": "", "error": str(e)}
    return {"ok": True, "prompt": text, "slug": fixtures_manifest().get("slug")}


@app.get("/api/v1/demo/file/{file_path:path}")
def demo_file(file_path: str):
    return file_response(file_path)


@app.post("/api/v1/preview")
async def preview_create(payload: dict[str, Any]):
    """创建草稿预览（无需登录/发布），返回 preview_id 供 /#/preview/{id} 打开。"""
    bp = payload.get("blueprint")
    if not isinstance(bp, dict):
        return {"ok": False, "error": "blueprint required"}
    rows = payload.get("rows") if isinstance(payload.get("rows"), list) else []
    resource = str(payload.get("resource") or "")
    pid = create_preview(bp, rows=rows, resource=resource)
    return {
        "ok": True,
        "preview_id": pid,
        "path": f"/#/preview/{pid}",
        "url_hint": f"WEB_BASE/#/preview/{pid}",
    }


@app.get("/api/v1/preview/{preview_id}")
def preview_get(preview_id: str):
    pack = get_preview(preview_id)
    if not pack:
        return {"ok": False, "error": "preview not found or expired"}
    return {
        "ok": True,
        "id": pack.get("id"),
        "blueprint": pack.get("blueprint"),
        "rows": pack.get("rows") or [],
        "resource": pack.get("resource") or "records",
    }


@app.get("/api/v1/llm/providers")
def llm_providers():
    cfg = load_ai_config()
    default = (
        (os.getenv("LLM_PROVIDER") or "").strip()
        or str(cfg.get("default_provider") or "deepseek")
    )
    return {"providers": list_providers(), "default": default}


@app.post("/api/v1/apps/generate")
@app.post("/api/v1/apps:generate")  # 兼容旧路径，勿再新增第三套
async def generate(
    prompt: str = Form(""),
    storage_mode: str = Form("schema_per_app"),
    llm_provider: str = Form(""),
    llm_model: str = Form(""),
    excel: UploadFile | None = File(None),
    data_file: UploadFile | None = File(None),  # 兼容：json/csv/xlsx
    images: list[UploadFile] | None = File(None),
    layout_files: list[UploadFile] | None = File(None),  # HTML/MHTML（Ctrl+S 另存为）
):
    with GenerateTrace() as trace:
        excel_meta = None
        upload = data_file if (data_file is not None and data_file.filename) else excel
        if upload is not None and upload.filename:
            content = await upload.read()
            if content:
                try:
                    excel_meta = parse_tabular_upload(upload.filename, content)
                    sheets = (excel_meta or {}).get("sheets") or []
                    trace.stage(
                        "parse_data",
                        upload.filename,
                        sheets=len(sheets),
                        rows=(sheets[0].get("row_count") if sheets else None),
                    )
                except Exception as e:  # noqa: BLE001
                    trace.error(f"数据文件解析失败: {e}")
                    return {
                        "draft": None,
                        "warnings": [f"数据文件解析失败: {e}"],
                        "confidence": 0.0,
                        "require_confirm": True,
                        "generate_log": trace.as_payload(),
                    }

        image_blobs: list[tuple[str, bytes, str]] = []
        for img in images or []:
            if not img.filename:
                continue
            raw = await img.read()
            if raw:
                ctype = img.content_type or "image/png"
                image_blobs.append((img.filename, raw, ctype))

        layout_parsed_list: list[dict] = []
        layout_texts: list[str] = []
        layout_names: list[str] = []
        for lf in layout_files or []:
            if not lf.filename:
                continue
            raw = await lf.read()
            if not raw:
                continue
            layout_names.append(lf.filename)
            parsed = parse_layout_file(lf.filename, raw)
            layout_parsed_list.append(parsed)
            layout_texts.append(layout_summary_text(parsed))

        image_count = len(image_blobs)
        html_count = len(layout_names)
        model_arg = llm_model.strip() or None
        user_prompt = prompt or ""
        trace.stage(
            "inputs",
            prompt_chars=len(user_prompt),
            images=image_count,
            layouts=html_count,
            llm_provider=llm_provider,
            llm_model=model_arg or "",
            image_names=[n for n, _, _ in image_blobs],
            layout_names=layout_names,
            data_file=(upload.filename if upload and upload.filename else ""),
        )

        # HTML 按钮行并入用户需求，便于 parse_action_bar
        html_btn_extra = ""
        for parsed in layout_parsed_list:
            if parsed.get("ok") and parsed.get("buttons"):
                html_btn_extra += "\n操作：" + "、".join(parsed["buttons"][:20])
        prompt_for_hints = user_prompt + html_btn_extra

        trace.stage("vision_extract", "开始理解截图")
        vision_text, vision_notes = understand_images(user_prompt, image_blobs, llm_provider, None)
        trace.stage(
            "vision_extract",
            "完成",
            chars=len(vision_text or ""),
            has_vision=bool(vision_text),
            notes=len(vision_notes or []),
        )
        html_layout_text = "\n\n".join(layout_texts)
        effective_prompt = compose_effective_prompt(
            prompt_for_hints,
            image_count,
            vision_text,
            html_layout_text,
            html_count,
        )
        faithful = wants_screenshot_layout(user_prompt, image_count, html_count)
        trace.stage(
            "compose_prompt",
            chars=len(effective_prompt or ""),
            screenshot_faithful=faithful,
        )

        trace.stage("build_blueprint", "启发式草案")
        draft, warnings, confidence = build_blueprint(
            effective_prompt, excel_meta, storage_mode, image_count, html_count
        )
        # url.config 抓包：导航页签 / 空接口告警
        if excel_meta and isinstance(excel_meta, dict):
            for w in excel_meta.get("warnings") or []:
                warnings.append(str(w))
            cap_ui = excel_meta.get("ui_hints") or {}
            if cap_ui.get("nav_items"):
                meta = draft.setdefault("meta", {})
                ui = meta.setdefault("ui", {})
                ui.setdefault("nav_items", cap_ui["nav_items"])
            cap = excel_meta.get("capture") or {}
            if cap.get("pairs"):
                warnings.append(
                    "已解析 url.config 抓包："
                    + "、".join(
                        f"{p.get('action') or '?'}({'空' if p.get('empty') else p.get('kind')})"
                        for p in cap["pairs"][:6]
                    )
                )
        # 用 HTML 再补一轮 ui_hints（名称/页签），写回 meta
        for parsed in layout_parsed_list:
            if not parsed.get("ok"):
                continue
            hints = parse_ui_hints(prompt_for_hints, image_count, html_count)
            merge_layout_into_ui_hints(hints, parsed)
            meta = draft.setdefault("meta", {})
            if hints.get("app_name") and (
                not meta.get("name") or "数据管理" in str(meta.get("name"))
            ):
                meta["name"] = hints["app_name"]
            if hints.get("ui"):
                meta["ui"] = {**(meta.get("ui") or {}), **hints["ui"]}
                if hints["ui"].get("platform_title"):
                    meta["platform_title"] = hints["ui"]["platform_title"]
            if hints.get("_html_buttons_line"):
                ab = parse_action_bar(hints["_html_buttons_line"])
                if ab:
                    for p in draft.get("pages") or []:
                        if isinstance(p, dict) and p.get("type") == "list":
                            lay = p.setdefault("layout", {})
                            lay["actions"] = ab["actions"]
                            labels = dict(lay.get("action_labels") or {})
                            labels.update(ab["action_labels"])
                            lay["action_labels"] = labels
            # 页标题 + widget 标题（来自 HTML label）
            for p in draft.get("pages") or []:
                if not isinstance(p, dict):
                    continue
                if p.get("type") == "list" and hints.get("list_title"):
                    p["title"] = hints["list_title"]
                if p.get("type") == "form_create" and hints.get("create_title"):
                    p["title"] = hints["create_title"]
                if p.get("type") == "dashboard":
                    if hints.get("dash_title"):
                        p["title"] = hints["dash_title"]
                    elif p.get("title") and "概览" in str(p.get("title")):
                        p["title"] = str(p["title"]).replace("概览", "看板")
                    lay = p.setdefault("layout", {})
                    for w in lay.get("widgets") or []:
                        if not isinstance(w, dict):
                            continue
                        if w.get("type") in {"line_chart", "bar_chart"} and hints.get("chart_title"):
                            w["title"] = hints["chart_title"]
                        if w.get("type") == "status_strip" and hints.get("strip_title"):
                            w["title"] = hints["strip_title"]
                        if w.get("type") == "table" and (
                            hints.get("table_title") or (hints.get("ui") or {}).get("table_title")
                        ):
                            w["title"] = hints.get("table_title") or hints["ui"]["table_title"]

        baseline = json.loads(json.dumps(draft))  # deep copy
        warnings.extend(vision_notes)
        for parsed in layout_parsed_list:
            if parsed.get("ok"):
                warnings.append(f"已解析页面布局: {parsed.get('filename')}")
            else:
                warnings.append(f"页面布局解析失败: {parsed.get('filename')} ({parsed.get('error')})")
        trace.notes(warnings)
        slug0 = (draft.get("meta") or {}).get("slug") or ""
        trace.stage(
            "build_blueprint",
            "完成",
            slug=slug0,
            pages=len(draft.get("pages") or []),
            confidence=confidence,
        )

        excel_summary = ""
        if excel_meta and excel_meta.get("sheets"):
            sh = excel_meta["sheets"][0]
            excel_summary = f"sheet={sh.get('name')} headers={sh.get('headers')}"
        if html_layout_text:
            excel_summary = (excel_summary + " | " if excel_summary else "") + "html_layout=yes"
        trace.stage("llm_enhance", "代码模型润色蓝图")
        draft, llm_notes = enhance_blueprint_with_llm(
            draft, effective_prompt, excel_summary, llm_provider, model_arg
        )
        warnings.extend(llm_notes)
        trace.notes(llm_notes)
        draft = harden_draft(draft, baseline)
        draft.setdefault("meta", {}).setdefault("source", {})
        draft["meta"]["source"]["prompt"] = user_prompt
        draft["meta"]["source"]["llm_provider"] = llm_provider
        if model_arg:
            draft["meta"]["source"]["llm_model"] = model_arg
        if upload is not None and upload.filename:
            draft["meta"]["source"]["excel_ref"] = f"upload://{upload.filename}"
            if excel_meta and excel_meta.get("sheets"):
                draft["meta"]["source"]["data_format"] = excel_meta["sheets"][0].get("source") or "excel"
                if excel_meta["sheets"][0].get("row_count"):
                    draft["meta"]["source"]["row_count"] = excel_meta["sheets"][0]["row_count"]
        if image_count:
            draft["meta"]["source"]["image_refs"] = [n for n, _, _ in image_blobs]
            if vision_text:
                draft["meta"]["source"]["vision_summary"] = vision_text[:1000]
        if html_count:
            draft["meta"]["source"]["layout_refs"] = layout_names
            draft["meta"]["source"]["layout_summary"] = html_layout_text[:2000]
        if image_count or html_count:
            draft["meta"]["source"]["screenshot_faithful"] = faithful
            if faithful:
                draft["meta"]["ui_preset"] = "screenshot_faithful"
                for p in draft.get("pages") or []:
                    if isinstance(p, dict) and p.get("type") == "dashboard":
                        p.setdefault("layout", {})["preset"] = "screenshot_faithful"
        draft["meta"]["source"]["generate_run_id"] = trace.run_id
        trace.stage("llm_enhance", "完成", slug=(draft.get("meta") or {}).get("slug") or "")

        fidelity_report: dict = {"skipped": True, "reason": "not_applicable"}
        if image_count and faithful:
            # 视觉摘录已写入 effective_prompt；此处：预览/计划 ↔ 原图打分 → 代码模型改蓝图 → 循环至 ≥95%
            trace.stage("fidelity_loop", "开始还原度迭代")
            preview_rows: list[dict] = []
            if excel_meta and isinstance(excel_meta, dict):
                for sh in excel_meta.get("sheets") or []:
                    if isinstance(sh, dict) and sh.get("row_dicts"):
                        preview_rows = list(sh["row_dicts"])[:500]
                        break
            draft, fidelity_report, fid_notes = await asyncio.to_thread(
                run_fidelity_loop,
                draft,
                image_blobs,
                vision_text,
                llm_provider=llm_provider,
                llm_model=model_arg,
                vision_model=None,  # 视觉通道只用 VISION_MODEL，勿混入文本模型名
                harden_fn=harden_draft,
                baseline=baseline,
                preview_rows=preview_rows,
            )
            warnings.extend(fid_notes)
            draft = harden_draft(draft, baseline)
            draft.setdefault("meta", {}).setdefault("source", {})
            draft["meta"]["source"]["fidelity"] = {
                "final_score": fidelity_report.get("final_score"),
                "passed": fidelity_report.get("passed"),
                "rounds": len(fidelity_report.get("rounds") or []),
                "target": fidelity_report.get("target"),
            }
            if fidelity_report.get("passed"):
                draft["meta"]["confidence"] = max(
                    float(draft["meta"].get("confidence") or confidence or 0),
                    0.95,
                )
            trace.stage(
                "fidelity_loop",
                "结束",
                final_score=fidelity_report.get("final_score"),
                passed=fidelity_report.get("passed"),
                rounds=len(fidelity_report.get("rounds") or []),
                target=fidelity_report.get("target"),
            )
        else:
            trace.stage(
                "fidelity_loop",
                "跳过",
                reason=("no_images" if not image_count else "not_screenshot_faithful"),
            )

        conf = float(draft.get("meta", {}).get("confidence") or confidence)
        log_payload = trace.as_payload()
        draft.setdefault("meta", {}).setdefault("source", {})
        draft["meta"]["source"]["generate_log_file"] = log_payload.get("log_file") or ""
        return {
            "draft": draft,
            "warnings": warnings,
            "confidence": conf,
            "require_confirm": True,
            "llm_provider": llm_provider,
            "llm_model": model_arg or "",
            "fidelity": fidelity_report,
            "generate_log": log_payload,
        }
