#!/usr/bin/env python3
"""
check-tags.py — Semantic tag similarity checker for Hugo content

Compares tags in staged files against all existing tags in the site.
Warns and blocks commit when a new tag is semantically similar to an existing one.

Uses spaCy word vectors (en_core_web_lg) for cosine similarity — catches
conceptual matches like "parenting" ≈ "fatherhood" while ignoring unrelated
words that happen to share letters like "dogs" vs "daily".

Fallback: if spaCy is unavailable, uses conservative edit-distance checks only.

Skip with: SKIP_TAG_CHECK=1 git commit

Usage: check-tags.py <file1.md> [file2.md ...]
"""

import os
import re
import sys
import time
from difflib import SequenceMatcher
from pathlib import Path

# --- Colors ---
RED = "\033[0;31m"
YELLOW = "\033[0;33m"
GREEN = "\033[0;32m"
CYAN = "\033[0;36m"
BOLD = "\033[1m"
NC = "\033[0m"

# Cosine similarity threshold for word vectors (0-1).
# 0.65 catches morphological variants (parenting/parenthood) and synonyms
# (cannabis/marijuana) while avoiding unrelated words. Tuned for short blog tags.
SEMANTIC_THRESHOLD = 0.65

# Edit-distance threshold — only used as a typo catcher alongside semantics.
# 0.85 is very conservative: catches "kubernetse" vs "kubernetes" but not
# "dogs" vs "daily" (which scores ~0.40).
TYPO_THRESHOLD = 0.85

# Substring match: shorter tag must be at least this many chars
# and cover at least this fraction of the longer tag.
SUBSTRING_MIN_LEN = 5
SUBSTRING_MIN_RATIO = 0.6

# --- spaCy setup (lazy, with graceful fallback) ---
_nlp = None
_spacy_available = None


def _load_spacy():
    """Load spaCy model once. Returns (nlp, True) or (None, False)."""
    global _nlp, _spacy_available
    if _spacy_available is not None:
        return _nlp, _spacy_available
    try:
        import spacy

        _nlp = spacy.load("en_core_web_lg")
        _spacy_available = True
    except (ImportError, OSError) as e:
        print(
            f"{YELLOW}spaCy not available ({e}), "
            f"falling back to edit-distance only{NC}"
        )
        _nlp = None
        _spacy_available = False
    return _nlp, _spacy_available


def extract_tags(filepath: Path, *, keep_blanks: bool = False) -> list[str]:
    """Extract tags from YAML front matter of a markdown file.

    If keep_blanks is True, empty strings from blank tag entries like
    tags: ["", "foo"] are preserved so the caller can detect them.
    """
    try:
        text = filepath.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return []

    # Match front matter between --- delimiters
    fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
    if not fm_match:
        return []

    frontmatter = fm_match.group(1)

    tags: list[str] = []

    # Match inline array: tags: ["foo", "bar"] or tags: ['foo', 'bar'] or tags: [foo, bar]
    inline = re.search(r"^tags:\s*\[([^\]]*)\]", frontmatter, re.MULTILINE)
    if inline:
        raw = inline.group(1).strip()
        if raw:  # non-empty array contents (skip tags: [] and tags: [ ])
            tags = [t.strip().strip("\"'").lower() for t in raw.split(",")]
    else:
        # Match YAML list format:
        # tags:
        #   - foo
        #   - bar
        list_match = re.search(
            r"^tags:\s*\n((?:\s+-\s+.+\n?)+)", frontmatter, re.MULTILINE
        )
        if list_match:
            items = re.findall(r"^\s+-\s+(.*)", list_match.group(1), re.MULTILINE)
            tags = [t.strip().strip("\"'").lower() for t in items]

    if keep_blanks:
        return tags
    return [t for t in tags if t]


def find_similar(
    new_tag: str,
    existing_tags: set[str],
    existing_docs: dict | None = None,
) -> list[tuple[str, str]]:
    """Find existing tags similar to a new tag.

    Uses semantic similarity (spaCy vectors) as the primary check,
    with edit-distance as a typo-catching backup.

    If existing_docs is provided, it should be a dict mapping tag strings
    to their pre-computed spaCy Doc objects (avoids redundant nlp() calls).

    Returns list of (existing_tag, reason) tuples.
    """
    nlp, has_spacy = _load_spacy()
    similar = []

    for existing in sorted(existing_tags):
        if existing == new_tag:
            continue

        # --- Check 1: Substring match (restricted) ---
        shorter, longer = sorted([new_tag, existing], key=len)
        if (
            len(shorter) >= SUBSTRING_MIN_LEN
            and shorter in longer
            and len(shorter) / len(longer) >= SUBSTRING_MIN_RATIO
        ):
            similar.append((existing, "substring match"))
            continue

        # --- Check 2: Semantic similarity (primary) ---
        if has_spacy:
            doc_new = nlp(new_tag)
            doc_ex = existing_docs[existing] if existing_docs else nlp(existing)

            if doc_new.has_vector and doc_ex.has_vector:
                score = doc_new.similarity(doc_ex)
                if score >= SEMANTIC_THRESHOLD:
                    similar.append((existing, f"semantic: {score:.0%}"))
                    continue

        # --- Check 3: Typo detection via edit distance (conservative) ---
        ratio = SequenceMatcher(None, new_tag, existing).ratio()
        if ratio >= TYPO_THRESHOLD:
            similar.append((existing, f"typo match: {ratio:.0%}"))

    return similar


def main() -> int:
    if os.environ.get("SKIP_TAG_CHECK") == "1":
        print(f"{YELLOW}Skipping tag similarity check (SKIP_TAG_CHECK=1){NC}")
        return 0

    if len(sys.argv) < 2:
        print("Usage: check-tags.py <file1.md> [file2.md ...]", file=sys.stderr)
        return 1

    staged_files = sys.argv[1:]

    # Find repo root
    repo_root = Path.cwd()
    content_dir = repo_root / "content"
    if not content_dir.is_dir():
        print(f"{YELLOW}No content/ directory found, skipping tag check{NC}")
        return 0

    # Resolve staged files to absolute paths for exclusion
    staged_abs = {(repo_root / f).resolve() for f in staged_files}

    # Build tag registry from all content files (excluding staged files)
    all_tags: set[str] = set()
    for md_file in content_dir.rglob("*.md"):
        if md_file.resolve() in staged_abs:
            continue
        all_tags.update(extract_tags(md_file))

    if not all_tags:
        print(f"{GREEN}No existing tags found, nothing to compare against.{NC}")
        return 0

    # Pre-compute spaCy docs for all existing tags (avoids repeated nlp() calls)
    nlp, has_spacy = _load_spacy()
    existing_docs = {tag: nlp(tag) for tag in all_tags} if has_spacy else None

    # Check staged files for similar tags
    found_issues = False
    start = time.monotonic()

    for staged_file in staged_files:
        filepath = repo_root / staged_file
        if not filepath.is_file():
            continue
        # Only check content files
        if not staged_file.startswith("content/"):
            continue

        file_tags_raw = extract_tags(filepath, keep_blanks=True)

        # Check for blank/empty tags
        blank_count = sum(1 for t in file_tags_raw if not t)
        if blank_count:
            found_issues = True
            print()
            print(
                f"{YELLOW}{BOLD}Found {blank_count} blank tag(s) "
                f"in {staged_file}{NC}"
            )
            print(f"{YELLOW}  Remove empty strings from the tags array{NC}")

        file_tags = [t for t in file_tags_raw if t]

        for tag in file_tags:
            # If the tag already exists exactly, it's fine
            if tag in all_tags:
                continue

            # New tag — check for similarity
            similar = find_similar(tag, all_tags, existing_docs)

            if similar:
                found_issues = True
                print()
                print(f"{YELLOW}{BOLD}New tag '{tag}' in {staged_file}{NC}")
                print(f"{YELLOW}  Similar existing tags:{NC}")
                for existing, reason in similar:
                    print(f"    {CYAN}\u2192 {existing} ({reason}){NC}")

    elapsed = time.monotonic() - start

    if found_issues:
        print()
        print(f"{RED}{BOLD}Tag similarity check failed.{NC}")
        print(f"{RED}Consider using an existing tag, or skip with:{NC}")
        print(f"{RED}  SKIP_TAG_CHECK=1 git commit{NC}")
        print(f"{RED}  ({elapsed:.1f}s){NC}")
        print()
        return 1

    print(f"{GREEN}Tag check passed \u2014 no similar tags found. ({elapsed:.1f}s){NC}")
    return 0


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