"""Раскладка Чекана по агентам.

Состояние прогона лежит на диске, поэтому подхватить работу может любой агент. Мешает
этому одно: каждый харнесс ищет свои файлы в своём месте. Модуль кладёт скилл туда, где
агент его найдёт, и дописывает в его файл инструкций короткий указатель на `chekan handoff`.

Указатель намеренно крошечный: он попадает в контекст КАЖДОЙ сессии агента, а исследование
рынка на этом классе инструментов показывает провалы на 18–214 тысячах токенов, съеденных
до первой полезной команды.
"""

from __future__ import annotations

import os
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path

START = "<!-- chekan:start -->"
END = "<!-- chekan:end -->"

POINTER = """Чекан — автопилот доставки. Если в проекте есть каталог `.chekan/` с незакрытым
прогоном, ты продолжаешь чужую работу: весь контекст на диске, диалог предыдущего агента
не нужен. Введи себя в курс одной командой — `chekan handoff`. Она печатает роль, запреты,
текущую фазу и следующий шаг. Отмечать работу выполненной нельзя: `done` ставит код
возврата команды через `chekan run`, иначе CLI откажет."""


@dataclass(frozen=True)
class Agent:
    key: str
    name: str
    home: Path
    skills: Path | None
    instructions: Path | None
    extra_skills: tuple[Path, ...] = ()
    """Доп. места для символьной ссылки на скилл у того же агента.

    Нужно Antigravity: его чат-раннер не видит `~/.gemini/skills` (это читает
    только отдельный CLI `gemini`) — обнаружено вживую 2026-08-17, раскладка по
    доке не подтвердилась сама по себе. Держим сразу оба задокументированных
    места вместо одного, раз источники расходятся между собой и между версиями.
    """

    @property
    def present(self) -> bool:
        return self.home.is_dir()


def plugin_provides_skill() -> bool:
    """У Claude Code скилл приходит плагином — второй в skills/ был бы дублем."""
    cache = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) / "plugins" / "cache"
    return any(cache.glob("*/chekan/*/skills/chekan/SKILL.md"))


def registry() -> list[Agent]:
    h = Path.home()
    cfg = Path(os.environ.get("XDG_CONFIG_HOME", h / ".config"))
    # Тот же каталог, что смотрит doctor: иначе скилл ложится туда, куда Claude Code
    # не заглядывает, а доктор рапортует про правильное место.
    claude = Path(os.environ.get("CLAUDE_CONFIG_DIR", h / ".claude"))
    return [
        Agent("claude", "Claude Code", claude, claude / "skills", claude / "CLAUDE.md"),
        Agent("codex", "Codex", h / ".codex", h / ".codex/skills", h / ".codex/AGENTS.md"),
        Agent("cursor", "Cursor", h / ".cursor", h / ".cursor/skills", h / ".cursor/AGENTS.md"),
        Agent(
            "gemini", "Gemini / Antigravity", h / ".gemini", h / ".gemini/skills", h / ".gemini/GEMINI.md",
            extra_skills=(h / ".gemini/antigravity-cli/skills", h / ".gemini/config/skills"),
        ),
        Agent("grok", "Grok", h / ".grok", h / ".grok/skills", h / ".grok/AGENTS.md"),
        Agent("opencode", "opencode", cfg / "opencode", cfg / "opencode/skills", cfg / "opencode/AGENTS.md"),
        Agent("shared", "Общий каталог (.agents)", h / ".agents", h / ".agents/skills", None),
    ]


def detected() -> list[Agent]:
    return [a for a in registry() if a.present]


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


def _block() -> str:
    return f"{START}\n{POINTER}\n{END}"


def upsert_pointer(path: Path, dry: bool) -> str:
    """Дописывает или обновляет указатель между маркерами. Чужое не трогает."""
    block = _block()
    if path.is_file():
        text = path.read_text(encoding="utf-8")
        if START in text and END in text:
            head, rest = text.split(START, 1)
            _, tail = rest.split(END, 1)
            new = head + block + tail
            if new == text:
                return "уже актуален"
            if dry:
                return "будет обновлён"
            _backup(path)
            path.write_text(new, encoding="utf-8")
            return "обновлён"
        new = text.rstrip() + "\n\n" + block + "\n"
        if dry:
            return "будет дописан"
        _backup(path)
        path.write_text(new, encoding="utf-8")
        return "дописан"
    if dry:
        return "будет создан"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(block + "\n", encoding="utf-8")
    return "создан файл"


def remove_pointer(path: Path, dry: bool) -> str:
    if not path.is_file():
        return "нет файла"
    text = path.read_text(encoding="utf-8")
    if START not in text or END not in text:
        return "указателя не было"
    head, rest = text.split(START, 1)
    _, tail = rest.split(END, 1)
    if dry:
        return "будет убран"
    _backup(path)
    path.write_text((head.rstrip() + "\n" + tail.lstrip()).strip() + "\n", encoding="utf-8")
    return "убран"


def _points_at(link: Path, source: Path) -> bool:
    """Наша ли это ссылка — по устройству и иноду, а не по строке пути.

    Под proot (Termux на Android) сравнение путей не работает в принципе: proot
    переписывает цель ссылки. Просишь записать
    `/data/data/com.termux/files/usr/tmp/x`, а readlink возвращает `/tmp/x` —
    расходятся и readlink, и realpath. Инструмент считал собственную ссылку
    чужой: раскладка не была идемпотентной, а снять её было нечем.
    """
    try:
        return os.path.samefile(link, source)
    except OSError:
        pass
    # Цель не существует — сравнить инодами нечего, остаётся строка.
    try:
        return os.readlink(link) == str(source)
    except OSError:
        return False


def link_skill(target_dir: Path, source: Path, dry: bool) -> str:
    """Симлинк на канонический скилл: копия превращается в форк, который не обновляют.

    Порядок аргументов у link_skill и unlink_skill намеренно одинаков — «куда, что».
    Зеркальные сигнатуры у парных функций один раз уже привели к тому, что ссылки
    создавались внутри репозитория вместо каталога агента, а отчёт рапортовал успех.
    """
    target = target_dir / "chekan"
    if target.is_symlink():
        if _points_at(target, source):
            return "уже связан"
        if not dry:
            target.unlink()
    elif target.exists():
        return "занято не нашим каталогом — не трогаем"
    if dry:
        return "будет связан"
    target_dir.mkdir(parents=True, exist_ok=True)
    target.symlink_to(source)
    if not target.is_symlink():
        return "НЕ СОЗДАН — проверьте права"
    return "связан"


def unlink_skill(target_dir: Path, source: Path, dry: bool) -> str:
    target = target_dir / "chekan"
    if not target.is_symlink():
        return "нет ссылки"
    if not _points_at(target, source):
        return "ссылка ведёт не к нам — не трогаем"
    if dry:
        return "будет убрана"
    target.unlink()
    return "убрана"


def apply(root: Path, dry: bool = True, remove: bool = False) -> int:
    source = root / "skills" / "chekan"
    if not source.is_dir():
        print(f"✗ нет канонического скилла: {source}")
        return 1

    agents = detected()
    if not agents:
        print("Ни одного агента не найдено.")
        return 0

    print("Снятие Чекана с агентов" if remove else "Раскладка Чекана по агентам")
    print("Режим: проверка, без записи" if dry else "Режим: ЗАПИСЬ")
    print()

    for a in agents:
        print(f"{a.name}  ({a.home})")
        if a.key == "claude" and not remove and plugin_provides_skill():
            print("  скилл: даёт плагин — второй в skills/ был бы дублем, пропущено")
        elif a.skills is not None:
            verb = (unlink_skill if remove else link_skill)(a.skills, source, dry)
            print(f"  скилл → {a.skills}/chekan: {verb}")
        for extra in a.extra_skills:
            verb = (unlink_skill if remove else link_skill)(extra, source, dry)
            print(f"  скилл → {extra}/chekan: {verb}")
        if a.instructions is not None:
            verb = remove_pointer(a.instructions, dry) if remove else upsert_pointer(a.instructions, dry)
            print(f"  указатель → {a.instructions.name}: {verb}")
        print()

    if dry:
        print("Ничего не записано. Применить: добавьте --apply")
    else:
        print("Готово. Копии изменённых файлов лежат рядом с оригиналами.")
        print("Проверить на любом агенте: `chekan handoff` внутри проекта с прогоном.")
    return 0
