#!/usr/bin/env python3
"""conv — синхронизация конвенций между каноном и репозиторием.

Канон — эта директория. Репозиторий держит закоммиченные копии нужных
конвенций в docs/conventions/, повторяя структуру канона. Копия — источник
правды для репозитория; канон — лавка, из которой берут.

Служебная разметка копии:

  ---
  origin: arch/time.md        # откуда взято
  origin_hash: a1b2c3d4       # отпечаток канона на момент синхронизации
  synced: 2026-07-25
  local: нет                  # или текст: чем и почему разошлись
  ---

Прочие ключи шапки (status, extends) — часть документа: они сравниваются
наравне с телом и приезжают из канона.

Локальные регионы — куски, которые по определению принадлежат репозиторию
(механизация, отступления, «здесь решили так»). Из сравнения исключаются:

  <!-- local:механизировано -->
  ...
  <!-- /local -->

Имя региона обязательно: перенос при pull идёт по именам.

Команды:
  conv list                       что есть в каноне
  conv add arch/time.md [...]     взять конвенцию в репозиторий
  conv status                     состояние копий репозитория
  conv diff [arch/time.md]        чем копия отличается от канона
  conv pull arch/time.md          забрать обновление канона
  conv push arch/time.md          вернуть локальное улучшение в канон
  conv push --new lang/go/x.md    завести в каноне новую конвенцию

Везде можно указать --repo <path> (по умолчанию — текущая директория)
и --dir <subpath> (по умолчанию docs/conventions), до или после команды.
"""

from __future__ import annotations

import argparse
import datetime
import difflib
import hashlib
import re
import sys
from pathlib import Path
from typing import NoReturn

CANON = Path(__file__).resolve().parent
CANON_TREES = ("common", "arch", "lang", "stack")
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
DEFAULT_DIR = "docs/conventions"
ENC = "utf-8"

# Маркеры распознаются только в начале строки: так пример разметки внутри
# текста конвенции не превращается в настоящий регион.
REGION_RE = re.compile(
    r"^<!--[ \t]*local(?::[ \t]*([^>]*?))?[ \t]*-->(.*?)^<!--[ \t]*/local[ \t]*-->",
    re.DOTALL | re.MULTILINE,
)
OPEN_RE = re.compile(r"^<!--[ \t]*local", re.MULTILINE)
CLOSE_RE = re.compile(r"^<!--[ \t]*/local", re.MULTILINE)


def die(message: str) -> NoReturn:
    print(f"conv: {message}", file=sys.stderr)
    sys.exit(1)


# --- разметка --------------------------------------------------------------


def read(path: Path) -> str:
    return path.read_text(encoding=ENC)


def write(path: Path, text: str) -> None:
    path.write_text(text, encoding=ENC)


def split_front(text: str) -> tuple[dict[str, str], str]:
    """Отделяет YAML-шапку (плоский key: value) от тела."""
    if not text.startswith("---\n"):
        return {}, text
    end = text.find("\n---\n", 4)
    if end == -1:
        return {}, text
    meta: dict[str, str] = {}
    for line in text[4:end].splitlines():
        if ":" in line:
            key, value = line.split(":", 1)
            meta[key.strip()] = value.strip()
    return meta, text[end + 5 :]


def join_front(meta: dict[str, str], body: str) -> str:
    if not meta:
        return body
    lines = "\n".join(f"{k}:{' ' + v if v else ''}" for k, v in meta.items())
    return f"---\n{lines}\n---\n{body}"


def doc_keys(meta: dict[str, str]) -> dict[str, str]:
    return {k: v for k, v in meta.items() if k not in SERVICE_KEYS}


def regions(body: str) -> dict[str, str]:
    """Содержимое локальных регионов по имени.

    Поднимает ValueError на разметке, из-за которой регион молча превратился
    бы в обычный текст и потерялся при pull.
    """
    matched = len(REGION_RE.findall(body))
    if len(OPEN_RE.findall(body)) != matched or len(CLOSE_RE.findall(body)) != matched:
        raise ValueError("непарный или нераспознанный маркер локального региона")
    found: dict[str, str] = {}
    for match in REGION_RE.finditer(body):
        name = (match.group(1) or "").strip()
        content = match.group(2)
        if not name:
            if content.strip():
                raise ValueError(
                    "безымянный локальный регион с содержимым — дай ему имя"
                )
            continue
        if name in found:
            raise ValueError(f"локальный регион '{name}' встречается дважды")
        found[name] = content
    return found


def checked_regions(body: str, where: str) -> dict[str, str]:
    try:
        return regions(body)
    except ValueError as exc:
        die(f"{where}: {exc}")


def blank_regions(body: str) -> str:
    """Тело с опустошёнными локальными регионами — то, что сравнивается."""

    def repl(match: re.Match[str]) -> str:
        raw = (match.group(1) or "").strip()
        head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
        return f"{head}\n<!-- /local -->"

    return REGION_RE.sub(repl, body)


def fill_regions(body: str, values: dict[str, str]) -> tuple[str, list[str]]:
    """Вставляет содержимое регионов по имени. Возвращает тело и имена,
    которым не нашлось места."""
    used: set[str] = set()

    def repl(match: re.Match[str]) -> str:
        raw = (match.group(1) or "").strip()
        head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
        if raw in values:
            used.add(raw)
            return f"{head}{values[raw]}<!-- /local -->"
        return match.group(0)

    filled = REGION_RE.sub(repl, body)
    lost = [n for n, v in values.items() if n not in used and v.strip()]
    return filled, lost


def fingerprint(meta: dict[str, str], body: str) -> str:
    """Отпечаток документа: ключи шапки плюс тело без локальных регионов."""
    head = "\n".join(f"{k}={v}" for k, v in sorted(doc_keys(meta).items()))
    return hashlib.sha256(f"{head}\n\n{blank_regions(body)}".encode(ENC)).hexdigest()[
        :8
    ]


def today() -> str:
    return datetime.date.today().isoformat()


# --- канон и репозиторий ---------------------------------------------------


def canon_list() -> list[str]:
    out: list[str] = []
    for tree in CANON_TREES:
        root = CANON / tree
        if root.is_dir():
            out += [str(p.relative_to(CANON)) for p in sorted(root.rglob("*.md"))]
    return out


def canon_read(origin: str) -> tuple[dict[str, str], str]:
    path = CANON / origin
    if not path.is_file():
        die(f"в каноне нет {origin}")
    return split_front(read(path))


def normalize_origin(name: str, *, must_exist: bool = True) -> str:
    """Принимает 'arch/time.md', 'arch/time' и однозначный хвост вроде 'time'."""
    name = name.strip("/")
    if not name.endswith(".md"):
        name += ".md"
    candidate = (CANON / name).resolve()
    if candidate.is_relative_to(CANON):
        rel = str(candidate.relative_to(CANON))
        if rel.split("/")[0] in CANON_TREES and (not must_exist or candidate.is_file()):
            return rel
    matches = [c for c in canon_list() if c == name or c.endswith("/" + name)]
    if len(matches) == 1:
        return matches[0]
    if not matches:
        die(f"в каноне нет {name} (путь должен начинаться с {'/'.join(CANON_TREES)})")
    die(f"неоднозначно: {name} → {', '.join(matches)}")


def repo_dir(args: argparse.Namespace) -> Path:
    return (Path(str(args.repo)) / str(args.dir)).resolve()


def repo_copies(base: Path) -> tuple[dict[str, Path], list[Path], list[str]]:
    """origin → копия; плюс .md без шапки и сообщения о нечитаемых файлах."""
    found: dict[str, Path] = {}
    untracked: list[Path] = []
    problems: list[str] = []
    if not base.is_dir():
        return found, untracked, problems
    for path in sorted(base.rglob("*.md")):
        try:
            meta, _ = split_front(read(path))
        except (OSError, UnicodeDecodeError) as exc:
            problems.append(f"{path.name}: не читается ({type(exc).__name__})")
            continue
        origin = meta.get("origin")
        if not origin:
            if path.name != "README.md":
                untracked.append(path)
            continue
        if origin in found:
            problems.append(
                f"{origin}: две копии ({found[origin]}, {path}) — вторая скрыта"
            )
            continue
        found[origin] = path
    return found, untracked, problems


def locate(args: argparse.Namespace, origin: str) -> Path:
    """Путь копии: по шапке, если она лежит не по канонному пути."""
    base = repo_dir(args)
    copies, _, _ = repo_copies(base)
    return copies.get(origin, base / origin)


# --- состояние -------------------------------------------------------------


def state(
    meta: dict[str, str], body: str, canon_meta: dict[str, str], canon_body: str
) -> str:
    copy_fp = fingerprint(meta, body)
    canon_fp = fingerprint(canon_meta, canon_body)
    if copy_fp == canon_fp:
        return "ok"
    base = meta.get("origin_hash")
    if not base:
        return "нет origin_hash в шапке"
    if base == canon_fp:
        return "изменено локально"
    if base == copy_fp:
        return "канон обновился"
    return "разошлись"


# --- команды ---------------------------------------------------------------


def cmd_list(args: argparse.Namespace) -> int:
    for origin in canon_list():
        meta, _ = canon_read(origin)
        marks = []
        if "extends" in meta:
            marks.append(f"расширяет {meta['extends']}")
        if "status" in meta:
            marks.append(meta["status"])
        tail = f"  ({'; '.join(marks)})" if marks else ""
        print(f"{origin}{tail}")
    return 0


def cmd_add(args: argparse.Namespace) -> int:
    base = repo_dir(args)
    added = False
    for raw in args.names:
        origin = normalize_origin(raw)
        target = base / origin
        if target.exists():
            print(f"{origin}: уже есть ({target}), пропускаю")
            continue
        canon_meta, canon_body = canon_read(origin)
        checked_regions(canon_body, f"канон/{origin}")
        meta: dict[str, str] = {
            "origin": origin,
            "origin_hash": fingerprint(canon_meta, canon_body),
            "synced": today(),
            "local": "нет",
        }
        meta.update(doc_keys(canon_meta))
        target.parent.mkdir(parents=True, exist_ok=True)
        write(target, join_front(meta, canon_body))
        added = True
        print(f"{origin} → {target}")
        if "extends" in canon_meta:
            print(f"  расширяет {canon_meta['extends']} — возможно, нужна и она")
    if added:
        print("не забудь строку в docs/conventions/README.md")
    return 0


def cmd_status(args: argparse.Namespace) -> int:
    base = repo_dir(args)
    copies, untracked, problems = repo_copies(base)
    if not copies and not untracked and not problems:
        print(f"в {base} нет копий конвенций")
        return 0
    width = max((len(o) for o in copies), default=0)
    for origin, path in copies.items():
        try:
            meta, body = split_front(read(path))
        except (OSError, UnicodeDecodeError) as exc:
            print(f"{origin:<{width}}  не читается ({type(exc).__name__})")
            continue
        if not (CANON / origin).is_file():
            print(f"{origin:<{width}}  нет в каноне")
            continue
        canon_meta, canon_body = canon_read(origin)
        try:
            regions(body)
        except ValueError as exc:
            print(f"{origin:<{width}}  разметка: {exc}")
            continue
        local = meta.get("local", "нет")
        note = "" if local == "нет" else f"  [{local}]"
        print(f"{origin:<{width}}  {state(meta, body, canon_meta, canon_body)}{note}")
    for path in untracked:
        print(f"{path.name}: без шапки origin — не отслеживается")
    for problem in problems:
        print(problem)
    return 0


def cmd_diff(args: argparse.Namespace) -> int:
    base = repo_dir(args)
    copies, _, _ = repo_copies(base)
    targets = [normalize_origin(args.name)] if args.name else list(copies)
    for origin in targets:
        path = copies.get(origin)
        if path is None:
            print(f"{origin}: нет копии в репозитории")
            continue
        if not (CANON / origin).is_file():
            print(f"{origin}: нет в каноне")
            continue
        meta, body = split_front(read(path))
        canon_meta, canon_body = canon_read(origin)
        if fingerprint(meta, body) == fingerprint(canon_meta, canon_body):
            continue
        sys.stdout.writelines(
            difflib.unified_diff(
                join_front(doc_keys(canon_meta), blank_regions(canon_body)).splitlines(
                    keepends=True
                ),
                join_front(doc_keys(meta), blank_regions(body)).splitlines(
                    keepends=True
                ),
                fromfile=f"канон/{origin}",
                tofile=f"репо/{origin}",
            )
        )
    return 0


def cmd_pull(args: argparse.Namespace) -> int:
    origin = normalize_origin(args.name)
    path = locate(args, origin)
    if not path.is_file():
        die(f"нет копии {origin} — сначала conv add {origin}")
    meta, body = split_front(read(path))
    canon_meta, canon_body = canon_read(origin)
    checked_regions(canon_body, f"канон/{origin}")
    local = checked_regions(body, f"репо/{origin}")
    st = state(meta, body, canon_meta, canon_body)
    if st == "ok":
        fresh = fingerprint(canon_meta, canon_body)
        if meta.get("origin_hash") != fresh:
            meta["origin_hash"] = fresh
            meta["synced"] = today()
            write(path, join_front(meta, body))
            print(f"{origin}: тексты совпадают, отпечаток освежён")
        else:
            print(f"{origin}: уже совпадает")
        return 0
    if st in ("изменено локально", "разошлись") and not args.force:
        die(
            f"{origin}: {st} — правки вне локальных регионов будут потеряны.\n"
            f"       посмотри conv diff {origin}, затем conv pull --force "
            f"или conv push {origin}"
        )
    merged, lost = fill_regions(canon_body, local)
    if lost and not args.force:
        die(
            f"{origin}: в каноне нет регионов {', '.join(lost)} — их содержимое "
            f"пропадёт.\n       перенеси вручную или conv pull --force"
        )
    for name in lost:
        print(f"  потерян локальный регион {name}")
    new_meta = {k: meta[k] for k in SERVICE_KEYS if k in meta}
    new_meta["origin_hash"] = fingerprint(canon_meta, canon_body)
    new_meta["synced"] = today()
    new_meta.update(doc_keys(canon_meta))
    write(path, join_front(new_meta, merged))
    print(f"{origin}: обновлено из канона — перечитай глазами, регионы могли устареть")
    return 0


def cmd_push(args: argparse.Namespace) -> int:
    origin = normalize_origin(args.name, must_exist=not args.new)
    path = locate(args, origin)
    if not path.is_file():
        die(f"нет копии {origin}")
    meta, body = split_front(read(path))
    checked_regions(body, f"репо/{origin}")
    target = CANON / origin
    if not target.is_file():
        if not args.new:
            die(f"в каноне нет {origin} — заведи новую конвенцию через conv push --new")
        target.parent.mkdir(parents=True, exist_ok=True)
        write(target, join_front(doc_keys(meta), blank_regions(body)))
        meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
        meta["synced"] = today()
        write(path, join_front(meta, body))
        print(f"{origin}: заведена в каноне")
        return 0
    canon_meta, canon_body = canon_read(origin)
    st = state(meta, body, canon_meta, canon_body)
    if st == "ok":
        print(f"{origin}: канон уже такой")
        return 0
    if st == "канон обновился":
        die(
            f"{origin}: копия не менялась, а канон ушёл вперёд — пушить нечего, нужен pull"
        )
    if st == "разошлись" and not args.force:
        die(
            f"{origin}: разошлись — канон менялся после синхронизации, "
            f"его правки затрутся.\n       посмотри conv diff {origin}, "
            f"затем conv push --force"
        )
    write(target, join_front(doc_keys(meta), blank_regions(body)))
    meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
    meta["synced"] = today()
    write(path, join_front(meta, body))
    print(f"{origin}: канон обновлён из репозитория")
    return 0


def main() -> int:
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--repo", default=".", help="корень репозитория")
    common.add_argument("--dir", default=DEFAULT_DIR, help="где лежат конвенции")

    parser = argparse.ArgumentParser(prog="conv", parents=[common], description=__doc__)
    sub = parser.add_subparsers(dest="cmd", required=True)

    sub.add_parser("list", parents=[common], help="что есть в каноне").set_defaults(
        fn=cmd_list
    )

    p_add = sub.add_parser(
        "add", parents=[common], help="взять конвенцию в репозиторий"
    )
    p_add.add_argument("names", nargs="+")
    p_add.set_defaults(fn=cmd_add)

    sub.add_parser("status", parents=[common], help="состояние копий").set_defaults(
        fn=cmd_status
    )

    p_diff = sub.add_parser(
        "diff", parents=[common], help="чем копия отличается от канона"
    )
    p_diff.add_argument("name", nargs="?")
    p_diff.set_defaults(fn=cmd_diff)

    p_pull = sub.add_parser("pull", parents=[common], help="забрать обновление канона")
    p_pull.add_argument("name")
    p_pull.add_argument("--force", action="store_true")
    p_pull.set_defaults(fn=cmd_pull)

    p_push = sub.add_parser("push", parents=[common], help="вернуть улучшение в канон")
    p_push.add_argument("name")
    p_push.add_argument("--force", action="store_true")
    p_push.add_argument("--new", action="store_true", help="завести новый файл канона")
    p_push.set_defaults(fn=cmd_push)

    args = parser.parse_args()
    return int(args.fn(args))


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