"""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, Literal

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 -->"


def _classify_rule_file(
    path: Path,
    *,
    project_root: Path | None = None,
) -> Literal["canonical", "package_only", "runtime_state"]:
    """Classify a rules-surface file into one of three content classes.

    canonical: *.md rule files, plus any non-Python/non-markdown asset that has
               a counterpart at ``.gzkit/rules/<rel>`` (e.g. JSON sidecars
               authored on the canonical side).
    package_only: ``__init__.py``, ``__pycache__/**``, Python modules with no
                  ``.gzkit/rules/`` counterpart (e.g. ``_scaffolder.py``), and
                  non-markdown assets without a canonical counterpart.
    runtime_state: (currently unused for the rules surface; reserved for
                   parity with ``_classify_chore_file``.)

    Signature-compatible with :func:`gzkit.chores._classify_chore_file`. See
    ``.gzkit/rules/skill-surface-sync.md`` § class-classifier.
    """
    path = Path(path)
    name = path.name
    parts = path.parts
    path_posix = path.as_posix()

    # package_only: __init__.py and __pycache__
    if name == "__init__.py" or "__pycache__" in parts:
        return "package_only"

    # Already under .gzkit/rules/ -> canonical (authored side)
    if ".gzkit/rules/" in path_posix:
        return "canonical"

    # Markdown rule files are canonical by default.
    if name.endswith(".md"):
        return "canonical"

    # For Python modules and non-markdown assets under src/gzkit/rules/,
    # resolve a counterpart at .gzkit/rules/<rel> when project_root is known.
    if project_root is not None:
        try:
            rel = path.relative_to(project_root / "src" / "gzkit" / "rules")
        except ValueError:
            rel = None
        if rel is not None:
            counterpart = project_root / ".gzkit" / "rules" / rel
            return "canonical" if counterpart.exists() else "package_only"

    # Fallback when project_root is not provided: resolve relative to CWD
    # when the path lives under src/gzkit/rules/.
    if "src/gzkit/rules/" in path_posix:
        cwd_root = Path.cwd()
        try:
            rel = path.relative_to(cwd_root / "src" / "gzkit" / "rules")
        except ValueError:
            # Try interpreting the input as repo-relative (e.g. when the
            # caller passes a path like Path("src/gzkit/rules/foo.json")
            # without an absolute root).
            try:
                rel = path.relative_to(Path("src/gzkit/rules"))
            except ValueError:
                rel = None
        if rel is not None:
            counterpart = cwd_root / ".gzkit" / "rules" / rel
            return "canonical" if counterpart.exists() else "package_only"

    return "package_only"


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 ``**/*``.
    Returns None for literal file paths with no glob segments (GHI #142) —
    a pattern with no wildcards is a file path, not a subtree prefix.
    """
    if _is_global_pattern(pattern):
        return None
    parts = pattern.replace("\\", "/").split("/")
    prefix_parts: list[str] = []
    saw_wildcard = False
    for part in parts:
        if "*" in part or "?" in part:
            saw_wildcard = True
            break
        prefix_parts.append(part)
    if not saw_wildcard:
        return None
    return "/".join(prefix_parts) if prefix_parts else None


_VENDOR_MIRROR_ROOTS: tuple[str, ...] = (
    ".claude",
    ".agents",
    ".github/instructions",
    ".github/skills",
)


def _is_vendor_mirror_prefix(prefix: str) -> bool:
    """Return True for prefixes that name a vendor-rendered output surface.

    Vendor mirrors (.claude, .agents, .github/instructions, .github/skills)
    are destinations gz writes generated content into — they are not
    user-authored subtrees that take nested AGENTS.md guidance. A meta-rule
    whose paths enumerate the vendor mirror it is itself rendered into
    (e.g. skill-surface-sync includes .github/instructions/**) creates a
    self-referential subtree the validator must waive (GHI #375).
    """
    normalized = prefix.replace("\\", "/").strip("/")
    return any(
        normalized == mirror or normalized.startswith(mirror + "/")
        for mirror in _VENDOR_MIRROR_ROOTS
    )


# ---------------------------------------------------------------------------
# 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 is None or _is_vendor_mirror_prefix(prefix):
                    continue
                if 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(target.relative_to(project_root).as_posix())

    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.is_dir():
            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(agents_path.relative_to(project_root).as_posix())

    _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")):
        if md_file.name == "AGENTS.md":
            continue
        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 on line 1 — Claude
    Code's parser falls back to unconditional load when anything
    (HTML comment included) precedes the opening ``---``.
    """
    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"---\npaths:\n{paths_yaml}\n---\n{_RENDER_HEADER}\n{rule.body}\n"


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

    Produces ``applyTo:`` frontmatter on line 1; the generator header
    follows the closing ``---`` so the frontmatter parser sees a
    well-formed block (same constraint as Claude rules).
    """
    apply_to = ", ".join(rule.frontmatter.paths)
    return f'---\napplyTo: "{apply_to}"\n---\n{_RENDER_HEADER}\n{rule.body}\n'


def _render_path_str(target: Path, project_root: Path | None) -> str:
    """Render a written path as a project-relative posix string when possible.

    Mirrors ``plan_sync_all``'s normalization so apply mode and dry-run agree.
    """
    if project_root is None:
        return str(target)
    try:
        return target.relative_to(project_root).as_posix()
    except ValueError:
        return target.as_posix()


def render_rules_to_dir(
    rules: list[CanonicalRule],
    target_dir: Path,
    renderer: str,
    project_root: Path | None = None,
) -> 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".
        project_root: When given, returned paths are rendered relative to it
            (forward-slash posix). ``sync_all`` passes this so every path in its
            write set has the same project-relative form — apply mode prints the
            list verbatim while dry-run (``plan_sync_all``) normalizes, so a
            mixed absolute/relative return made the two diverge for rule files
            (the macOS-passes / Linux-fails coverage hole this closes).

    Returns:
        List of written file paths — project-relative posix strings when
        ``project_root`` is given, else absolute 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(_render_path_str(target, project_root))

    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


# Re-export scaffolding API so `from gzkit.rules import X` works.
from gzkit.rules._scaffolder import (  # noqa: E402
    CORE_RULES,
    _iter_canonical_rule_slugs,
    scaffold_core_rules,
)

__all__ = [
    "CORE_RULES",
    "CanonicalRule",
    "ClassifiedRule",
    "RuleFrontmatter",
    "_classify_rule_file",
    "_iter_canonical_rule_slugs",
    "classify_instruction_rules",
    "load_rule",
    "load_rules",
    "render_rule_for_claude",
    "render_rule_for_copilot",
    "render_rules_to_dir",
    "scaffold_core_rules",
    "sync_claude_rules",
    "sync_nested_agents_md",
    "validate_rule_placement",
]
