"""Fabrication detector: validates LLM output against input context.

Catches hallucinated proper nouns (project names, task names, people,
organizations, goals) that appear in LLM output but not in the context
data the LLM was given. This is the engine-level guardrail that
complements prompt-level anti-fabrication rules.

The detector does NOT reject the output — it strips the fabricated lines
and emits activity events so the user can see what was removed. This is
a safety net, not a quality gate: the prompt rules are the primary
defense, this catches what leaks through.

Rule #16: NO FABRICATED DATA — EVER.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field

log = logging.getLogger(__name__)


@dataclass
class FabricationReport:
    """Result of a fabrication check."""

    fabricated_names: list[str] = field(default_factory=list)
    flagged_lines: list[str] = field(default_factory=list)
    clean_output: str = ""
    was_modified: bool = False


# ── Proper noun extraction ────────────────────────────────────────

# Match multi-word Title Case phrases (2+ words), which are likely
# project/task/person names. Single Title Case words at line start
# are too common in markdown (every bullet starts with one).
_TITLE_CASE_RE = re.compile(
    r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b"
)

# Known structural words that are Title Case but not proper nouns.
# These appear naturally in markdown headers, bullet labels, etc.
_STRUCTURAL_NAMES: set[str] = {  # arch:deterministic — noise filter for detector's own output format, not content strategy
    # Section headers from format.yaml
    "Good Morning", "Today Focus", "Active Tasks", "Coaching Nudges",
    "Yesterday Takeaway", "About You", "Day Summary", "Still Active",
    "Decisions Made", "Memory Updates", "Key Decisions", "Key Takeaways",
    "Week Summary", "Goal Check", "Suggested Memory", "No Active",
    "Nothing Scheduled", "None Today", "No Updates", "No Decisions",
    "No Learnings", "No Memory", "All Goals",
    # Common greeting phrases
    "Good Afternoon", "Good Evening",
    # Days / months
    "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
    "Saturday", "Sunday", "January", "February", "March", "April",
    "May", "June", "July", "August", "September", "October",
    "November", "December",
    # Common markdown label phrases
    "Action Items", "Next Steps", "Open Questions",
    "No Specific", "Not Set",
}

# Words that are always structural even as part of a multi-word name
_STRUCTURAL_PREFIXES = {  # arch:deterministic — grammatical prefix filter, not content strategy
    "no", "not", "none", "nothing", "all", "the", "this", "that",
    "write", "output", "use", "keep", "never", "if", "be",
}

# Single words that are structural even inside a multi-word phrase
_STRUCTURAL_SINGLE_WORDS = {  # arch:deterministic — calendar/time-of-day words, not content strategy
    "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
    "Saturday", "Sunday", "January", "February", "March", "April",
    "May", "June", "July", "August", "September", "October",
    "November", "December", "Morning", "Afternoon", "Evening",
}


def extract_proper_nouns(text: str) -> set[str]:
    """Extract likely proper nouns (multi-word Title Case) from text.

    Returns a set of candidate proper noun strings found in the text.
    Filters out known structural phrases and markdown noise.
    """
    candidates: set[str] = set()
    for match in _TITLE_CASE_RE.finditer(text):
        name = match.group(1).strip()
        # Skip known structural names
        if name in _STRUCTURAL_NAMES:
            continue
        # Strip leading structural prefix words (The, No, All, etc.)
        words = name.split()
        while words and words[0].lower() in _STRUCTURAL_PREFIXES:
            words = words[1:]
        if len(words) < 2:
            # After stripping prefixes, need at least 2 words
            continue
        name = " ".join(words)
        # Re-check structural names after stripping
        if name in _STRUCTURAL_NAMES:
            continue
        # Check if any remaining word is a standalone structural name
        # (e.g., "Monday Morning" — Monday is structural)
        if any(w in _STRUCTURAL_SINGLE_WORDS for w in words):
            continue
        # Skip very short names (< 4 chars total)
        if len(name) < 4:
            continue
        candidates.add(name)
    return candidates


# ── Context name extraction ───────────────────────────────────────

def extract_context_names(context: str) -> set[str]:
    """Extract all proper nouns and identifiers from the input context.

    This builds the "ground truth" set — names that actually exist in
    the data the LLM was given. The LLM output is only allowed to
    reference names that appear here.
    """
    names: set[str] = set()

    # Extract multi-word Title Case names from context
    for match in _TITLE_CASE_RE.finditer(context):
        name = match.group(1).strip()
        names.add(name)
        # Also add lowercased version for case-insensitive matching
        names.add(name.lower())

    # Extract [[wikilinks]] — these are explicit entity references
    for match in re.finditer(r"\[\[([^\]]+)\]\]", context):
        name = match.group(1).strip()
        names.add(name)
        names.add(name.lower())

    # Extract frontmatter-style values (title:, name:, project:, etc.)
    for match in re.finditer(r"(?:title|name|project|goal|task|slug):\s*[\"']?([^\"'\n]+)", context, re.IGNORECASE):
        val = match.group(1).strip().rstrip('"\'')
        if val and len(val) > 2:
            names.add(val)
            names.add(val.lower())

    # Extract bullet-list items that look like task/project names
    # e.g., "- Project Alpha — in progress"
    for match in re.finditer(r"^[-*]\s+(.+?)(?:\s*[—–-]\s+|$)", context, re.MULTILINE):
        item = match.group(1).strip()
        if item and len(item) > 2:
            names.add(item)
            names.add(item.lower())

    # Extract words after "Project " or "Goal " prefix (common patterns)
    for match in re.finditer(r"\b(?:Project|Goal|Task)\s+([A-Z][a-zA-Z\s]+?)(?:\s*[—–\-:,\.]|\s*$)", context):
        name = match.group(0).strip().rstrip("—–-:,. ")
        names.add(name)
        names.add(name.lower())

    return names


# ── Identity-trusted names (never flagged as fabrication) ─────────

# Module-level cache for identity names loaded from USER.md / SOUL.md.
# Populated lazily on first call to check_fabrication() when a
# vault_reader is available. The user's own name, company, and
# location must NEVER be stripped from LLM output.
_identity_names: set[str] | None = None


def load_identity_names(vault_reader: object | None = None) -> set[str]:
    """Load always-trusted names from vault identity files.

    Reads USER.md and SOUL.md to extract the user's name, company,
    location, and any other identity fields. These names are NEVER
    flagged as fabrication — they are the user, not inventions.
    """
    global _identity_names
    if _identity_names is not None:
        return _identity_names

    names: set[str] = set()

    if not vault_reader:
        _identity_names = names
        return names

    # Read USER.md — the primary source of user identity
    try:
        doc = vault_reader.read("system/identity/USER.md")
        user_text = doc.content if hasattr(doc, "content") else str(doc)
        if user_text:
            # Extract **Key**: Value pairs from identity section
            for match in re.finditer(
                r"\*\*(?:Name|Role|Location|Companies?)\*\*:\s*(.+)",
                user_text,
            ):
                val = match.group(1).strip()
                if val and len(val) > 1 and not val.startswith("["):
                    names.add(val)
                    names.add(val.lower())
                    # Also add individual name parts (first name, last name)
                    for part in val.split():
                        if len(part) > 2:
                            names.add(part)
                            names.add(part.lower())
    except Exception:
        pass

    # Read SOUL.md — agent identity (name references)
    try:
        doc = vault_reader.read("system/identity/SOUL.md")
        soul_text = doc.content if hasattr(doc, "content") else str(doc)
        if soul_text:
            for match in re.finditer(
                r"\*\*(?:Name|Owner|Creator)\*\*:\s*(.+)",
                soul_text,
            ):
                val = match.group(1).strip()
                if val and len(val) > 1 and not val.startswith("["):
                    names.add(val)
                    names.add(val.lower())
    except Exception:
        pass

    _identity_names = names
    log.debug("Loaded %d identity-trusted names: %s", len(names), names)
    return names


def reset_identity_cache() -> None:
    """Clear the identity names cache (for testing or vault switch)."""
    global _identity_names
    _identity_names = None


# ── Core detection ────────────────────────────────────────────────

def check_fabrication(
    output: str,
    context: str,
    *,
    label: str = "",
    vault_reader: object | None = None,
) -> FabricationReport:
    """Check LLM output for fabricated proper nouns not in the context.

    Args:
        output: The LLM-generated text to validate.
        context: The input context that was given to the LLM.
        label: Optional label for logging (e.g., "morning-briefing").
        vault_reader: Optional VaultReader to load identity-trusted names
                      from USER.md. The user's own name is NEVER flagged.

    Returns:
        FabricationReport with fabricated names, flagged lines,
        and a cleaned version of the output with flagged lines removed.
    """
    report = FabricationReport()

    if not output or not context:
        report.clean_output = output or ""
        return report

    # Build the ground truth set from context
    context_names = extract_context_names(context)

    # Add identity-trusted names — the user's own name, company, etc.
    # These are NEVER flagged as fabrication regardless of whether
    # they appear in the current context string.
    identity = load_identity_names(vault_reader)
    context_names.update(identity)

    # Also add individual words from context names for partial matching
    context_words: set[str] = set()
    for name in context_names:
        for word in name.lower().split():
            if len(word) > 3:  # Skip short words
                context_words.add(word)

    # Extract proper nouns from LLM output
    output_nouns = extract_proper_nouns(output)

    # Check each output noun against context
    fabricated: set[str] = set()
    for noun in output_nouns:
        noun_lower = noun.lower()
        # Direct match in context names
        if noun in context_names or noun_lower in context_names:
            continue
        # Check if all significant words of the noun appear in context
        noun_words = [w for w in noun_lower.split() if len(w) > 3]
        if noun_words and all(w in context_words for w in noun_words):
            continue
        # Check substring match — the name might appear embedded in
        # a longer phrase in the context
        if noun_lower in context.lower():
            continue
        # This noun is fabricated
        fabricated.add(noun)

    if not fabricated:
        report.clean_output = output
        return report

    # Build cleaned output by removing lines containing fabricated names
    report.fabricated_names = sorted(fabricated)
    lines = output.split("\n")
    clean_lines: list[str] = []
    for line in lines:
        line_has_fabrication = False
        for name in fabricated:
            if name in line:
                line_has_fabrication = True
                report.flagged_lines.append(line.strip())
                break
        if not line_has_fabrication:
            clean_lines.append(line)

    report.clean_output = "\n".join(clean_lines)
    report.was_modified = True

    if label:
        log.warning(
            "[%s] Fabrication detected — %d invented names stripped: %s",
            label, len(fabricated), ", ".join(sorted(fabricated)),
        )

    return report


# ── Convenience for memory compaction ─────────────────────────────

def check_memory_fabrication(
    output: str,
    source_content: str,
    *,
    layer: str = "",
    vault_reader: object | None = None,
) -> FabricationReport:
    """Check memory compaction output for fabricated content.

    Memory compaction is especially vulnerable because each layer
    reads from the layer below — a fabrication at one level becomes
    "source data" for the next. This check is strict: every proper
    noun in the output MUST appear in the source material.
    """
    return check_fabrication(
        output, source_content,
        label=f"memory-{layer}" if layer else "memory",
        vault_reader=vault_reader,
    )
