"""Rule classification and native path-scoped instruction surfaces.

Extracts instruction rule sync from sync.py and adds:
- audience-based classification (shared vs claude-only)
- nested AGENTS.md generation for shared subtree rules
- rule placement validation
- canonical rule loading and validation from .gzkit/rules/

@covers ADR-0.16.0  OBPI-0.16.0-02 rules-as-content
"""

import logging
from pathlib import Path
from typing import Any

import yaml
from pydantic import BaseModel, ConfigDict, Field

from gzkit.config import GzkitConfig

_logger = logging.getLogger(__name__)

_GENERATED_MARKER = "<!-- Generated by gzkit — do not edit manually -->"


class ClassifiedRule(BaseModel):
    """An instruction rule with audience classification."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    filename: str = Field(..., description="Source instruction filename")
    audience: str = Field("shared", description="shared or claude-only")
    apply_to: str = Field(..., description="Raw applyTo frontmatter value")
    body: str = Field(..., description="Rule body content after frontmatter")
    is_global: bool = Field(..., description="Whether the rule applies globally")
    subtree_prefixes: list[str] = Field(
        default_factory=list, description="Directory prefixes for subtree rules"
    )


# ---------------------------------------------------------------------------
# Extracted from sync.py (lines 1181-1244)
# ---------------------------------------------------------------------------


def _skip_leading_comments(lines: list[str]) -> list[str]:
    """Skip leading HTML comments and blank lines before YAML frontmatter."""
    for idx, raw in enumerate(lines):
        stripped = raw.strip()
        if stripped == "---":
            return lines[idx:]
        if stripped.startswith("<!--") and stripped.endswith("-->"):
            continue
        if not stripped:
            continue
        break
    return lines


def _parse_instruction_frontmatter(content: str) -> dict[str, str]:
    """Parse frontmatter from an instruction file.

    Args:
        content: Full file content.

    Returns:
        Dictionary of frontmatter key-value pairs.

    """
    lines = _skip_leading_comments(content.splitlines())
    if not lines or lines[0].strip() != "---":
        return {}

    fm: dict[str, str] = {}
    for raw in lines[1:]:
        stripped = raw.strip()
        if stripped == "---":
            break
        if not stripped or ":" not in stripped:
            continue
        key, value = stripped.split(":", 1)
        fm[key.strip()] = value.strip().strip("\"'")
    return fm


def _extract_body_after_frontmatter(content: str) -> str:
    """Return the body content after YAML frontmatter.

    Args:
        content: Full file content.

    Returns:
        Body text after the closing --- delimiter.

    """
    lines = _skip_leading_comments(content.splitlines())
    if not lines or lines[0].strip() != "---":
        return content

    for idx, raw in enumerate(lines[1:], start=1):
        if raw.strip() == "---":
            return "\n".join(lines[idx + 1 :])
    return content


def _convert_apply_to_paths(apply_to: str) -> list[str]:
    """Convert Copilot applyTo string to Claude paths list.

    Args:
        apply_to: Comma-separated glob patterns.

    Returns:
        List of trimmed glob patterns.

    """
    patterns = [p.strip() for p in apply_to.split(",") if p.strip()]

    for pattern in patterns:
        if any(c in pattern for c in "{}!"):
            _logger.warning(
                "Glob pattern %r contains brace expansion or negation "
                "which may not be supported by Claude Code paths matcher.",
                pattern,
            )

    return patterns


# ---------------------------------------------------------------------------
# Classification helpers
# ---------------------------------------------------------------------------


def _is_global_pattern(pattern: str) -> bool:
    """Check if a glob pattern is global (starts with **)."""
    return pattern.startswith("**")


def _extract_subtree_prefix(pattern: str) -> str | None:
    """Extract the directory prefix from a subtree glob pattern.

    For ``tests/**`` returns ``tests``.
    For ``src/airlineops/warehouse/**`` returns ``src/airlineops/warehouse``.
    Returns None for global patterns like ``**/*``.
    """
    if _is_global_pattern(pattern):
        return None
    parts = pattern.replace("\\", "/").split("/")
    prefix_parts: list[str] = []
    for part in parts:
        if "*" in part or "?" in part:
            break
        prefix_parts.append(part)
    return "/".join(prefix_parts) if prefix_parts else None


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def classify_instruction_rules(project_root: Path) -> list[ClassifiedRule]:
    """Read and classify all instruction files by audience.

    Args:
        project_root: Project root directory.

    Returns:
        List of classified rules.

    """
    instructions_dir = project_root / ".github" / "instructions"
    if not instructions_dir.exists():
        return []

    rules: list[ClassifiedRule] = []
    for src_file in sorted(instructions_dir.iterdir()):
        if not src_file.name.endswith(".instructions.md"):
            continue

        content = src_file.read_text(encoding="utf-8")
        fm = _parse_instruction_frontmatter(content)

        exclude = fm.get("excludeAgent", "")
        if exclude in ("coding-agent", "all"):
            continue

        apply_to = fm.get("applyTo", "")
        if not apply_to:
            continue

        audience = fm.get("audience", "shared")
        body = _extract_body_after_frontmatter(content)

        patterns = _convert_apply_to_paths(apply_to)
        is_global = all(_is_global_pattern(p) for p in patterns)

        subtree_prefixes: list[str] = []
        if not is_global:
            for p in patterns:
                prefix = _extract_subtree_prefix(p)
                if prefix and prefix not in subtree_prefixes:
                    subtree_prefixes.append(prefix)

        rules.append(
            ClassifiedRule(
                filename=src_file.name,
                audience=audience,
                apply_to=apply_to,
                body=body,
                is_global=is_global,
                subtree_prefixes=subtree_prefixes,
            )
        )

    return rules


def sync_claude_rules(project_root: Path, config: GzkitConfig | None = None) -> list[str]:
    """Mirror .github/instructions/*.instructions.md to .claude/rules/*.md.

    Uses config.paths.claude_rules instead of hardcoded path.

    Args:
        project_root: Project root directory.
        config: Optional project configuration.

    Returns:
        List of mirrored file paths (relative to project_root).

    """
    if config is None:
        config = GzkitConfig.load(project_root / ".gzkit.json")

    instructions_dir = project_root / ".github" / "instructions"
    rules_dir = project_root / config.paths.claude_rules
    rules_dir.mkdir(parents=True, exist_ok=True)

    updated: list[str] = []
    expected_names: set[str] = set()

    if instructions_dir.exists():
        for src_file in sorted(instructions_dir.iterdir()):
            if not src_file.name.endswith(".instructions.md"):
                continue

            content = src_file.read_text(encoding="utf-8")
            fm = _parse_instruction_frontmatter(content)

            exclude = fm.get("excludeAgent", "")
            if exclude in ("coding-agent", "all"):
                continue

            apply_to = fm.get("applyTo", "")
            if not apply_to:
                _logger.warning("Skipping %s: no applyTo frontmatter.", src_file.name)
                continue

            body = _extract_body_after_frontmatter(content)
            target_name = src_file.name.replace(".instructions.md", ".md")
            expected_names.add(target_name)

            if apply_to.strip() == "**/*":
                output = body.lstrip("\n")
            else:
                paths = _convert_apply_to_paths(apply_to)
                paths_yaml = "\n".join(f'  - "{p}"' for p in paths)
                output = f"---\npaths:\n{paths_yaml}\n---\n{body}"

            target = rules_dir / target_name
            target.write_text(output, encoding="utf-8")
            updated.append(str(target.relative_to(project_root)))

    if rules_dir.exists():
        for existing in rules_dir.iterdir():
            if existing.is_file() and existing.name not in expected_names:
                existing.unlink()

    return updated


def sync_nested_agents_md(project_root: Path, config: GzkitConfig | None = None) -> list[str]:
    """Generate nested AGENTS.md files for shared subtree rules.

    Args:
        project_root: Project root directory.
        config: Optional project configuration.

    Returns:
        List of nested AGENTS.md file paths (relative to project_root).

    """
    if config is None:
        config = GzkitConfig.load(project_root / ".gzkit.json")

    rules = classify_instruction_rules(project_root)

    subtree_rules: dict[str, list[ClassifiedRule]] = {}
    for rule in rules:
        if rule.audience != "shared" or rule.is_global:
            continue
        for prefix in rule.subtree_prefixes:
            subtree_rules.setdefault(prefix, []).append(rule)

    updated: list[str] = []
    expected_paths: set[Path] = set()

    for subtree, rules_for_subtree in sorted(subtree_rules.items()):
        subtree_dir = project_root / subtree
        if not subtree_dir.exists():
            continue

        agents_path = subtree_dir / "AGENTS.md"
        expected_paths.add(agents_path)

        sections: list[str] = []
        for rule in rules_for_subtree:
            body = rule.body.strip()
            if body:
                sections.append(body)

        if not sections:
            continue

        content = (
            f"{_GENERATED_MARKER}\n"
            f"# {subtree} Agent Instructions\n\n"
            "> Shared subtree rules. See root `AGENTS.md` for project-wide governance.\n\n"
            + "\n\n".join(sections)
            + "\n"
        )

        agents_path.write_text(content, encoding="utf-8")
        updated.append(str(agents_path.relative_to(project_root)))

    _cleanup_stale_nested_agents(project_root, expected_paths)

    return updated


def _cleanup_stale_nested_agents(project_root: Path, expected_paths: set[Path]) -> None:
    """Remove nested AGENTS.md files not in the expected set.

    Only deletes files bearing the gzkit generated marker.
    """
    root_agents = project_root / "AGENTS.md"
    for agents_file in project_root.rglob("AGENTS.md"):
        if agents_file == root_agents:
            continue
        if agents_file in expected_paths:
            continue
        try:
            first_line = agents_file.read_text(encoding="utf-8").split("\n", 1)[0]
            if "Generated by gzkit" in first_line:
                agents_file.unlink()
        except (OSError, UnicodeDecodeError):
            pass


def validate_rule_placement(project_root: Path) -> list[str]:
    """Check for misplaced rules.

    Args:
        project_root: Project root directory.

    Returns:
        List of warning messages.

    """
    warnings: list[str] = []
    rules = classify_instruction_rules(project_root)

    claude_rules_dir = project_root / ".claude" / "rules"

    for rule in rules:
        if rule.audience == "claude-only":
            target_name = rule.filename.replace(".instructions.md", ".md")
            if not (claude_rules_dir / target_name).exists():
                warnings.append(
                    f"{rule.filename}: audience is claude-only but not mirrored to .claude/rules/"
                )
        elif rule.audience == "shared" and not rule.is_global:
            for prefix in rule.subtree_prefixes:
                subtree_dir = project_root / prefix
                agents_path = subtree_dir / "AGENTS.md"
                if subtree_dir.exists() and not agents_path.exists():
                    warnings.append(
                        f"{rule.filename}: shared subtree rule for '{prefix}' "
                        f"has no nested AGENTS.md"
                    )

    return warnings


# ---------------------------------------------------------------------------
# Canonical rule loading (.gzkit/rules/)
# ---------------------------------------------------------------------------


class RuleFrontmatter(BaseModel):
    """Vendor-neutral rule frontmatter schema.

    Every canonical rule in ``.gzkit/rules/`` must have this frontmatter.
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    id: str = Field(..., description="Rule identifier (kebab-case, e.g., 'governance-core')")
    paths: list[str] = Field(
        ..., description="Glob patterns for files this rule applies to", min_length=1
    )
    description: str = Field(..., description="One-line description of the rule's purpose")


class CanonicalRule(BaseModel):
    """A loaded canonical rule: validated frontmatter plus body content."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    frontmatter: RuleFrontmatter = Field(..., description="Validated rule frontmatter")
    body: str = Field(..., description="Markdown body content (after frontmatter)")
    source_path: str = Field(..., description="Path to the source .md file")


def _parse_canonical_frontmatter(path: Path) -> tuple[dict[str, Any], str]:
    """Parse a canonical rule file into (frontmatter_dict, body).

    Raises ValueError if frontmatter delimiters are missing.
    """
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---"):
        msg = f"Rule file missing frontmatter delimiter: {path}"
        raise ValueError(msg)

    parts = text.split("---", 2)
    if len(parts) < 3:
        msg = f"Rule file has unclosed frontmatter: {path}"
        raise ValueError(msg)

    fm_text = parts[1].strip()
    body = parts[2].strip()

    fm_dict: dict[str, Any] = yaml.safe_load(fm_text) or {}
    return fm_dict, body


def load_rule(path: Path) -> CanonicalRule:
    """Load and validate a single canonical rule file.

    Raises ValueError for parse errors, pydantic.ValidationError for
    invalid frontmatter.
    """
    fm_dict, body = _parse_canonical_frontmatter(path)
    frontmatter = RuleFrontmatter(**fm_dict)
    return CanonicalRule(
        frontmatter=frontmatter,
        body=body,
        source_path=str(path),
    )


def load_rules(rules_dir: Path) -> list[CanonicalRule]:
    """Load and validate all canonical rules from a directory.

    Returns a list of validated CanonicalRule objects sorted by rule ID.
    Raises ValueError if the directory does not exist.
    Raises pydantic.ValidationError if any rule has invalid frontmatter.
    """
    if not rules_dir.is_dir():
        msg = f"Rules directory does not exist: {rules_dir}"
        raise ValueError(msg)

    rules: list[CanonicalRule] = []
    for md_file in sorted(rules_dir.glob("*.md")):
        rules.append(load_rule(md_file))

    return sorted(rules, key=lambda r: r.frontmatter.id)


# ---------------------------------------------------------------------------
# Vendor-specific rule rendering (canonical → vendor shape)
# ---------------------------------------------------------------------------

_RENDER_HEADER = "<!-- Generated by gz agent sync — do not edit -->\n"


def render_rule_for_claude(rule: CanonicalRule) -> str:
    """Render a canonical rule into Claude rules format.

    Global rules (paths: ["**/*"]) omit the frontmatter block.
    Scoped rules get ``paths:`` YAML frontmatter.
    """
    is_global = rule.frontmatter.paths == ["**/*"]
    if is_global:
        return f"{_RENDER_HEADER}\n{rule.body}\n"
    paths_yaml = "\n".join(f'  - "{p}"' for p in rule.frontmatter.paths)
    return f"{_RENDER_HEADER}---\npaths:\n{paths_yaml}\n---\n\n{rule.body}\n"


def render_rule_for_copilot(rule: CanonicalRule) -> str:
    """Render a canonical rule into Copilot instructions format.

    Produces ``applyTo:`` frontmatter with comma-separated glob patterns.
    """
    apply_to = ", ".join(rule.frontmatter.paths)
    return f'{_RENDER_HEADER}---\napplyTo: "{apply_to}"\n---\n\n{rule.body}\n'


def render_rules_to_dir(
    rules: list[CanonicalRule],
    target_dir: Path,
    renderer: str,
) -> list[str]:
    """Render canonical rules to a target directory.

    Args:
        rules: Loaded canonical rules.
        target_dir: Output directory (e.g., .claude/rules/ or .github/instructions/).
        renderer: "claude" or "copilot".

    Returns:
        List of written file paths (relative strings).

    """
    target_dir.mkdir(parents=True, exist_ok=True)

    render_fn = render_rule_for_claude if renderer == "claude" else render_rule_for_copilot
    suffix = ".md" if renderer == "claude" else ".instructions.md"
    name_fn = (
        (lambda r: f"{r.frontmatter.id.replace('_', '-')}{suffix}")
        if renderer == "claude"
        else (lambda r: f"{r.frontmatter.id.replace('-', '_')}{suffix}")
    )

    written: list[str] = []
    expected_names: set[str] = set()

    for rule in rules:
        filename = name_fn(rule)
        expected_names.add(filename)
        output = render_fn(rule)
        target = target_dir / filename
        target.write_text(output, encoding="utf-8")
        written.append(str(target))

    for existing in target_dir.iterdir():
        if existing.is_file() and existing.name not in expected_names:
            try:
                content = existing.read_text(encoding="utf-8")
            except UnicodeDecodeError:
                continue
            if "Generated by gz agent sync" in content:
                existing.unlink()

    return written
