"""Скиллы машины владельца: список и подсказка к задаче.

Копия скиллов лежит в `canon/skills` — по каталогу с `SKILL.md` на скилл. Здесь
две работы и обе маленькие.

1. **Список.** `chekan skills` печатает имя и первое предложение поля `description`.
   Описания там — многострочные абзацы на пятнадцать строк: вывалить их целиком
   значит сделать список нечитаемым, ради чего его и заводили.

2. **Подсказка.** Слова названия и зоны задачи ищутся в описании скилла. Правил
   ровно три, и все — про молчание, а не про полноту:

   - Слово, которым описан каждый седьмой скилл каталога («проверка», «скилл»),
     не различает ничего: по нему подсказка не даётся.
   - Одного совпавшего слова мало: `chekan/report.py` совпадает с
     `zemlya-report-e2e` по слову «report», и это совпадение имени файла, а не
     смысла. Нужны минимум два разных слова, и хотя бы одно — в имени скилла:
     имя говорит, о чём скилл, а описание может помянуть слово вскользь.
   - Хвост описания после «НЕ для» перечисляет ЧУЖИЕ скиллы («zemlya →
     zemlya-report-e2e»). Искать в нём — верный способ посоветовать ровно тот
     скилл, который автор описания там запретил.

Разделение с `accept-map.json` названо прямо: карта решает, чем **принимать
проект**, эта подсказка — чем **делать задачу**. Слепому приёмщику её не дают:
подсказка сняла бы с него слепоту.
"""

from __future__ import annotations

import re
from collections import Counter
from pathlib import Path
from typing import NamedTuple

CANON = Path(__file__).resolve().parent.parent / "canon" / "skills"

# Больше трёх — уже не подсказка, а второй каталог.
LIMIT = 3

# Доля каталога, после которой слово считается общим и подсказки не даёт.
GENERIC_SHARE = 0.50

# Сколько разных слов задачи должно сойтись с описанием, чтобы это был совет.
ENOUGH = 2

# Общие стоп-слова и служебные предлоги/союзы, не несущие предметного смысла.
STOPWORDS: frozenset[str] = frozenset({
    "при", "как", "для", "под", "без", "или", "что", "это", "после", "перед",
    "когда", "брать", "задачи", "задача", "задачу", "прогон", "прогона", "прогоне",
    "просят", "вместо", "любой", "всегда", "тем", "того", "также", "снять", "через",
    "работает", "проверь", "прогони", "проверить", "доработки", "доработке", "доработка",
    "the", "with", "for", "and", "from", "use", "when", "asks", "user", "run", "test",
})

# Где кончается «когда брать» и начинается «а когда не брать».
NOT_FOR = re.compile(r"(НЕ\s+для|НЕ\s+подменяет|НЕ\s+вместо|НЕ\s+О[Тт]|NOT\s+for|Not\s+for)")

WORD = re.compile(r"[0-9a-zA-Zа-яёА-ЯЁ]+")

# Конец предложения: точка (или ! ?), за которой пробел или конец строки. Так
# «.epf», «web.telegram.org» и «zbrcheck.ru» не разрывают фразу пополам.
SENTENCE = re.compile(r"[.!?](?=\s|$)")


class Skill(NamedTuple):
    name: str
    summary: str  # первое предложение описания, одной строкой
    trigger: str  # часть описания до «НЕ для» — по ней и ищется совпадение


def description(text: str) -> str:
    """Поле `description` из фронтматтера, свёрнутое в одну строку.

    YAML не разбираем: зависимости у инструмента нет, а формат здесь один и тот
    же — ключ и продолжение с отступом (`>`, `>-` или простая строка).
    """
    if not text.startswith("---"):
        return ""
    end = text.find("\n---", 3)
    head = text[3:end] if end != -1 else text[3:]
    lines = head.splitlines()
    collected: list[str] = []
    for i, line in enumerate(lines):
        if not line.startswith("description:"):
            continue
        first = line.split(":", 1)[1].strip().lstrip(">|").lstrip("-").strip()
        collected.append(first)
        for tail in lines[i + 1:]:
            if tail.strip() and not tail[0].isspace():
                break  # начался следующий ключ
            collected.append(tail.strip())
        break
    return " ".join(w for w in " ".join(collected).split() if w)


def first_sentence(text: str) -> str:
    match = SENTENCE.search(text)
    return text[: match.end()].strip() if match else text.strip()


def catalog(directory: Path | None = None) -> list[Skill]:
    """Скиллы из каталога канона, по имени. Нет каталога — пустой список."""
    root = directory or CANON
    if not root.is_dir():
        return []
    found: list[Skill] = []
    for item in sorted(root.iterdir()):
        card = item / "SKILL.md"
        if not card.is_file():
            continue
        try:
            text = card.read_text(encoding="utf-8")
        except OSError:
            continue
        full = description(text)
        cut = NOT_FOR.search(full)
        found.append(
            Skill(
                name=item.name,
                summary=first_sentence(full),
                trigger=full[: cut.start()] if cut else full,
            )
        )
    return found


def words(text: str) -> set[str]:
    """Слова длиннее двух знаков за вычетом служебных стоп-слов."""
    return {w for w in WORD.findall(text.lower()) if len(w) > 2 and w not in STOPWORDS}


def suggest(task: dict, skills: list[Skill] | None = None) -> list[Skill]:
    """Подходящие задаче скиллы. Ничего не подошло — пустой список."""
    skills = catalog() if skills is None else skills
    if not skills:
        return []

    index = [(s, words(s.name), words(s.trigger)) for s in skills]
    seen: Counter[str] = Counter()
    for _skill, name_words, trigger_words in index:
        seen.update(name_words | trigger_words)
    ceiling = max(1, int(len(skills) * GENERIC_SHARE))

    asked = words(task.get("title", "")) | words(task.get("zone", ""))
    asked = {w for w in asked if seen.get(w, 0) <= ceiling}

    scored: list[tuple[int, str, Skill]] = []
    for skill, name_words, trigger_words in index:
        if skill.name == "chekan":
            continue
        hits = [w for w in asked if w in name_words or w in trigger_words]
        if len(hits) < ENOUGH:
            continue
        # Имя весит вдвое: попадание в имя скилла — совпадение по существу,
        # попадание в описание может быть и вскользь брошенным словом.
        score = sum(2 if w in name_words else 1 for w in hits)
        scored.append((-score, skill.name, skill))
    scored.sort()
    return [skill for _score, _name, skill in scored[:LIMIT]]


def render(skills: list[Skill] | None = None, width: int = 100) -> list[str]:
    """Строки списка: имя и первое предложение. Ни одной многострочной."""
    skills = catalog() if skills is None else skills
    if not skills:
        return [f"Скиллов не найдено: нет каталога {CANON}."]
    pad = max(len(s.name) for s in skills)
    rows = []
    for skill in skills:
        row = f"  {skill.name:<{pad}}  {skill.summary}".rstrip()
        rows.append(row if len(row) <= width else row[: width - 1] + "…")
    return rows
