#!/usr/bin/env python3
"""L3a — визуальная карта управляемой формы 1С из Form.xml → HTML (+JSON).

Это НЕ пиксельная копия клиента 1С, а точная карта того, что описано в
метаданных формы: состав и порядок элементов, заголовки, DataPath, флаги
«только просмотр» / «недоступен» / «скрыт», колонки таблиц, кнопки и к каким
командам привязаны. Именно этого хватает, чтобы проверить «кнопка появилась»,
«колонка стала редактируемой», «поле уехало не в ту группу» — и это
единственный визуальный канал, который работает офлайн и одинаково доступен
любому агенту.

  render_form.py <Form.xml|каталог выгрузки> [--out <файл.html>] [--json <файл>]
"""
from __future__ import annotations

import html
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

SKIP = {"ExtendedTooltip", "ContextMenu", "SearchStringAddition",
        "ViewStatusAddition", "SearchControlAddition", "Event", "Events"}
CONTAINERS = {"UsualGroup", "Pages", "Page", "ButtonGroup", "CommandBar",
              "AutoCommandBar", "Popup", "ColumnGroup"}


def _local(tag: str) -> str:
    return tag.rsplit("}", 1)[-1]


def text_of(node, name: str) -> str | None:
    """Простое или многоязычное (v8:item/v8:content) свойство."""
    for child in node:
        if _local(child.tag) != name:
            continue
        items = [c for c in child if _local(c.tag) == "item"]
        if items:
            for it in items:
                for c in it:
                    if _local(c.tag) == "content" and c.text:
                        return c.text.strip()
            return None
        return (child.text or "").strip() or None
    return None


def flag_of(node, name: str) -> bool | None:
    v = text_of(node, name)
    if v is None:
        return None
    return v.lower() == "true"


def parse_item(node, owner: str = "") -> dict | None:
    kind = _local(node.tag)
    if kind in SKIP:
        return None
    item = {
        "kind": kind,
        "name": node.get("name"),
        "id": node.get("id"),
        "title": text_of(node, "Title"),
        "data_path": text_of(node, "DataPath"),
        "type": text_of(node, "Type"),
        "command": text_of(node, "CommandName"),
        "read_only": flag_of(node, "ReadOnly"),
        "visible": flag_of(node, "Visible"),
        "enabled": flag_of(node, "Enabled"),
        "back_color": text_of(node, "BackColor"),
        "width": text_of(node, "Width"),
        "title_location": text_of(node, "TitleLocation"),
        "group": text_of(node, "Group"),
        "owner": owner,
        "children": [],
    }
    # встроенные команды таблицы называются «<Таблица><Действие>» —
    # владелец нужен, чтобы срезать префикс в подписи кнопки
    child_owner = item["name"] if kind == "Table" else owner
    for child in node:
        ctag = _local(child.tag)
        if ctag == "ChildItems":
            for sub in child:
                parsed = parse_item(sub, child_owner)
                if parsed:
                    item["children"].append(parsed)
        elif ctag == "AutoCommandBar":
            parsed = parse_item(child, child_owner)
            if parsed:
                parsed["kind"] = "AutoCommandBar"
                item["children"].insert(0, parsed)
        elif ctag not in SKIP and ctag in CONTAINERS:
            parsed = parse_item(child, child_owner)
            if parsed:
                item["children"].append(parsed)
    return item


def object_synonyms(form_xml: Path) -> dict[str, str]:
    """DataPath → синоним реквизита из корневого XML обработки.

    В клиенте заголовок поля/колонки по умолчанию берётся из синонима
    реквизита, а не из имени элемента формы — без этого карта показывала бы
    «КонтрагентыОрганизацииСуммаКомиссииВДокументах» там, где пользователь
    видит «Сумма комиссии в документах».
    """
    try:
        src_dir = form_xml.parents[3]          # <src>/Forms/<Форма>/Ext/Form.xml
        root_xml = src_dir.parent / f"{src_dir.name}.xml"
        if not root_xml.exists():
            return {}
        root = ET.parse(root_xml).getroot()
    except (IndexError, ET.ParseError, OSError):
        return {}
    out: dict[str, str] = {}

    def props(node) -> tuple[str | None, str | None]:
        name = syn = None
        for p in node:
            if _local(p.tag) == "Properties":
                name, syn = text_of(p, "Name"), text_of(p, "Synonym")
        return name, syn

    obj = next(iter(root), None)
    if obj is None:
        return {}
    for node in obj:
        if _local(node.tag) != "ChildObjects":
            continue
        for child in node:
            ctag = _local(child.tag)
            name, syn = props(child)
            if not name:
                continue
            if ctag == "Attribute" and syn:
                out[f"Объект.{name}"] = syn
            elif ctag == "TabularSection":
                if syn:
                    out[f"Объект.{name}"] = syn
                for p in child:
                    if _local(p.tag) != "ChildObjects":
                        continue
                    for a in p:
                        aname, asyn = props(a)
                        if aname and asyn:
                            out[f"Объект.{name}.{aname}"] = asyn
    return out


def parse_form(path: Path) -> dict:
    root = ET.parse(path).getroot()
    form = {"kind": "Form", "name": path.parent.parent.name,
            "title": text_of(root, "Title"), "children": [], "attributes": []}
    for child in root:
        ctag = _local(child.tag)
        if ctag in ("ChildItems", "Attributes"):
            for sub in child:
                parsed = parse_item(sub)
                if not parsed:
                    continue
                if ctag == "Attributes":
                    form["attributes"].append(
                        {"name": parsed["name"], "title": parsed["title"]})
                    if parsed["name"] and parsed["title"]:
                        SYNONYMS.setdefault(parsed["name"], parsed["title"])
                else:
                    form["children"].append(parsed)
        elif ctag == "AutoCommandBar":
            parsed = parse_item(child)
            if parsed:
                form["children"].insert(0, parsed)
    return form


# ---------- HTML ----------

CSS = """
:root { color-scheme: light; }
body { font: 13px/1.45 -apple-system, "Segoe UI", Tahoma, sans-serif;
       margin: 0; padding: 18px; background: #f2f2f2; color: #1a1a1a; }
.form { max-width: 1000px; margin: 0 auto; background: #fff;
        border: 1px solid #b9b9b9; border-radius: 3px;
        box-shadow: 0 1px 4px rgba(0,0,0,.12); }
.titlebar { background: linear-gradient(#fbfbfb,#ececec);
            border-bottom: 1px solid #c4c4c4; padding: 7px 12px;
            font-weight: 600; border-radius: 3px 3px 0 0; }
.body { padding: 12px; }
.cmdbar { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 8px;
          background: #f7f7f7; border: 1px solid #dcdcdc; margin-bottom: 10px; }
.btn { border: 1px solid #b0b0b0; background: linear-gradient(#fff,#efefef);
       border-radius: 3px; padding: 3px 10px; font-size: 12px; }
.group { border: 1px solid #d6d6d6; padding: 9px 10px 10px;
         margin: 0 0 10px; border-radius: 2px; }
.group > .legend { font-weight: 600; font-size: 12px; margin: -2px 0 8px;
                   color: #333; }
.group.horiz > .items { display: flex; gap: 14px; flex-wrap: wrap; }
.field { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.field > label { min-width: 170px; text-align: right; color: #333; }
.field.top { flex-direction: column; align-items: flex-start; }
.field.top > label { text-align: left; min-width: 0; }
.ctl { border: 1px solid #a9a9a9; background: #fff; padding: 3px 6px;
       min-width: 190px; border-radius: 2px; font-size: 12px; }
.ctl.ro { background: #eeeeee; color: #666; }
.ctl.check { min-width: 0; padding: 2px 5px; }
table.grid { border-collapse: collapse; width: 100%; font-size: 12px;
             margin-top: 4px; }
table.grid th { background: linear-gradient(#fafafa,#ededed);
                border: 1px solid #cfcfcf; padding: 4px 6px; text-align: left;
                font-weight: 600; }
table.grid td { border: 1px solid #dedede; padding: 7px 6px; }
table.grid th.ro { color: #777; }
.tag { font-size: 10px; padding: 1px 5px; border-radius: 8px;
       background: #e6e6e6; color: #555; margin-left: 6px;
       vertical-align: middle; }
.tag.ro { background: #ffe6c7; color: #8a4b00; }
.tag.hidden { background: #ffd9d9; color: #9b0000; }
.tag.off { background: #e3e3ff; color: #303090; }
.lbl { min-width: 120px; border-bottom: 1px dotted #bbb; }
.meta { color: #8a8a8a; font-size: 10px; margin-left: 6px; }
.pages { border: 1px solid #d6d6d6; border-radius: 2px; margin-bottom: 10px; }
.tabs { display: flex; gap: 2px; background: #f0f0f0; padding: 5px 6px 0;
        border-bottom: 1px solid #d0d0d0; }
.tab { border: 1px solid #cfcfcf; border-bottom: none; background: #e8e8e8;
       padding: 4px 12px; font-size: 12px; border-radius: 3px 3px 0 0; }
.tab.active { background: #fff; font-weight: 600; }
.page { padding: 10px; }
.legendbox { max-width: 1000px; margin: 14px auto 0; font-size: 11px;
             color: #666; }
"""


def esc(s: str | None) -> str:
    return html.escape(s or "")


def tags_html(item: dict) -> str:
    out = []
    if item.get("read_only"):
        out.append('<span class="tag ro">только просмотр</span>')
    if item.get("visible") is False:
        out.append('<span class="tag hidden">скрыт</span>')
    if item.get("enabled") is False:
        out.append('<span class="tag off">недоступен</span>')
    return "".join(out)


SYNONYMS: dict[str, str] = {}


def strip_prefix(name: str, owner: str) -> str:
    return name[len(owner):] or name if owner and name.startswith(owner) \
        else name


# Соглашение именования этого разработчика (см. skill 1c-epf): команды формы
# верхнего уровня (не привязанные к таблице, поэтому owner пуст) часто несут
# служебную приставку-глину "а"/"Форма"/"Команда" перед реальным смыслом —
# «аДокументыНоваяСтавкаНДС», «ФормаУдалитьСФ», «КомандаПеренестиВУслуги».
NOISE_PREFIXES = ("Форма", "Команда", "а")


def strip_command_noise(name: str) -> str:
    # Префиксы бывают склеены по нескольку ("ФормаКоманда...") — снимаем,
    # пока снимается, а не только один слой.
    changed = True
    while changed:
        changed = False
        for p in NOISE_PREFIXES:
            if (name.startswith(p) and len(name) > len(p)
                    and name[len(p)].isupper()):
                name = name[len(p):]
                changed = True
                break
    return name


def camel_to_words(name: str) -> str:
    # Акроним (НДС, СФ, IB) — подряд идущие заглавные, не размазывать по
    # буквам; обычное слово — заглавная + строчные/цифры.
    parts = re.findall(
        r"[A-ZА-ЯЁ]{2,}(?![a-zа-яё])|[A-ZА-ЯЁ][a-zа-яё0-9]*|[a-zа-яё0-9]+",
        name)
    if not parts:
        return name

    def fmt(word: str) -> str:
        if len(word) > 1 and word.isupper():
            return word            # акроним — регистр не трогаем
        return word.lower() if len(word) > 1 else word

    head, *rest = parts
    return " ".join([head] + [fmt(w) for w in rest])


def label_for(item: dict) -> str:
    """Как подпись выглядит у пользователя: свой заголовок → синоним
    реквизита → последний сегмент пути → имя элемента."""
    if item.get("title"):
        return item["title"]
    path = item.get("data_path")
    if path:
        if path in SYNONYMS:
            return SYNONYMS[path]
        tail = path.rsplit(".", 1)[-1]
        if tail == "LineNumber":
            return "N"
        if tail.startswith("Total"):
            base = f"Объект.{path.split('.')[1]}.{tail[len('Total'):]}" \
                if path.count(".") >= 2 else ""
            return f"Итого: {SYNONYMS.get(base, tail[len('Total'):])}"
        return SYNONYMS.get(path, tail)
    name = item.get("name") or ""
    if item.get("command") and name:
        stripped = strip_prefix(name, item.get("owner") or "")
        return camel_to_words(strip_command_noise(stripped))
    return name


def render_field(item: dict) -> str:
    kind = item["kind"]
    cls = "ctl" + (" ro" if item.get("read_only") else "")
    style = (f' style="background:{esc(item["back_color"])}"'
             if item.get("back_color") else "")
    if kind in ("CheckBoxField",):
        ctl = f'<span class="{cls} check"{style}>☐</span>'
    elif kind == "RadioButtonField":
        ctl = f'<span class="{cls} check"{style}>◉ ○</span>'
    elif kind == "LabelField":
        return (f'<div class="field"><label>{esc(label_for(item))}</label>'
                f'<span class="lbl"{style}>&nbsp;</span>{tags_html(item)}'
                f'<span class="meta">{esc(item.get("data_path"))}</span></div>')
    else:
        ctl = f'<span class="{cls}"{style}>&nbsp;</span>'
    top = " top" if (item.get("title_location") == "Top") else ""
    meta = (f'<span class="meta">{esc(item.get("data_path"))}</span>'
            if item.get("data_path") else "")
    return (f'<div class="field{top}"><label>{esc(label_for(item))}</label>'
            f'{ctl}{tags_html(item)}{meta}</div>')


def render_table(item: dict) -> str:
    cols = [c for c in item["children"]
            if c["kind"] not in ("AutoCommandBar", "CommandBar")]
    bars = [c for c in item["children"]
            if c["kind"] in ("AutoCommandBar", "CommandBar")]
    head = "".join(
        f'<th class="{"ro" if c.get("read_only") else ""}">'
        f'{esc(label_for(c))}{tags_html(c)}</th>' for c in cols) or "<th>—</th>"
    row = "".join("<td></td>" for _ in cols) or "<td></td>"
    inner = "".join(render_item(b) for b in bars)
    return (f'<div class="group"><div class="legend">'
            f'{esc(label_for(item))} <span class="meta">'
            f'{esc(item.get("data_path"))}</span>{tags_html(item)}</div>'
            f'{inner}<table class="grid"><tr>{head}</tr>'
            f'<tr>{row}</tr><tr>{row}</tr></table></div>')


def render_pages(item: dict) -> str:
    pages = [p for p in item["children"] if p["kind"] == "Page"]
    tabs = "".join(
        f'<div class="tab{" active" if i == 0 else ""}">{esc(label_for(p))}'
        f'{tags_html(p)}</div>' for i, p in enumerate(pages))
    bodies = "".join(
        f'<div class="page"{"" if i == 0 else " hidden"}>'
        + "".join(render_item(c) for c in p["children"]) + "</div>"
        for i, p in enumerate(pages))
    return f'<div class="pages"><div class="tabs">{tabs}</div>{bodies}</div>'


def render_item(item: dict) -> str:
    kind = item["kind"]
    if kind == "Pages":
        return render_pages(item)
    if kind == "Table":
        return render_table(item)
    if kind == "Popup":
        subs = ", ".join(esc(label_for(c)) for c in item["children"]
                         if c["kind"] == "Button")
        return (f'<span class="btn">{esc(label_for(item))} \u25be'
                f'<span class="meta">{subs}</span></span>')
    if kind in ("AutoCommandBar", "CommandBar", "ButtonGroup"):
        btns = "".join(
            (f'<span class="btn"'
             + (f' style="background:{esc(c["back_color"])}"'
                if c.get("back_color") else "")
             + f'>{esc(label_for(c))}{tags_html(c)}</span>')
            if c["kind"] == "Button" else render_item(c)
            for c in item["children"]
            if c["kind"] in ("Button", "Popup", "ButtonGroup"))
        nested = "".join(render_item(c) for c in item["children"]
                         if c["kind"] not in ("Button", "Popup", "ButtonGroup"))
        return (f'<div class="cmdbar">{btns or "<i>пусто</i>"}</div>{nested}'
                if btns or nested else "")
    if kind == "UsualGroup":
        horiz = " horiz" if item.get("group") == "Horizontal" else ""
        inner = "".join(render_item(c) for c in item["children"])
        legend = (f'<div class="legend">{esc(label_for(item))}'
                  f'{tags_html(item)}</div>' if label_for(item) else "")
        return (f'<div class="group{horiz}">{legend}'
                f'<div class="items">{inner}</div></div>')
    if kind == "Button":
        return f'<div class="cmdbar"><span class="btn">' \
               f'{esc(label_for(item))}{tags_html(item)}</span></div>'
    if kind.endswith("Field") or kind == "Decoration":
        return render_field(item)
    inner = "".join(render_item(c) for c in item["children"])
    if inner:
        return (f'<div class="group"><div class="legend">{esc(kind)} '
                f'{esc(label_for(item))}</div>{inner}</div>')
    return render_field(item)


def to_html(form: dict, source: str) -> str:
    body = "".join(render_item(c) for c in form["children"])
    attrs = ", ".join(esc(a["name"]) for a in form["attributes"])
    return f"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8">
<title>{esc(form['name'])} — карта формы</title><style>{CSS}</style></head>
<body>
<div class="form">
  <div class="titlebar">{esc(form.get('title') or form['name'])}</div>
  <div class="body">{body}</div>
</div>
<div class="legendbox">
  Карта формы по метаданным (не пиксельная копия клиента 1С).
  Источник: {esc(source)}<br>Реквизиты формы: {attrs or '—'}
</div>
</body></html>"""


def find_forms(target: Path) -> list[Path]:
    if target.is_file():
        return [target]
    return sorted(p for p in target.rglob("Form.xml")
                  if "Forms" in p.parts or "Form" in p.parts)


def main() -> int:
    if len(sys.argv) < 2:
        raise SystemExit(__doc__)
    target = Path(sys.argv[1]).resolve()
    forms = find_forms(target)
    if not forms:
        raise SystemExit(f"не нашёл Form.xml в {target}")
    out_dir = Path(sys.argv[sys.argv.index("--out") + 1]) \
        if "--out" in sys.argv else target.parent / "form_maps"
    out_dir.mkdir(parents=True, exist_ok=True)
    made, structures = [], {}
    for f in forms:
        SYNONYMS.clear()
        SYNONYMS.update(object_synonyms(f))
        form = parse_form(f)
        structures[form["name"]] = form
        dest = out_dir / f"{form['name']}.html"
        dest.write_text(to_html(form, str(f)), encoding="utf-8")
        made.append(dest)
        print(f"{form['name']}: {dest}")
    if "--json" in sys.argv:
        Path(sys.argv[sys.argv.index("--json") + 1]).write_text(
            json.dumps(structures, ensure_ascii=False, indent=2),
            encoding="utf-8")
    return 0


if __name__ == "__main__":
    sys.exit(main())
