"""Lint wiki pages for citation staleness, missing sources, and unmarked claims.

Two modes:
- lightweight: runs automatically after sync, checks only pages whose sources changed
- full: manual ``lilbee wiki lint``, checks all wiki pages
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path

from lilbee.core.config import Config, cfg
from lilbee.core.security import validate_path_within
from lilbee.data.ingest import file_hash
from lilbee.data.store import CitationRecord, Store
from lilbee.wiki.citation import (
    CitationStatus,
    find_unmarked_claims,
    verify_citation,
)
from lilbee.wiki.grammar import WIKI_LINK_RE
from lilbee.wiki.index import append_wiki_log
from lilbee.wiki.shared import (
    WIKI_CONTENT_SUBDIRS,
    WikiLogAction,
    WikiSubdir,
    parse_frontmatter,
)

_ORPHAN_CANDIDATE_SUBDIRS: tuple[str, ...] = (WikiSubdir.CONCEPTS, WikiSubdir.ENTITIES)

log = logging.getLogger(__name__)


class IssueSeverity(Enum):
    """Severity level for lint issues."""

    WARNING = "warning"
    ERROR = "error"


class IssueType(Enum):
    """Classification of lint findings, used by prune to filter programmatically."""

    PATH_TRAVERSAL = "path_traversal"
    SOURCE_MISSING = "source_missing"
    STALE_HASH = "stale_hash"
    EXCERPT_MISSING = "excerpt_missing"
    MODEL_CHANGED = "model_changed"
    UNMARKED_CLAIM = "unmarked_claim"
    ORPHAN = "orphan"


@dataclass(frozen=True)
class LintIssue:
    """A single lint finding on a wiki page."""

    wiki_source: str
    severity: IssueSeverity
    message: str
    issue_type: IssueType | None = None

    def to_dict(self) -> dict[str, str]:
        """Serialize to a plain dict suitable for JSON output."""
        return {
            "wiki_source": self.wiki_source,
            "severity": self.severity.value,
            "message": self.message,
            "issue_type": self.issue_type.value if self.issue_type else "",
        }


@dataclass
class LintReport:
    """Aggregated results from linting one or more wiki pages."""

    issues: list[LintIssue] = field(default_factory=list)

    @property
    def error_count(self) -> int:
        return sum(1 for i in self.issues if i.severity == IssueSeverity.ERROR)

    @property
    def warning_count(self) -> int:
        return sum(1 for i in self.issues if i.severity == IssueSeverity.WARNING)


def _lint_citation(
    rec: CitationRecord,
    documents_dir: Path,
) -> LintIssue | None:
    """Check a single citation record against the filesystem.
    Returns a LintIssue if the citation is stale or broken, None if valid.
    """
    source_path = documents_dir / rec["source_filename"]
    wiki_source = rec["wiki_source"]

    try:
        validate_path_within(source_path, documents_dir)
    except ValueError:
        return LintIssue(
            wiki_source=wiki_source,
            severity=IssueSeverity.ERROR,
            message=f"Source path escapes documents dir: {rec['source_filename']}",
            issue_type=IssueType.PATH_TRAVERSAL,
        )

    if not source_path.exists():
        return LintIssue(
            wiki_source=wiki_source,
            severity=IssueSeverity.ERROR,
            message=f"Source deleted: {rec['source_filename']}",
            issue_type=IssueType.SOURCE_MISSING,
        )

    current_hash = file_hash(source_path)
    if current_hash != rec["source_hash"]:
        return LintIssue(
            wiki_source=wiki_source,
            severity=IssueSeverity.WARNING,
            message=f"Stale hash for {rec['source_filename']} (citation: {rec['citation_key']})",
            issue_type=IssueType.STALE_HASH,
        )

    source_text = source_path.read_text(encoding="utf-8", errors="replace")
    status = verify_citation(rec, source_text)
    if status == CitationStatus.EXCERPT_MISSING:
        return LintIssue(
            wiki_source=wiki_source,
            severity=IssueSeverity.WARNING,
            message=f"Excerpt not found in source for {rec['citation_key']}",
            issue_type=IssueType.EXCERPT_MISSING,
        )
    return None


def _lint_model_changed(wiki_source: str, text: str, config: Config) -> LintIssue | None:
    """Flag pages whose generated_by model differs from the current chat model."""
    generated_by = parse_frontmatter(text).get("generated_by", "")
    if not generated_by:
        return None
    if generated_by != config.chat_model:
        return LintIssue(
            wiki_source=wiki_source,
            severity=IssueSeverity.WARNING,
            issue_type=IssueType.MODEL_CHANGED,
            message=(
                f"model_changed: page generated by {generated_by!r}, "
                f"current model is {config.chat_model!r}"
            ),
        )
    return None


def _lint_unmarked(wiki_source: str, text: str) -> list[LintIssue]:
    """Find unmarked claims in a wiki page."""
    unmarked = find_unmarked_claims(text)
    return [
        LintIssue(
            wiki_source=wiki_source,
            severity=IssueSeverity.WARNING,
            message=f"Unmarked claim: {line[:80]}",
            issue_type=IssueType.UNMARKED_CLAIM,
        )
        for line in unmarked
    ]


def lint_wiki_page(
    wiki_source: str,
    store: Store,
    config: Config | None = None,
) -> list[LintIssue]:
    """Lint a single wiki page: check citations and unmarked claims."""
    if config is None:
        config = cfg
    issues: list[LintIssue] = []

    citations = store.get_citations_for_wiki(wiki_source)
    for rec in citations:
        issue = _lint_citation(rec, config.documents_dir)
        if issue is not None:
            issues.append(issue)

    wiki_root = config.data_root / config.wiki_dir
    # wiki_source is like "wiki/summaries/doc.md": strip the wiki_dir prefix
    relative = str(wiki_source).removeprefix(str(config.wiki_dir) + "/")
    wiki_path = wiki_root / relative
    if wiki_path.exists():
        text = wiki_path.read_text(encoding="utf-8", errors="replace")
        issues.extend(_lint_unmarked(wiki_source, text))
        model_issue = _lint_model_changed(wiki_source, text, config)
        if model_issue is not None:
            issues.append(model_issue)

    return issues


def lint_changed_sources(
    changed_sources: list[str],
    store: Store,
    config: Config | None = None,
) -> LintReport:
    """Lightweight lint for wiki pages citing changed or removed sources.

    Callable from tools that already know the set of changed sources
    (e.g. a future `lilbee wiki check <source>` command); the sync
    pipeline uses `lilbee.wiki.ingest.incremental_update` instead, which runs full
    extraction rather than citation replay.
    """
    if config is None:
        config = cfg
    report = LintReport()

    seen_pages: set[str] = set()
    for source_name in changed_sources:
        citations = store.get_citations_for_source(source_name)
        for rec in citations:
            wiki_source = rec["wiki_source"]
            if wiki_source in seen_pages:
                continue
            seen_pages.add(wiki_source)
            report.issues.extend(lint_wiki_page(wiki_source, store, config))

    if report.issues:
        log.info(
            "Wiki lint: %d error(s), %d warning(s)",
            report.error_count,
            report.warning_count,
        )
    return report


def lint_all(
    store: Store,
    config: Config | None = None,
) -> LintReport:
    """Full lint: check every wiki page in the store."""
    if config is None:
        config = cfg
    report = LintReport()

    wiki_root = config.data_root / config.wiki_dir
    if not wiki_root.exists():
        return report

    for subdir in WIKI_CONTENT_SUBDIRS:
        subdir_path = wiki_root / subdir
        if not subdir_path.is_dir():
            continue
        for md_path in sorted(subdir_path.rglob("*.md")):
            relative = md_path.relative_to(wiki_root)
            wiki_source = f"{config.wiki_dir}/{relative.as_posix()}"
            report.issues.extend(lint_wiki_page(wiki_source, store, config))

    report.issues.extend(_lint_orphans(wiki_root, config))
    append_wiki_log(
        WikiLogAction.LINT,
        f"{report.error_count} error(s), {report.warning_count} warning(s)",
        config,
    )
    return report


def _lint_orphans(wiki_root: Path, config: Config) -> list[LintIssue]:
    """Flag concept/entity pages that no other page links back to.

    Single-pass over the wiki tree: we collect every inbound
    ``[[slug]]`` reference and the set of orphan candidates in one
    ``rglob`` walk, then subtract. The earlier two-pass version
    re-walked the tree to compute ``referenced`` and again to check
    candidates, which doubles the file-IO at build time.
    """
    referenced: set[str] = set()
    candidates: list[Path] = []
    candidate_roots = {wiki_root / sub for sub in _ORPHAN_CANDIDATE_SUBDIRS}
    for md_path in wiki_root.rglob("*.md"):
        text = md_path.read_text(encoding="utf-8", errors="replace")
        for match in WIKI_LINK_RE.finditer(text):
            slug = match.group(1).split("|", 1)[0].strip().lower()
            if slug:
                referenced.add(slug)
        if any(root in md_path.parents for root in candidate_roots):
            candidates.append(md_path)

    issues: list[LintIssue] = []
    for md_path in sorted(candidates):
        slug = md_path.stem.lower()
        if slug in referenced:
            continue
        relative = md_path.relative_to(wiki_root)
        wiki_source = f"{config.wiki_dir}/{relative.as_posix()}"
        issues.append(
            LintIssue(
                wiki_source=wiki_source,
                severity=IssueSeverity.WARNING,
                issue_type=IssueType.ORPHAN,
                message=f"Orphan: no inbound [[{slug}]] links from any other page",
            )
        )
    return issues
