"""Миграция knowledge graph (memory-gendesign.jsonl) → Obsidian vault через CouchDB.

Читает текущий KG и записывает каждую entity как `.md` файл прямо в CouchDB
в формате Self-hosted LiveSync (Vrtmrz/obsidian-livesync). Клиенты Obsidian
с LiveSync plugin автоматически подтянут изменения.

Формат LiveSync v0.x (classic chunks + children):
    Chunk document:
        {_id: "h:<base32-hash>", data: "<содержимое>", type: "leaf"}

    Main document:
        {_id: "entities/Foo.md", path: "entities/Foo.md",
         children: ["h:..."], ctime, mtime, size, type:"plain", eden:{}}

    eden — write-side staging area, при чтении не используется. Поэтому контент
    ОБЯЗАН быть в отдельных chunk-docs (LiveSync читает через children).
    PUT-ordering: chunks first, main docs second — иначе клиент видит пустой
    body во время replication race.

Структура vault:
    sessions/    — Session_End_* entities
    entities/    — Module_*, Schema_*, прочие
    decisions/   — *_Plan_*, *_Strategy_* (ADR-style)
    runbooks/    — *_Scraper_*, *_Pipeline_* (how-to)
    sources/    — DataSources_* (источники данных)
    misc/        — всё остальное

Запуск:
    cd backend
    COUCHDB_URL=https://obsidian.gendsgn.ru \
    COUCHDB_USER=obsidian \
    COUCHDB_PASSWORD=<пароль> \
    COUCHDB_DB=gendesign-vault \
    uv run --no-project --with requests python -X utf8 \
        ../scripts/migrate_kg_to_obsidian.py [--dry-run]
"""

from __future__ import annotations

import argparse
import hashlib
import json
import logging
import os
import re
import sys
import time
from collections import defaultdict
from pathlib import Path
from typing import Any
from urllib.parse import quote

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("kg_to_obsidian")

REPO_ROOT = Path(__file__).resolve().parent.parent
KG_PATH = REPO_ROOT / "memory" / "memory-gendesign.jsonl"


_B32 = "0123456789abcdefghijklmnopqrstuv"


def chunk_id_for(content: str) -> str:
    """Генерирует chunk_id похожий на LiveSync internal format:
    `h:` + 13 base32-like chars. Уникальность достаточна — collision space 2^65.

    LiveSync встроенный generator использует свой base32-кодированный hash.
    Подражаем формату чтобы клиент не отбраковал по validation.
    """
    digest = hashlib.sha256(content.encode("utf-8")).digest()
    n = int.from_bytes(digest[:8], "big")
    out = []
    for _ in range(13):
        out.append(_B32[n & 0x1F])
        n >>= 5
    return "h:" + "".join(out)


_MONTH_MAP = {
    "jan": "01", "feb": "02", "mar": "03", "apr": "04", "may": "05", "jun": "06",
    "jul": "07", "aug": "08", "sep": "09", "oct": "10", "nov": "11", "dec": "12",
}
_DATE_PATTERNS = [
    # Apr27_2026, Apr30_2026, Apr29_2026_Late
    # Ведущий lookbehind (?<![A-Za-z]) вместо \b: в именах entity месяц следует
    # за '_' (Session_End_Apr30_2026), а '_' — это \w, поэтому \b между '_' и
    # буквой не возникает. Завершающий (?![\dA-Za-z]) вместо \b — чтобы суффикс
    # '_Late' (Apr29_2026_Late) не срывал совпадение.
    re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})_(?P<y>20\d{2})(?![\dA-Za-z])", re.IGNORECASE),
    # Apr26 (year implied 2026)
    re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})(?![\dA-Za-z])(?!_20)", re.IGNORECASE),
    # Nov2025
    re.compile(r"(?<![A-Za-z])(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<y>20\d{2})(?![\dA-Za-z])", re.IGNORECASE),
    # Q1_2026, Q2_2026 — quarter→month
    re.compile(r"(?<![A-Za-z])Q(?P<q>[1-4])_(?P<y>20\d{2})(?![\dA-Za-z])"),
]


def extract_date(name: str) -> str | None:
    """Из имени entity извлекает ISO-дату (YYYY-MM-DD или YYYY-MM).

    Apr26 → 2026-04-26 (год дефолтный 2026 если не указан)
    Nov2025 → 2025-11
    Q1_2026 → 2026-Q1 (для квартала)
    """
    for pat in _DATE_PATTERNS:
        m = pat.search(name)
        if not m:
            continue
        gd = m.groupdict()
        if "q" in gd:
            return f"{gd['y']}-Q{gd['q']}"
        mon = _MONTH_MAP[gd["m"][:3].lower()]
        if "d" in gd:
            y = gd.get("y") or "2026"
            return f"{y}-{mon}-{int(gd['d']):02d}"
        return f"{gd['y']}-{mon}"
    return None


# Категоризация: входим в первое совпавшее правило. Порядок важен —
# specific raньше generic.
def categorize_v2(name: str, entity_type: str) -> str:
    """Возвращает многоуровневый путь поддиректории (без имени файла, без расширения).

    Стратегия — group по бизнес-домену, потом по технической роли.
    """
    n = name
    et = (entity_type or "").lower()

    # 1. Top-level meta — флагманы проекта
    if n in {"Project_GenDesign", "TechStack_v2.2", "NorthStar_DiscoveryMVP",
            "Phase0_DiscoveryMVP", "Stages_MVP_v1", "Team_Setup",
            "Onboarding_Colleague_KG", "WorkSplit_Generative_Stage1"}:
        return "meta"

    # 2. Sprints
    if n.startswith("Sprint") and ("_" in n):
        return "sprints"

    # 3. Sessions — chronological logs
    if n.startswith("Session") or et == "session":
        return "sessions"

    # 4. Bug fixes
    if n.startswith("Bug_") or et == "bug":
        return "fixes"

    # 5. GitHub
    if n.startswith("GitHub_"):
        return "github"

    # 6. Domain: DOM.РФ
    if n.startswith("DomRF") or n == "DomRf_Kn_Scraper_Apr27":
        if "_API_" in n or n.startswith("DomRF_API"):
            return "domains/domrf/api"
        if any(w in n for w in ("_Loaded_", "_Imported_", "_Wipe_")):
            return "domains/domrf/events"
        if "_Schema_" in n or n.endswith("_Schema"):
            return "domains/domrf/schemas"
        if "_Scraper" in n:
            return "domains/domrf/scraper"
        if "Enums" in n or "Architecture" in n or "Host" in n or "Integration" in n:
            return "domains/domrf/api"
        return "domains/domrf"

    # 7. Domain: Cadastre (NSPD + Cad_*)
    if n.startswith("NSPD") or n.startswith("Cad_") or n == "Cad_Quarter_Backfill_Apr30":
        return "domains/cadastre"

    # 8. Domain: Rosreestr
    if (n.startswith("Rosreestr") or n.startswith("Sverdl_") or
        (n.startswith("DataSource_Rosreestr") or n == "Backfill_District_Median_From_Kn_Flats")):
        return "domains/rosreestr"

    # 9. Domain: Analytics
    if any(w in n for w in ("Recommend", "Velocity", "Affordability", "DemandIndex",
                            "AlternativeDeal", "Competitor_Benchmark", "Macro_Context",
                            "Mortgage", "PriceGap", "Tier2", "Tier_Alpha", "Quartirography",
                            "RegionGroups", "Regional_Migration", "RepeatedMortgage",
                            "QualityShift")):
        if n.startswith("Component_") or n.startswith("Feature_"):
            return "domains/analytics/ui"
        if n.startswith("Endpoint_"):
            return "domains/analytics/api"
        if n.startswith("Hook_"):
            return "domains/analytics/code"
        if n.startswith("Bug_"):
            return "fixes"
        if "Trend" in n or "Index" in n or "Report" in n:
            return "domains/analytics/research"
        return "domains/analytics"

    # 10. Domain: UI components/routes (общие, не analytics-specific)
    if n.startswith("Component_") or n.startswith("Feature_") or n.startswith("UI_"):
        return "domains/ui/components"
    if n.startswith("Route_"):
        return "domains/ui/routes"

    # 11. Domain: API endpoints
    if n.startswith("Endpoint_"):
        return "domains/api/endpoints"

    # 12. Domain: Geo
    if (n.startswith("Geo_") or n == "Pattern_OSM_Overpass_Admin_Level"
        or n == "Limit_District_Geometry_Closed" or n == "File_Ekb_Districts_Geojson_Cache"):
        return "domains/geo"

    # 13. Domain: Infra (deploy, docker, hosting, etc)
    if any(w in n for w in ("Docker", "Deploy", "Sentry", "Hosting", "SSH",
                            "OpsBundle", "Domain_Setup", "HTTPS", "Redis",
                            "Caddy", "Celery", "TTL_Lock", "Stealth_Fingerprint",
                            "Runbook_VPS", "Production_Wipe")):
        return "domains/infra"

    # 14. Code: Modules / Schemas / Patterns / Hooks / Tasks
    if n.startswith("Module_") or et == "code_module":
        return "code/modules"
    if n.startswith("Schema_") or et == "schema":
        return "code/schemas"
    if n.startswith("Pattern_"):
        return "code/patterns"
    if n.startswith("Hook_"):
        return "code/hooks"
    if n.startswith("Task_"):
        return "code/tasks"

    # 15. Sources (data source research, non-Rosreestr)
    if n.startswith("DataSource_") or n.startswith("DataSources_"):
        return "sources"

    # 16. Limitations
    if n.startswith("Limit_") or n == "Open_Limitations_Apr30" or et == "limitation":
        return "limitations"

    # 17. Decisions (plans, strategies, policies)
    if n.startswith("PlanA_") or n.startswith("Plan_") or "_Strategy_" in n:
        return "decisions"
    if et in ("policy", "plan", "decision", "decision_pending", "strategy"):
        return "decisions"

    # 18. Events / milestones / objectives
    if (n.startswith("Objective_") or "_Loaded_" in n or "_Imported_" in n
        or n.startswith("Phase_") or et in ("event", "milestone")):
        return "events"

    # 19. Feedback
    if n.startswith("Feedback_") or et == "feedback" or "MCP_AddFacts" in n:
        return "feedback"

    # 20. Research (generic)
    if et == "research":
        return "research"

    # 21. Tests
    if et == "test" or n.endswith("_Test_Apr25"):
        return "research"

    # 22. Reviews
    if et == "review":
        return "research"

    # 23. Build (SiteFinder etc)
    if et == "build" or n.startswith("SiteFinder"):
        return "domains/sitefinder"

    # 24. Deliverable — most likely analytics или infra
    if et == "deliverable":
        return "events"

    # 25. Infrastructure type
    if et in ("infrastructure", "infra"):
        return "domains/infra"

    return "unsorted"


_TAG_RE = re.compile(r"[^a-z0-9]+")


def tags_for_path(subdir: str, entity_type: str) -> list[str]:
    """Генерит теги из subpath + entity_type. Пример:
    domains/domrf/api + api_spec → [domrf, api, domrf-api, api-spec]
    """
    parts = [p for p in subdir.split("/") if p and p not in ("domains", "code")]
    tags = list(dict.fromkeys(parts))  # uniq, preserve order
    # composite tag — domain-subdomain
    if len(parts) >= 2:
        tags.append(f"{parts[0]}-{parts[1]}")
    if entity_type:
        et_norm = _TAG_RE.sub("-", entity_type.lower()).strip("-")
        if et_norm and et_norm not in tags:
            tags.append(et_norm)
    return tags


# Старая функция для совместимости с CouchDB-режимом (он сейчас не используется,
# но всё ещё есть в коде на случай отката).
def categorize(name: str, entity_type: str) -> str:
    subdir = categorize_v2(name, entity_type)
    return subdir.split("/")[0]


_SLUG_RE = re.compile(r"[^a-zA-Zа-яА-Я0-9_\- ]+")


def safe_filename(name: str) -> str:
    """Превращает name в файл-безопасное имя (без / : * etc)."""
    s = _SLUG_RE.sub("", name).strip().replace(" ", "_")
    return s[:200]  # Obsidian/Windows path limit


def md_for_entity(entity: dict, outgoing: list[dict], incoming: list[dict],
                  name_to_path: dict[str, str], subdir: str | None = None) -> str:
    """Генерит .md содержимое entity с frontmatter (теги + дата), observations,
    relations через короткие [[wiki-links]] (Obsidian резолвит по basename).
    """
    name = entity["name"]
    etype = entity.get("entityType", "unknown")
    observations = entity.get("observations") or []
    date = extract_date(name)
    tags = tags_for_path(subdir or "", etype) if subdir else []

    lines: list[str] = []

    # YAML frontmatter
    lines.append("---")
    lines.append(f"id: {name}")
    lines.append(f"type: {etype}")
    if date:
        lines.append(f"date: {date}")
    if tags:
        lines.append(f"tags: [{', '.join(tags)}]")
    lines.append("imported_from: knowledge_graph")
    lines.append(f"imported_at: {time.strftime('%Y-%m-%d')}")
    lines.append("---")
    lines.append("")
    lines.append(f"# {name}")
    lines.append("")
    meta_bits = [f"**Type:** `{etype}`"]
    if date:
        meta_bits.append(f"**Date:** {date}")
    if subdir:
        # breadcrumb: накопленный path → его MOC
        crumbs = []
        parts = subdir.split("/")
        for i in range(len(parts)):
            sub = "/".join(parts[: i + 1])
            crumbs.append(f"[[{_moc_id(sub)}|{parts[i]}]]")
        meta_bits.append("**Path:** " + " / ".join(crumbs))
    lines.append("  ·  ".join(meta_bits))
    lines.append("")

    if observations:
        lines.append("## Observations")
        lines.append("")
        for obs in observations:
            txt = obs.replace("\n", " ").strip()
            lines.append(f"- {txt}")
        lines.append("")

    if outgoing:
        lines.append("## Relations (outgoing)")
        lines.append("")
        # группируем по relationType — нагляднее
        by_rel: dict[str, list[str]] = defaultdict(list)
        for rel in outgoing:
            by_rel[rel["relationType"]].append(rel["to"])
        for rtype, targets in sorted(by_rel.items()):
            lines.append(f"### {rtype}")
            for target in targets:
                if target in name_to_path:
                    lines.append(f"- [[{target}]]")
                else:
                    lines.append(f"- {target}  *(no entity in KG)*")
            lines.append("")

    if incoming:
        lines.append("## Relations (incoming)")
        lines.append("")
        by_rel2: dict[str, list[str]] = defaultdict(list)
        for rel in incoming:
            by_rel2[rel["relationType"]].append(rel["from"])
        for rtype, sources in sorted(by_rel2.items()):
            lines.append(f"### {rtype}  ← from")
            for src in sources:
                if src in name_to_path:
                    lines.append(f"- [[{src}]]")
                else:
                    lines.append(f"- {src}  *(no entity)*")
            lines.append("")

    return "\n".join(lines)


def put_doc(
    base_url: str, db: str, auth: tuple[str, str], doc_id: str, doc: dict,
    dry_run: bool = False,
) -> bool:
    """PUT/POST документ. При conflict — GET _rev и PUT с _rev."""
    if dry_run:
        return True
    url = f"{base_url}/{db}/{quote(doc_id, safe='')}"
    r = requests.put(url, json=doc, auth=auth, timeout=30)
    if r.status_code in (201, 202):
        return True
    if r.status_code == 409:
        # Conflict — try get existing and retry with _rev
        rg = requests.get(url, auth=auth, timeout=30)
        if rg.status_code == 200:
            existing = rg.json()
            doc["_rev"] = existing["_rev"]
            r2 = requests.put(url, json=doc, auth=auth, timeout=30)
            if r2.status_code in (201, 202):
                return True
            logger.warning("Retry-after-conflict PUT %s: HTTP %d %s",
                           doc_id, r2.status_code, r2.text[:200])
        return False
    logger.warning("PUT %s: HTTP %d %s", doc_id, r.status_code, r.text[:200])
    return False


def delete_doc(base_url: str, db: str, auth: tuple[str, str], doc_id: str) -> bool:
    """DELETE документ (для перезаписи старой broken-версии)."""
    url = f"{base_url}/{db}/{quote(doc_id, safe='')}"
    rg = requests.get(url, auth=auth, timeout=30)
    if rg.status_code != 200:
        return True  # уже нет — OK
    rev = rg.json()["_rev"]
    r = requests.delete(f"{url}?rev={rev}", auth=auth, timeout=30)
    return r.status_code in (200, 202)


def _load_kg() -> tuple[dict[str, dict], list[dict]]:
    """Загружает KG из JSONL: returns (entities_by_name, relations_list)."""
    entities: dict[str, dict] = {}
    relations: list[dict] = []
    with KG_PATH.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except json.JSONDecodeError:
                continue
            if d.get("type") == "entity":
                entities[d["name"]] = d
            elif d.get("type") == "relation":
                relations.append(d)
    return entities, relations


_TOP_LEVEL_DESCRIPTIONS = {
    "meta": "🧭 Проект, vision, стратегия, команда",
    "sprints": "🏃 Sprint plans",
    "sessions": "📅 Журналы сессий разработки",
    "events": "📌 События — milestones, объективы, deliverables",
    "decisions": "🎯 Решения, планы, политики (ADR-style)",
    "domains": "🗺 Бизнес-домены — DomRF, Cadastre, Rosreestr, Analytics, UI, API, Geo, Infra",
    "code": "🔧 Code modules, schemas, patterns, hooks, tasks",
    "sources": "💾 Источники данных (research)",
    "research": "🔬 Generic research, отчёты",
    "fixes": "🐞 Bug fixes",
    "limitations": "🚧 Ограничения, что не работает",
    "github": "🐙 GitHub issues",
    "feedback": "💬 Feedback / TODO заметки от пользователя",
    "unsorted": "❓ Не сгруппировано",
}


def _moc_id(subdir: str) -> str:
    """Уникальный (per-vault) идентификатор MOC-файла для папки subdir.
    Использует дефис-разделённый path: domains/domrf/api → domrf-api-MOC.
    Префикс 'domains' опускается у вложенных, но сам domains→domains-MOC.
    """
    parts = subdir.split("/")
    if len(parts) > 1 and parts[0] == "domains":
        parts = parts[1:]
    return "-".join(parts) + "-MOC"


def _moc_for_subdir(subdir: str, entities_in_subdir: list[str],
                     entities_all: dict[str, dict],
                     subdirs_below: list[str]) -> str:
    """Генерит MOC.md (Map of Content) для папки subdir."""
    parts = subdir.split("/")
    top = parts[0]
    title = " / ".join(parts) if len(parts) > 1 else parts[0]
    desc = _TOP_LEVEL_DESCRIPTIONS.get(top, "")
    moc_id = _moc_id(subdir)

    lines = [
        "---",
        f"id: {moc_id}",
        "type: moc",
        f"tags: [moc, {top}]",
        f"imported_at: {time.strftime('%Y-%m-%d')}",
        "---",
        "",
        f"# 🗂 {title}",
        "",
    ]
    if desc and len(parts) == 1:
        lines.append(f"> {desc}")
        lines.append("")

    if subdirs_below:
        lines.append("## Подразделы")
        lines.append("")
        for sd in sorted(subdirs_below):
            sub_moc = _moc_id(sd)
            sd_label = sd.split("/")[-1]
            lines.append(f"- [[{sub_moc}|{sd_label}/]]")
        lines.append("")

    if entities_in_subdir:
        lines.append(f"## Entities ({len(entities_in_subdir)})")
        lines.append("")
        # сортируем по дате (новые сверху), затем по имени
        def sort_key(name: str) -> tuple[str, str]:
            d = extract_date(name) or "0000"
            return (d, name)
        for n in sorted(entities_in_subdir, key=sort_key, reverse=True):
            ent = entities_all.get(n, {})
            et = ent.get("entityType", "")
            d = extract_date(n)
            d_str = f"  · `{d}`" if d else ""
            et_str = f"  · *{et}*" if et else ""
            lines.append(f"- [[{n}]]{d_str}{et_str}")
        lines.append("")

    return "\n".join(lines)


def _global_index_md(entities: dict[str, dict], relations_n: int,
                      by_subdir: dict[str, list[str]]) -> str:
    """Главный индекс (00_index.md) — entry point в vault."""
    lines = [
        "---",
        "id: 00_index",
        "type: index",
        "tags: [index, hub]",
        f"imported_at: {time.strftime('%Y-%m-%d')}",
        "---",
        "",
        "# 🗂 Knowledge Graph — GenDesign",
        "",
        f"Vault содержит **{len(entities)} entities** и **{relations_n} relations**, "
        "мигрированных из `memory/memory-gendesign.jsonl`.",
        "",
        "## Top-level разделы",
        "",
    ]
    # Группируем top-level папки
    top_level: dict[str, int] = defaultdict(int)
    for sd, names in by_subdir.items():
        top_level[sd.split("/")[0]] += len(names)
    for top in sorted(top_level):
        desc = _TOP_LEVEL_DESCRIPTIONS.get(top, "")
        lines.append(f"- [[{_moc_id(top)}|**{top}/**]] ({top_level[top]} entities) — {desc}")
    lines.append("")

    lines.append("## Граф")
    lines.append("")
    lines.append("Открой Graph View (Ctrl+G) — увидишь паутину связей entities по `[[wiki-links]]`.")
    lines.append("")
    lines.append("Фильтр по тегу: `tag:#domrf`, `tag:#analytics`, `tag:#cadastre` и т.д.")
    lines.append("")
    return "\n".join(lines)


def _clean_vault_subdirs(vault_path: Path, keep_files: set[str]) -> int:
    """Удаляет из vault'а всё ранее сгенерированное (по old structure).
    Сохраняет файлы из keep_files (user content: welcome.md и пр).
    Возвращает кол-во удалённых файлов.
    """
    removed = 0
    OLD_DIRS = ("sessions", "entities", "decisions", "runbooks", "sources",
                "meta", "sprints", "events", "domains", "code", "research",
                "fixes", "limitations", "github", "feedback", "unsorted")
    for sub in OLD_DIRS:
        d = vault_path / sub
        if not d.exists():
            continue
        for f in d.rglob("*.md"):
            try:
                f.unlink()
                removed += 1
            except OSError as e:
                logger.warning("Не удалить %s: %s", f, e)
        # удалить пустые папки (rmdir каждый уровень снизу вверх)
        for sub_d in sorted(d.rglob("*"), key=lambda p: -len(p.parts)):
            if sub_d.is_dir():
                try:
                    sub_d.rmdir()
                except OSError:
                    pass
        try:
            d.rmdir()
        except OSError:
            pass
    # Удалить 00_index.md из root если есть
    idx = vault_path / "00_index.md"
    if idx.exists() and "00_index.md" not in keep_files:
        idx.unlink()
        removed += 1
    return removed


def _write_to_local_vault(vault_path: Path, limit: int | None) -> int:
    """Пишет .md файлы напрямую в локальную папку Obsidian vault с иерархической
    структурой + MOC-страницы для навигации.

    LiveSync plugin сам подхватит изменения через filesystem watcher и
    загрузит их в CouchDB в V3-chunked формате.
    """
    if not vault_path.exists() or not vault_path.is_dir():
        logger.error("Vault path не существует: %s", vault_path)
        return 2

    entities, relations = _load_kg()
    logger.info("Loaded %d entities, %d relations", len(entities), len(relations))

    # Cleanup старой структуры (сохраняем user-files)
    keep = {"welcome.md", "qqq.md", "untitled.md", "2026-05-11.md"}
    removed = _clean_vault_subdirs(vault_path, keep)
    logger.info("Cleaned %d old .md files from vault", removed)

    # Relation indices
    out_by: dict[str, list[dict]] = defaultdict(list)
    in_by: dict[str, list[dict]] = defaultdict(list)
    for r in relations:
        out_by[r["from"]].append(r)
        in_by[r["to"]].append(r)

    # Path resolution
    name_to_subdir: dict[str, str] = {}
    name_to_path: dict[str, str] = {}
    for name, ent in entities.items():
        subdir = categorize_v2(name, ent.get("entityType", ""))
        fname = safe_filename(name) + ".md"
        name_to_subdir[name] = subdir
        name_to_path[name] = f"{subdir}/{fname}"

    names = list(entities.keys())
    if limit:
        names = names[:limit]
    logger.info("Will write %d entities", len(names))

    # PASS 1: запись всех entities
    ok = 0
    for i, name in enumerate(names, 1):
        ent = entities[name]
        rel_path = name_to_path[name]
        subdir = name_to_subdir[name]
        seen: set[tuple[str, str, str]] = set()
        out_uniq: list[dict] = []
        for r in out_by[name]:
            key = (r["from"], r["to"], r["relationType"])
            if key in seen:
                continue
            seen.add(key)
            out_uniq.append(r)
        in_uniq: list[dict] = []
        for r in in_by[name]:
            key = (r["from"], r["to"], r["relationType"])
            if key in seen:
                continue
            seen.add(key)
            in_uniq.append(r)
        content = md_for_entity(ent, out_uniq, in_uniq, name_to_path, subdir)

        target = vault_path / rel_path
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
        ok += 1

    logger.info("Entities written: %d", ok)

    # PASS 2: MOC-страницы (для каждой подпапки)
    by_subdir: dict[str, list[str]] = defaultdict(list)
    for name in names:
        by_subdir[name_to_subdir[name]].append(name)

    # Найти все уровни подпапок и их subdirs
    all_subdirs: set[str] = set()
    for sd in by_subdir:
        parts = sd.split("/")
        for i in range(1, len(parts) + 1):
            all_subdirs.add("/".join(parts[:i]))

    moc_count = 0
    for sd in sorted(all_subdirs):
        entities_here = by_subdir.get(sd, [])
        # подпапки на уровень ниже
        subdirs_below = [s for s in all_subdirs
                         if s.startswith(sd + "/") and s.count("/") == sd.count("/") + 1]
        moc_content = _moc_for_subdir(sd, entities_here, entities, subdirs_below)
        moc_filename = _moc_id(sd) + ".md"
        moc_path = vault_path / sd / moc_filename
        moc_path.parent.mkdir(parents=True, exist_ok=True)
        moc_path.write_text(moc_content, encoding="utf-8")
        moc_count += 1

    logger.info("MOC pages written: %d", moc_count)

    # PASS 3: глобальный индекс
    index_content = _global_index_md(entities, len(relations), by_subdir)
    (vault_path / "00_index.md").write_text(index_content, encoding="utf-8")

    logger.info("DONE: %d entities + %d MOC + 1 index в %s",
                ok, moc_count, vault_path)
    logger.info("Открой Obsidian — LiveSync plugin отсканирует и запушит в CouchDB.")
    return 0


def _cleanup_orphan_chunks(base_url: str, db: str, auth: tuple[str, str],
                            dry_run: bool) -> int:
    """Удаляет h:kg* chunk-документы (legacy from old migration schema).

    Использует _all_docs со startkey/endkey по префиксу 'h:kg'. Чанки уже
    не нужны после перехода на eden-inline embedding.
    """
    url = f"{base_url}/{db}/_all_docs"
    params = {"startkey": '"h:kg"', "endkey": '"h:kg\\ufff0"', "limit": 10000}
    r = requests.get(url, params=params, auth=auth, timeout=60)
    if r.status_code != 200:
        logger.error("_all_docs failed: HTTP %d %s", r.status_code, r.text[:200])
        return 1
    rows = r.json().get("rows", [])
    logger.info("Found %d orphan h:kg* chunks", len(rows))
    if dry_run:
        return 0
    # bulk_docs с _deleted:true — куда быстрее чем 549 индивидуальных DELETE.
    docs = [{"_id": row["id"], "_rev": row["value"]["rev"], "_deleted": True}
            for row in rows]
    if not docs:
        return 0
    bulk_url = f"{base_url}/{db}/_bulk_docs"
    rb = requests.post(bulk_url, json={"docs": docs}, auth=auth, timeout=120)
    if rb.status_code not in (201, 200):
        logger.error("_bulk_docs failed: HTTP %d %s", rb.status_code, rb.text[:200])
        return 1
    results = rb.json()
    deleted = sum(1 for x in results if x.get("ok"))
    logger.info("Deleted %d/%d orphan chunks", deleted, len(docs))
    return 0


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true",
                    help="Только сгенерить .md (вывести count) без PUT в CouchDB")
    ap.add_argument("--limit", type=int, default=None,
                    help="Загрузить только первые N entities (для smoke-теста)")
    ap.add_argument("--cleanup-orphan-chunks", action="store_true",
                    help="Удалить осиротевшие h:kg* chunk-документы из предыдущей миграции")
    ap.add_argument("--local-vault", type=str, default=None,
                    help="Путь к локальной папке Obsidian vault. В этом режиме скрипт "
                         "пишет .md файлы прямо в файловую систему, а LiveSync plugin "
                         "сам корректно загрузит их в CouchDB в правильном V3-chunked "
                         "формате. Не использует CouchDB API.")
    args = ap.parse_args()

    base_url = os.environ.get("COUCHDB_URL", "https://obsidian.gendsgn.ru")
    db = os.environ.get("COUCHDB_DB", "gendesign-vault")
    user = os.environ.get("COUCHDB_USER", "obsidian")
    password = os.environ.get("COUCHDB_PASSWORD")
    if not password and not args.dry_run and not args.local_vault:
        logger.error("COUCHDB_PASSWORD env required (unless --dry-run or --local-vault)")
        return 1
    auth = (user, password or "")

    if args.cleanup_orphan_chunks:
        return _cleanup_orphan_chunks(base_url, db, auth, args.dry_run)

    if not KG_PATH.exists():
        logger.error("KG not found: %s", KG_PATH)
        return 2

    if args.local_vault:
        return _write_to_local_vault(Path(args.local_vault), args.limit)

    # 1. Load KG
    entities: dict[str, dict] = {}
    relations: list[dict] = []
    with KG_PATH.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except json.JSONDecodeError:
                continue
            if d.get("type") == "entity":
                # Берём LATEST entity если name дублируется (auto-memory append)
                entities[d["name"]] = d
            elif d.get("type") == "relation":
                relations.append(d)
    logger.info("Loaded %d entities, %d relations", len(entities), len(relations))

    # 2. Build relation indices
    out_by: dict[str, list[dict]] = defaultdict(list)
    in_by: dict[str, list[dict]] = defaultdict(list)
    for r in relations:
        # Дедуп: одна и та же relation может встречаться много раз в auto-memory log
        out_by[r["from"]].append(r)
        in_by[r["to"]].append(r)

    # 3. Compute target paths for [[wiki-links]] resolution
    name_to_path: dict[str, str] = {}
    for name, ent in entities.items():
        subdir = categorize(name, ent.get("entityType", ""))
        fname = safe_filename(name) + ".md"
        name_to_path[name] = f"{subdir}/{fname}"

    # 4. Migrate
    names = list(entities.keys())
    if args.limit:
        names = names[: args.limit]
    logger.info("Will migrate %d entities (--limit=%s)", len(names), args.limit)

    # PASS 1: pre-compute content + chunk_id for every entity, чтобы потом
    # сделать bulk PUT chunks FIRST, потом — main docs.
    prepared: list[tuple[str, str, str, int]] = []  # (path, chunk_id, content, size)
    for name in names:
        ent = entities[name]
        path = name_to_path[name]
        seen: set[tuple[str, str, str]] = set()
        out_uniq: list[dict] = []
        for r in out_by[name]:
            key = (r["from"], r["to"], r["relationType"])
            if key in seen:
                continue
            seen.add(key)
            out_uniq.append(r)
        in_uniq: list[dict] = []
        for r in in_by[name]:
            key = (r["from"], r["to"], r["relationType"])
            if key in seen:
                continue
            seen.add(key)
            in_uniq.append(r)
        content = md_for_entity(ent, out_uniq, in_uniq, name_to_path)
        size_bytes = len(content.encode("utf-8"))
        chunk_id = chunk_id_for(content)
        prepared.append((path, chunk_id, content, size_bytes))

    # PASS 2: bulk PUT chunks (idempotent — если уже есть, CouchDB вернёт conflict,
    # это OK).
    unique_chunks: dict[str, str] = {}  # chunk_id -> content
    for _, cid, content, _ in prepared:
        unique_chunks.setdefault(cid, content)
    logger.info("Bulk PUT %d unique chunks…", len(unique_chunks))
    chunk_docs = [{"_id": cid, "data": data, "type": "leaf"}
                  for cid, data in unique_chunks.items()]
    if not args.dry_run:
        rb = requests.post(f"{base_url}/{db}/_bulk_docs",
                           json={"docs": chunk_docs, "new_edits": True},
                           auth=auth, timeout=120)
        if rb.status_code in (201, 200):
            results = rb.json()
            n_ok = sum(1 for x in results if x.get("ok"))
            n_conflict = sum(1 for x in results if x.get("error") == "conflict")
            logger.info("  chunks: %d created, %d already existed", n_ok, n_conflict)
        else:
            logger.error("Bulk chunks PUT failed: HTTP %d %s",
                         rb.status_code, rb.text[:200])
            return 1

    # PASS 3: PUT main docs sequentially (deleting old eden-only versions first
    # to avoid _rev conflicts from прошлой неудачной миграции).
    ok = 0
    fail = 0
    now_ms = int(time.time() * 1000)
    for i, (path, chunk_id, _content, size_bytes) in enumerate(prepared, 1):
        # delete старую версию (eden-only без children — broken)
        delete_doc(base_url, db, auth, path)
        main_doc = {
            "_id": path,
            "path": path,
            "children": [chunk_id],
            "ctime": now_ms,
            "mtime": now_ms,
            "size": size_bytes,
            "type": "plain",
            "eden": {},
        }
        if put_doc(base_url, db, auth, path, main_doc, args.dry_run):
            ok += 1
        else:
            fail += 1
        if i % 50 == 0:
            logger.info("  main docs: %d/%d (ok=%d fail=%d)",
                        i, len(prepared), ok, fail)

    # 5. Index page
    index_md_lines = [
        "---",
        "id: 00_index",
        "type: index",
        f"imported_at: {time.strftime('%Y-%m-%d')}",
        "---",
        "",
        "# 🗂 Knowledge graph index",
        "",
        f"Migrated from `memory/memory-gendesign.jsonl` ({len(entities)} entities,"
        f" {len(relations)} relations).",
        "",
    ]
    by_subdir: dict[str, list[str]] = defaultdict(list)
    for name, path in name_to_path.items():
        by_subdir[path.split("/")[0]].append(name)
    for subdir in sorted(by_subdir):
        index_md_lines.append(f"## {subdir}/  ({len(by_subdir[subdir])})")
        index_md_lines.append("")
        for n in sorted(by_subdir[subdir]):
            p = name_to_path[n][:-3]
            index_md_lines.append(f"- [[{p}|{n}]]")
        index_md_lines.append("")
    index_content = "\n".join(index_md_lines)
    index_chunk_id = chunk_id_for(index_content)
    # chunk first
    put_doc(base_url, db, auth, index_chunk_id,
            {"_id": index_chunk_id, "data": index_content, "type": "leaf"},
            args.dry_run)
    delete_doc(base_url, db, auth, "00_index.md")
    put_doc(base_url, db, auth, "00_index.md", {
        "_id": "00_index.md", "path": "00_index.md",
        "children": [index_chunk_id], "ctime": now_ms, "mtime": now_ms,
        "size": len(index_content.encode("utf-8")),
        "type": "plain", "eden": {},
    }, args.dry_run)

    logger.info("DONE: %d ok, %d failed", ok, fail)
    return 0 if fail == 0 else 1


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