"""Синхронизация канона и скиллов между машинами.

По умолчанию — сухой прогон с диффом. Ничего не перезаписывается без копии рядом:
у чужих инструментов установщики затирали CLAUDE.md без бэкапа, уничтожали .claude.json
и удаляли .gitignore. Здесь это невозможно by design.
"""

from __future__ import annotations

import difflib
import json
import os
import shutil
from datetime import datetime
from pathlib import Path

CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "chekan" / "sync.json"

DEFAULT_CONFIG = {
    "source": "~/Разработки/chekan/canon",
    "targets": {
        "canon": [
            {"from": "CLAUDE.md", "to": "~/.claude/CLAUDE.md"},
            {"from": "AGENTS.md", "to": "~/.grok/AGENTS.md"}
        ],
        "skills": {"from": "skills", "to": "~/.claude/skills"}
    }
}


def _p(path: str | Path) -> Path:
    return Path(os.path.expanduser(str(path))).resolve()


def load_config() -> dict:
    if CONFIG.is_file():
        return json.loads(CONFIG.read_text(encoding="utf-8"))
    return DEFAULT_CONFIG


def write_default_config() -> Path:
    CONFIG.parent.mkdir(parents=True, exist_ok=True)
    CONFIG.write_text(json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return CONFIG


def backup(path: Path) -> Path:
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    dest = path.with_name(f"{path.name}.bak-{stamp}")
    shutil.copy2(path, dest)
    return dest


LOCAL_START = "<!-- chekan:local -->"
LOCAL_END = "<!-- /chekan:local -->"


def extract_local(text: str) -> str | None:
    """Машинно-локальный блок, который синхронизация обязана сохранить.

    На каждой машине есть своё: пути, порты, прокси, предупреждение не ходить по SSH
    к самому себе. Общий канон это не знает и знать не должен, а затирать — значит
    ломать машину.
    """
    if LOCAL_START not in text or LOCAL_END not in text:
        return None
    start = text.index(LOCAL_START)
    end = text.index(LOCAL_END) + len(LOCAL_END)
    return text[start:end]


def merge_local(canon_text: str, current_text: str) -> tuple[str, bool]:
    """Канон + сохранённый локальный блок. Второе значение — был ли блок."""
    local = extract_local(current_text)
    if local is None:
        return canon_text, False
    if LOCAL_START in canon_text:
        head = canon_text[: canon_text.index(LOCAL_START)]
        tail = canon_text[canon_text.index(LOCAL_END) + len(LOCAL_END) :]
        return head + local + tail, True
    return canon_text.rstrip() + "\n\n" + local + "\n", True


def unmarked_divergence(canon_text: str, current_text: str) -> bool:
    """Есть ли на машине содержательные строки, которых нет в каноне и не помечены."""
    stripped = current_text
    local = extract_local(current_text)
    if local:
        stripped = stripped.replace(local, "")
    canon_lines = {ln.strip() for ln in canon_text.splitlines() if ln.strip()}
    own = [
        ln.strip()
        for ln in stripped.splitlines()
        if ln.strip() and ln.strip() not in canon_lines and not ln.startswith("#")
    ]
    return len(own) > 3


def _diff(src: Path, dst: Path) -> list[str]:
    a = dst.read_text(encoding="utf-8", errors="replace").splitlines() if dst.is_file() else []
    b = src.read_text(encoding="utf-8", errors="replace").splitlines()
    return list(
        difflib.unified_diff(a, b, fromfile=f"было {dst}", tofile=f"станет {src}", lineterm="", n=2)
    )


def harvest(dry_run: bool = True) -> int:
    """Собрать канон с ЭТОЙ машины в репозиторий. Обратное направление к synchronise.

    Публикует та машина, где канон свежее; остальные его потребляют. Перед записью —
    сканирование на секреты: канон уезжает в репозиторий, и ключу там не место.
    """
    from .secrets import scan_files

    cfg = load_config()
    source = _p(cfg["source"])
    print(f"Канон собирается в: {source}")
    print("Режим: сухой прогон (для записи добавьте --apply)" if dry_run else "Режим: ЗАПИСЬ")
    print()

    picked: list[tuple[Path, Path]] = []
    for item in cfg["targets"].get("canon", []):
        src, dst = _p(item["to"]), source / item["from"]
        if src.is_file():
            picked.append((src, dst))
            print(f"  {src} → canon/{item['from']}")
        else:
            print(f"  ! {src} — нет на этой машине, пропущено")

    skills_cfg = cfg["targets"].get("skills")
    skill_dirs: list[Path] = []
    if skills_cfg:
        live = _p(skills_cfg["to"])
        if live.is_dir():
            # Симлинк означает, что скилл управляется чужим установщиком. Такие не
            # вендорим: чужой код тянет за собой чужую лицензию и чужое авторство,
            # а копия в нашем репозитории превращается в форк, который никто не обновляет.
            foreign = sorted(d.name for d in live.iterdir() if d.is_symlink())
            skill_dirs = sorted(
                d
                for d in live.iterdir()
                if not d.is_symlink() and (d / "SKILL.md").is_file()
            )
            print(f"  {live} → canon/{skills_cfg['from']} ({len(skill_dirs)} своих скиллов)")
            if foreign:
                print(
                    f"  · пропущено чужих ({len(foreign)}): управляются своим установщиком, "
                    "в канон не копируются"
                )

    print()
    print("Проверка на секреты перед публикацией…")
    to_scan = [s for s, _ in picked]
    for d in skill_dirs:
        to_scan += [p for p in d.rglob("*") if p.is_file() and p.suffix in TEXT_SUFFIXES]
    findings = scan_files(to_scan, Path.home())
    if findings:
        print(f"  ✗ найдено {len(findings)} — публикация остановлена:")
        for f in findings[:10]:
            print(f"      {f}")
        print("\n  Выньте значения в .env и повторите.")
        return 1
    print(f"  ✓ чисто ({len(to_scan)} файлов)")
    print()

    if dry_run:
        print("Ничего не записано. Применить: `chekan sync --harvest --apply`")
        return 0

    source.mkdir(parents=True, exist_ok=True)
    for src, dst in picked:
        dst.parent.mkdir(parents=True, exist_ok=True)
        if dst.is_file() and dst.read_bytes() != src.read_bytes():
            print(f"    копия прежней: {backup(dst)}")
        shutil.copy2(src, dst)
    if skill_dirs and skills_cfg:
        dest_root = source / skills_cfg["from"]
        dest_root.mkdir(parents=True, exist_ok=True)
        for d in skill_dirs:
            target = dest_root / d.name
            if target.exists():
                shutil.rmtree(target)
            shutil.copytree(d, target, symlinks=False, ignore=shutil.ignore_patterns(*IGNORE))
    print(f"Канон собран в {source}. Закоммитьте и запушьте — остальные машины возьмут его через `chekan sync`.")
    return 0


TEXT_SUFFIXES = {".md", ".py", ".sh", ".json", ".yml", ".yaml", ".toml", ".txt"}
IGNORE = ("__pycache__", "*.pyc", ".DS_Store", "node_modules", ".venv", "*.log")


def synchronise(dry_run: bool = True) -> int:
    cfg = load_config()
    source = _p(cfg["source"])
    if not source.is_dir():
        print(f"Источник канона не найден: {source}")
        print(f"Заведите его или поправьте {CONFIG}")
        if not CONFIG.is_file():
            print(f"Записан конфиг по умолчанию: {write_default_config()}")
        return 1

    changes = 0
    print(f"Источник: {source}")
    print("Режим: сухой прогон (для записи добавьте --apply)" if dry_run else "Режим: ЗАПИСЬ")
    print()

    for item in cfg["targets"].get("canon", []):
        src, dst = source / item["from"], _p(item["to"])
        if not src.is_file():
            print(f"! в источнике нет {item['from']} — пропущено")
            continue
        if dst.is_file() and dst.read_bytes() == src.read_bytes():
            print(f"✓ {dst} — совпадает")
            continue

        canon_text = src.read_text(encoding="utf-8")
        current_text = dst.read_text(encoding="utf-8") if dst.is_file() else ""
        merged, had_local = merge_local(canon_text, current_text)

        changes += 1
        print(f"→ {dst}")
        if had_local:
            print("    машинный блок найден и будет сохранён")
        elif current_text and unmarked_divergence(canon_text, current_text):
            print("    ✗ ОСТАНОВЛЕНО: на этой машине есть свои правила, не помеченные как машинные.")
            print("      Синхронизация их сотрёт. Оберните машинную часть в маркеры:")
            print(f"        {LOCAL_START}")
            print("        …ваши машинные правила…")
            print(f"        {LOCAL_END}")
            print("      и повторите. Пока — файл не тронут.")
            print()
            continue

        for line in difflib.unified_diff(
            current_text.splitlines(), merged.splitlines(),
            fromfile=f"было {dst}", tofile="станет", lineterm="", n=2,
        )[:40]:
            print(f"    {line}")

        if not dry_run:
            dst.parent.mkdir(parents=True, exist_ok=True)
            if dst.is_file():
                print(f"    копия: {backup(dst)}")
            dst.write_text(merged, encoding="utf-8")
            print("    записано")
        print()

    skills = cfg["targets"].get("skills")
    if skills:
        changes += _sync_skills(source / skills["from"], _p(skills["to"]), dry_run)

    print()
    if not changes:
        print("Всё синхронно.")
    elif dry_run:
        print(f"Расхождений: {changes}. Ничего не записано. Применить: `chekan sync --apply`")
    else:
        print(f"Применено изменений: {changes}. Копии старых версий лежат рядом.")
    return 0


def _sync_skills(src_dir: Path, dst_dir: Path, dry_run: bool) -> int:
    if not src_dir.is_dir():
        print(f"! каталог скиллов в источнике не найден: {src_dir}")
        return 0

    have = {d.name for d in dst_dir.iterdir() if d.is_dir() or d.is_symlink()} if dst_dir.is_dir() else set()
    want = {d.name for d in src_dir.iterdir() if (d / "SKILL.md").is_file()}

    missing = sorted(want - have)
    extra = sorted(have - want)

    print(f"Скиллы: в источнике {len(want)}, на машине {len(have)}")
    if extra:
        print(f"  только на этой машине ({len(extra)}): {', '.join(extra[:8])}"
              + (" …" if len(extra) > 8 else ""))
        print("  они не трогаются — синхронизация ничего не удаляет")
    if not missing:
        print("  ничего не отсутствует")
        return 0

    print(f"  отсутствуют ({len(missing)}): {', '.join(missing)}")
    if dry_run:
        return len(missing)

    dst_dir.mkdir(parents=True, exist_ok=True)
    for name in missing:
        shutil.copytree(src_dir / name, dst_dir / name)
        print(f"    + {name}")
    return len(missing)
