# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
# See LICENSE file for details.
"""
Brain Analysis: Text similarity, counting, and neuroscience helpers.

Split from brain_utils.py for Law 7 compliance.
"""

from pathlib import Path


# === TEXT UTILITIES ===

_STOP_WORDS = frozenset({
    "the", "a", "an", "is", "in", "to", "of", "and", "for", "on", "it",
    "this", "that", "with", "from", "be", "was", "were", "are", "been",
    "has", "have", "had", "do", "did", "does", "will", "would", "could",
    "should", "can", "may", "we", "you", "i", "my", "our", "your", "me",
    "what", "how", "why", "when", "where", "which", "who", "not", "no",
    "but", "or", "if", "so", "at", "by", "up", "out", "about", "into",
    "just", "also", "than", "then", "its", "let", "lets",
})


import re

_SENTENCE_SPLIT = re.compile(r'(?<=[.!?])\s+')


def truncate_at_sentence(text: str, max_chars: int = 500) -> str:
    """Truncate text at the last sentence boundary before max_chars.

    If no sentence boundary found, falls back to word boundary.
    Never returns less than 40 chars (avoids over-truncation).
    """
    if not text or len(text) <= max_chars:
        return text.strip()

    candidate = text[:max_chars]
    sentences = _SENTENCE_SPLIT.split(candidate)
    if len(sentences) > 1:
        result = ""
        for s in sentences:
            s_stripped = s.strip()
            # Only include complete sentences (end with terminal punctuation)
            if not s_stripped or not s_stripped[-1] in '.!?)':
                break
            if len(result) + len(s_stripped) + 1 <= max_chars:
                result = (result + " " + s_stripped).strip() if result else s_stripped
            else:
                break
        if len(result) >= 40:
            return result.strip()

    for i in range(min(len(text), max_chars) - 1, max(39, max_chars // 2), -1):
        if text[i] in ".!?)":
            return text[:i + 1].strip()

    space_idx = text.rfind(" ", 40, max_chars)
    if space_idx > 0:
        return text[:space_idx].strip()

    return text[:max_chars].strip()


def text_similarity(a: str, b: str) -> float:
    """Simple word-overlap Jaccard similarity (0.0-1.0).

    Stop words are filtered from both inputs before computing overlap.
    Falls back to unfiltered if filtering removes all words from either side.
    """
    if not a or not b:
        return 0.0
    wa_raw = set(a.lower().split())
    wb_raw = set(b.lower().split())
    wa = wa_raw - _STOP_WORDS
    wb = wb_raw - _STOP_WORDS
    # Safety fallback: if filtering wiped either side, use unfiltered sets
    if not wa:
        wa = wa_raw
    if not wb:
        wb = wb_raw
    if not wa or not wb:
        return 0.0
    return len(wa & wb) / len(wa | wb)


def search_relevance(query: str, document: str) -> float:
    """Query-coverage similarity for search (0.0-1.0).

    Combines unigram overlap with bigram overlap for better semantic matching.
    "What have we been working on" now matches "P48 modularization working"
    via shared bigrams and partial word coverage.
    """
    if not query or not document:
        return 0.0
    ql = query.lower()
    dl = document.lower()
    qw_raw = ql.split()
    dw_raw = dl.split()
    qw = set(qw_raw) - _STOP_WORDS
    dw = set(dw_raw) - _STOP_WORDS
    # Safety fallback: if filtering wiped the query, use unfiltered sets
    if not qw:
        qw = set(qw_raw)
        dw = set(dw_raw)
    if not qw:
        return 0.0

    # Unigram overlap (original method)
    unigram_score = len(qw & dw) / len(qw)

    # Bigram overlap — catches phrase-level matches
    def _bigrams(words):
        return set(zip(words, words[1:])) if len(words) >= 2 else set()

    q_bi = _bigrams([w for w in qw_raw if w not in _STOP_WORDS])
    d_bi = _bigrams([w for w in dw_raw if w not in _STOP_WORDS])
    bigram_score = len(q_bi & d_bi) / len(q_bi) if q_bi else 0.0

    # Substring match — "modular" in query matches "modularization" in doc
    substring_hits = 0
    for qword in qw:
        if len(qword) >= 4 and any(qword in dword or dword in qword for dword in dw):
            substring_hits += 1
    substring_score = substring_hits / len(qw) if qw else 0.0

    # Weighted combination: unigram 50%, bigram 25%, substring 25%
    return min(1.0, unigram_score * 0.5 + bigram_score * 0.25 + substring_score * 0.25)


def extract_domain(filename: str) -> str:
    """Extract domain name from evidence filename."""
    name = filename.replace(".json", "")
    parts = name.split("_")
    return parts[0] if parts else "unknown"



# === CONTENT-AWARE SALIENCE & CONFIDENCE (Phase 0 — Salience Calibration) ===






# ---------------------------------------------------------------------------
# Re-exports from split modules (preserves public API)
# ---------------------------------------------------------------------------
from .brain_analysis_counting import (  # noqa: F401
    count_lines, count_json_items, count_md_sections,
)
from .brain_analysis_neuro import (  # noqa: F401
    compute_weighted_count, compute_confidence_decay,
    detect_cross_domain_patterns, get_brain_density,
)
from .brain_analysis_scoring import (  # noqa: F401
    compute_item_salience, compute_item_confidence,
)
