"""Grounding check — post-synthesis 검증 (Phase 3.5a).

Strong/weak flag 분리:
- **Strong** (→ partial 강등 or refuse): fabricated_number, intent_misalignment(important)
- **Weak** (→ confidence lower only): uncited_claim, low_overlap, intent_misalignment(generic)

Re-gate 로직 (Phase 3.5a 9라운드 토론 결과):
- strong 1개 → partial 강등
- strong 2개 이상 → refuse
- weak → confidence "low" 만

Intent alignment (rule-based):
- query 의 핵심 명사가 answer 에 등장하는지 확인
- "처벌" 같은 중요 키워드 누락은 strong
- "주요", "관련" 같은 generic 은 무시
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import TYPE_CHECKING

from core.utils import setup_logger

if TYPE_CHECKING:
    from .evidence_service import EvidenceItem

logger = setup_logger("grounding")

# "주요", "관련" 등 intent alignment 에서 제외할 generic 단어
GENERIC_TERMS = frozenset({
    "주요", "관련", "내용", "정의", "기준", "방법", "설명", "개요",
    "대한", "위한", "대해", "무엇", "어떤", "어떻게", "있는",
    "하는", "되는", "이런", "그런", "이것", "그것",
})


@dataclass(slots=True)
class GroundingResult:
    strong_flags: list[str]
    weak_flags: list[str]


_UNIT_CHARS = r'명인개%년월일조항호세건원회'

# "이상/이하/초과/미만" — threshold 표현 (numeric conflict 에서 skip 대상)
_THRESHOLD_SUFFIXES = re.compile(r'이상|이하|초과|미만')

# 약칭/근사치 prefix — 매칭 전 제거 (Phase 3.5 B1).
# ⚠ 최대/최소 는 의도적으로 제외 — 이들은 bound operator 라 의미가 다름 (Phase 3.5 B1 fix3).
# 약/대략/거의/얼추 만 노이즈 prefix 로 strip.
_APPROX_PREFIX_RE = re.compile(r'(약|대략|거의|얼추)\s*')

# 단위 동의어 dict — 추출 직후 정규화 (Phase 3.5 B1)
# 의미가 동일한 단위는 같은 표기로 통일해서 set 비교/range overlap 안정화.
_UNIT_SYNONYMS: dict[str, str] = {
    "인": "명",
    "사람": "명",
    "퍼센트": "%",
    "프로": "%",
    "KRW": "원",
    "krw": "원",
}

# tolerance(±1%) 허용 단위 — 양적 측정값 (Phase 3.5 B1)
_TOLERANCE_UNITS: frozenset[str] = frozenset({"명", "원", "%", "건", "개"})

# tolerance 미적용 단위 — 식별자성 숫자 (연도/조문/횟수)
_EXACT_ONLY_UNITS: frozenset[str] = frozenset({"년", "월", "일", "조", "항", "호", "회"})

# 최대/최소 prefix 패턴 — bound operator (Phase 3.5 B1 fix3).
# 매칭된 숫자는 exact pool 에서 제외하고 one-sided range 로 변환.
# 경계값 자체는 clear 대상 아님 (Codex 권장: "최대 100명" + answer "100명" → flag 유지).
_BOUND_PATTERN_RE = re.compile(
    rf'(최대|최소)\s*(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)'
)
_RANGE_INF = 10**18  # one-sided range 상한 sentinel


def _normalize_unit(unit: str) -> str:
    """단위 동의어 → 대표 표기."""
    return _UNIT_SYNONYMS.get(unit, unit)


def _extract_unit(literal: str) -> str | None:
    """리터럴에서 숫자 뒤 단위(한 글자 또는 동의어) 추출 + 정규화."""
    # 천단위 콤마 + 옵션 소수 + 한글 단위 한 글자 또는 동의어
    m = re.match(rf'[\d,.]+\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)', literal)
    if not m:
        return None
    return _normalize_unit(m.group(1))


def _extract_numeric_corpus(text: str) -> dict:
    """단위별 숫자 + 범위 + bound 통합 추출 (Phase 3.5 B1 fix1+fix3).

    Returns:
      {
        "exact_by_unit": {unit_or_None: set(digits)},   # 평범한 숫자 (bound 제외)
        "ranges_by_unit": {unit: [(lo, hi), ...]},       # 양방향(A~B) + 단방향(최대/최소)
      }

    None 키는 단위 없는 bare 숫자.
    `최대 N <unit>` → ranges[(0, N-1)] (경계값 자체는 cleared 대상 아님)
    `최소 N <unit>` → ranges[(N+1, INF)]
    """
    cleaned = _APPROX_PREFIX_RE.sub('', text)

    exact_by_unit: dict[str | None, set[str]] = {None: set()}
    ranges_by_unit: dict[str, list[tuple[int, int]]] = {}

    # 1) 최대/최소 — bound. exact pool 에서 제외, one-sided range 로 변환.
    bound_spans: list[tuple[int, int]] = []  # 매칭 substring 위치 — 이후 단계에서 skip
    for m in _BOUND_PATTERN_RE.finditer(cleaned):
        bound_kind = m.group(1)
        try:
            n = int(m.group(2).replace(',', '').split('.')[0])
        except ValueError:
            continue
        unit = _normalize_unit(m.group(3))
        if bound_kind == "최대":
            ranges_by_unit.setdefault(unit, []).append((0, max(0, n - 1)))
        else:  # 최소
            ranges_by_unit.setdefault(unit, []).append((n + 1, _RANGE_INF))
        bound_spans.append((m.start(), m.end()))

    def _in_bound_span(pos: int) -> bool:
        return any(s <= pos < e for s, e in bound_spans)

    # 2) 천단위 콤마 bare number
    for m in re.finditer(r'\d{1,3}(?:,\d{3})+(?:\.\d+)?', cleaned):
        if _in_bound_span(m.start()):
            continue
        exact_by_unit[None].add(m.group().replace(',', ''))

    # 3) 단위 있는 숫자 (단위 동의어 포함)
    for m in re.finditer(
        rf'(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)',
        cleaned,
    ):
        if _in_bound_span(m.start()):
            continue
        digits = m.group(1).replace(',', '').split('.')[0]
        if not digits:
            continue
        unit = _normalize_unit(m.group(2))
        exact_by_unit.setdefault(unit, set()).add(digits)

    # 4) 양방향 범위 표현 (A~B / A 부터 B)
    for m in re.finditer(
        rf'(\d[\d,.]*)\s*(?:[~\-–]|부터)\s*(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로)',
        cleaned,
    ):
        if _in_bound_span(m.start()):
            continue
        try:
            lo = int(m.group(1).replace(',', '').split('.')[0])
            hi = int(m.group(2).replace(',', '').split('.')[0])
        except ValueError:
            continue
        unit = _normalize_unit(m.group(3))
        ranges_by_unit.setdefault(unit, []).append((min(lo, hi), max(lo, hi)))

    # 5) bare 2자리+ 단독 숫자
    for m in re.finditer(r'\b(\d{2,})\b', cleaned):
        if _in_bound_span(m.start()):
            continue
        exact_by_unit[None].add(m.group())

    return {
        "exact_by_unit": exact_by_unit,
        "ranges_by_unit": ranges_by_unit,
    }


def _within_unit_range(
    n: int, unit: str | None, ranges_by_unit: dict[str, list[tuple[int, int]]]
) -> bool:
    """unit-matching range 검증.

    answer unit 이 None (bare 숫자) 면 보수적으로 False — bare 답변은 range clear 대상 아님.
    """
    if unit is None:
        return False
    return any(lo <= n <= hi for lo, hi in ranges_by_unit.get(unit, []))


def _close_to_unit_pool(
    n: int, unit: str | None, exact_by_unit: dict[str | None, set[str]], tol: float
) -> bool:
    """unit-matching tolerance 검증.

    answer unit 이 None 이면 False — bare 답변은 tolerance 대상 아님.
    같은 unit bucket 안의 후보만 비교.
    """
    if unit is None:
        return False
    candidates = exact_by_unit.get(unit, set())
    for c in candidates:
        try:
            cn = int(c)
        except ValueError:
            continue
        if cn == 0:
            continue
        if abs(n - cn) / cn <= tol:
            return True
    return False


def _extract_number_literals(text: str) -> set[str]:
    """숫자 + 단위 추출 + normalize (Phase 3.5 B1: 6단계 확장).

    1) 약칭 prefix 제거 ("약 100명" → "100명")
    2) 천단위 콤마 bare number 우선 ("1,000" → "1000" set 등록)
    3) 한국어 단위 접미사 매칭 (기존)
    4) 범위 표현 양쪽 숫자 추출 (separator: ~, -, –, 부터)
    5) 단위 동의어 정규화 (인→명, 퍼센트→%, KRW→원)
    6) bare 2자리+ 추출 (기존)
    """
    # 1. 약칭 prefix 제거 (전체 텍스트에서)
    cleaned = _APPROX_PREFIX_RE.sub('', text)

    # 2. 천단위 콤마 bare number — normalize 된 값을 set 에 선등록
    normalized: set[str] = set()
    for m in re.finditer(r'\d{1,3}(?:,\d{3})+(?:\.\d+)?', cleaned):
        normalized.add(m.group().replace(',', ''))

    # 3. 숫자 + 한국어 단위 접미사 (동의어 포함)
    raw: set[str] = set(re.findall(
        rf'\d[\d,.]*\s*(?:[{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)\w{{0,2}}',
        cleaned,
    ))

    # 4. 범위 표현 — separator 에 "부터" 추가
    for m in re.finditer(
        rf'(\d[\d,.]*)\s*(?:[~\-–]|부터)\s*(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로)',
        cleaned,
    ):
        unit_norm = _normalize_unit(m.group(3))
        raw.add(m.group(1) + unit_norm)
        raw.add(m.group(2) + unit_norm)

    # 5. normalize: 단위 동의어 통일 + 콤마 제거
    for r in raw:
        # 단위 부분 정규화
        m = re.match(r'([\d,.]+)\s*([^\d\s]+)', r)
        if m:
            digits_part = m.group(1)
            unit_part = _normalize_unit(m.group(2))
            normalized.add(digits_part + unit_part)
            normalized.add(digits_part.replace(',', '') + unit_part)
        normalized.add(r.strip())
        num_only = re.match(r'[\d,.]+', r)
        if num_only:
            normalized.add(num_only.group().replace(',', ''))

    # 6. 단독 숫자 (2자리+ 만)
    for d in re.findall(r'\b(\d{2,})\b', cleaned):
        normalized.add(d)
    return normalized


def _within_evidence_range(digits: str, raw: str, evidence_text: str) -> bool:
    """evidence 에 'A~B 단위' 가 있고 answer 의 숫자가 그 범위 안이면 True.

    범위 단위는 무시 (단위 비교는 호출 전 단계). digits = 정수 문자열.
    """
    try:
        n = int(digits)
    except ValueError:
        return False
    cleaned_ev = _APPROX_PREFIX_RE.sub('', evidence_text)
    for m in re.finditer(
        rf'(\d[\d,.]*)\s*(?:[~\-–]|부터)\s*(\d[\d,.]*)\s*[{_UNIT_CHARS}]',
        cleaned_ev,
    ):
        try:
            lo = int(m.group(1).replace(',', '').split('.')[0])
            hi = int(m.group(2).replace(',', '').split('.')[0])
            if min(lo, hi) <= n <= max(lo, hi):
                return True
        except ValueError:
            continue
    return False


def _close_to_any(n: int, candidates: set[str], tol: float) -> bool:
    """candidates 중 하나라도 (1±tol) 배율 안에 들어오면 True.

    n 은 정수, candidates 는 digits-only 문자열 집합.
    """
    for c in candidates:
        try:
            cn = int(c)
        except ValueError:
            continue
        if cn == 0:
            continue
        if abs(n - cn) / cn <= tol:
            return True
    return False


def _extract_content_tokens(text: str) -> set[str]:
    """한국어 2자 이상 명사 + 영어 3자 이상 단어."""
    return set(re.findall(r'[가-힣]{2,}|[a-zA-Z]{3,}', text))


def _parse_number_with_unit(literal: str) -> tuple[str, str] | None:
    """숫자 리터럴에서 (digits_only, unit) 분리. 단위 없으면 None."""
    m = re.match(rf'([\d,.]+)\s*([{_UNIT_CHARS}])', literal)
    if not m:
        return None
    digits = m.group(1).replace(',', '')
    unit = m.group(2)
    return (digits, unit)


def _check_evidence_numeric_conflicts(evidence: list["EvidenceItem"]) -> list[str]:
    """evidence 간 숫자 충돌 감지 (Phase 3.5b). evidence >= 2 일 때만 활성.

    동일 단위, 다른 숫자 → weak flag. "이상/이하/초과/미만" 포함 시 skip.
    bare number 는 비교 안 함 (조항 번호 등 false positive 방지).
    """
    if len(evidence) < 2:
        return []

    # 각 evidence 에서 단위 있는 숫자 + threshold 여부 추출
    # {evidence_idx: [(digits, unit, has_threshold), ...]}
    per_evidence: dict[int, list[tuple[str, str, bool]]] = {}
    for idx, ev in enumerate(evidence):
        nums = re.findall(
            rf'\d[\d,.]*\s*[{_UNIT_CHARS}]\w{{0,4}}',
            ev.span_text,
        )
        entries = []
        for raw in nums:
            parsed = _parse_number_with_unit(raw)
            if not parsed:
                continue
            has_thr = bool(_THRESHOLD_SUFFIXES.search(raw))
            entries.append((parsed[0], parsed[1], has_thr))
        if entries:
            per_evidence[idx] = entries

    if len(per_evidence) < 2:
        return []

    # 단위별로 evidence 간 숫자 비교
    # {unit: {digits: [evidence_idx, ...]}}
    unit_map: dict[str, dict[str, list[int]]] = {}
    for idx, entries in per_evidence.items():
        for digits, unit, has_thr in entries:
            if has_thr:
                continue  # threshold 표현은 skip
            if unit not in unit_map:
                unit_map[unit] = {}
            if digits not in unit_map[unit]:
                unit_map[unit][digits] = []
            if idx not in unit_map[unit][digits]:
                unit_map[unit][digits].append(idx)

    flags: list[str] = []
    for unit, digits_map in unit_map.items():
        distinct_values = list(digits_map.keys())
        if len(distinct_values) >= 2:
            # 가장 많이 등장하는 2개 비교
            top2 = sorted(distinct_values, key=lambda d: len(digits_map[d]), reverse=True)[:2]
            flags.append(
                f"evidence_numeric_conflict:{top2[0]}{unit}_vs_{top2[1]}{unit}"
            )

    return flags


def check(
    query: str,
    answer: str,
    evidence: list[EvidenceItem],
) -> GroundingResult:
    """답변 vs evidence grounding 검증 + query intent alignment."""
    strong: list[str] = []
    weak: list[str] = []

    if not answer or not evidence:
        return GroundingResult([], [])

    # ⚠ citation marker [n] 양측 제거 (대칭성 — Phase 3.5 B1)
    evidence_text = re.sub(r'\[\d+\]', '', " ".join(e.span_text for e in evidence))

    # ── Strong 1: fabricated number (unit-aware 3단계 — Phase 3.5 B1 fix1+fix3) ──
    # Codex 지적 반영:
    #   - fix1: range/tolerance/exact 모두 단위 일치 시에만 clear
    #     (예: "150원" vs "100~200명" → flag 유지)
    #   - fix3: 최대/최소 prefix 는 bound 의미 보존
    #     (예: "최대 100명" + answer "100명" → flag 유지, "최대 100명" + answer "50명" → cleared)
    answer_clean = re.sub(r'\[\d+\]', '', answer)
    answer_corpus = _extract_numeric_corpus(answer_clean)
    evidence_corpus = _extract_numeric_corpus(evidence_text)
    ev_exact_by_unit = evidence_corpus["exact_by_unit"]
    ev_ranges_by_unit = evidence_corpus["ranges_by_unit"]

    # cleared 는 (unit, digits) 쌍 단위로 추적 — 단위 충돌 케이스 방어
    cleared_pairs: set[tuple[str | None, str]] = set()

    # Pass 1: 각 (unit, digits) 가 evidence 에서 정당화되는지 판정
    for unit, digits_set in answer_corpus["exact_by_unit"].items():
        for d in digits_set:
            # 1) exact match — 같은 unit bucket 내에서만
            if d in ev_exact_by_unit.get(unit, set()):
                cleared_pairs.add((unit, d))
                continue
            # bare answer (unit=None) 는 evidence bare bucket 도 보조 매칭
            if unit is None and d in ev_exact_by_unit.get(None, set()):
                cleared_pairs.add((unit, d))
                continue
            try:
                n = int(d)
            except ValueError:
                continue
            # 2) range — same-unit 만 (bare answer 는 range clear 대상 아님)
            if _within_unit_range(n, unit, ev_ranges_by_unit):
                cleared_pairs.add((unit, d))
                continue
            # 3) ±1% tolerance — 단위가 양적(_TOLERANCE_UNITS) + 4자리+ + same-unit
            if (
                unit in _TOLERANCE_UNITS
                and len(d) >= 4
                and _close_to_unit_pool(n, unit, ev_exact_by_unit, tol=0.01)
            ):
                cleared_pairs.add((unit, d))
                continue
            # 식별자성 단위(_EXACT_ONLY_UNITS) 는 tolerance 패스 X.

    # Pass 2: cleared 되지 않은 (unit, digits) 를 strong flag.
    # 1자리 무시는 unit 이 식별자성(_EXACT_ONLY_UNITS: 년/월/일/조/항/호/회) 이 아닐 때만 적용.
    # bare(None) 답변 숫자는 같은 digit 이 다른 unit 에서 cleared 됐으면 skip — 추출 부산물 방어.
    # ⚠ 단위 cross-clear (예: "원" cleared → "명" 도 skip) 은 금지: Codex unit-mismatch 케이스가 깨짐.
    unit_anchored_cleared: set[str] = {d for (u, d) in cleared_pairs if u is not None}
    flagged_keys: set[tuple[str | None, str]] = set()
    for unit, digits_set in answer_corpus["exact_by_unit"].items():
        for d in digits_set:
            if (unit, d) in cleared_pairs or (unit, d) in flagged_keys:
                continue
            # bare(None) 답변 숫자가 임의의 단위 bucket 에서 cleared 됐으면 duplicate 로 처리.
            # 사례: "1,000명" → unit bucket "명" 에 1000 + bare bucket None 에 1000 (comma normalize 부산물).
            # 이미 ("명", "1000") 가 cleared 라면 (None, "1000") 도 같은 사실을 가리키므로 skip.
            if unit is None and d in unit_anchored_cleared:
                continue
            if len(d) < 2 and unit not in _EXACT_ONLY_UNITS:
                continue
            flagged_keys.add((unit, d))
            # 사람이 읽기 좋게 "{digits}{unit}" 또는 bare 형태로 표기
            label = f"{d}{unit}" if unit else d
            strong.append(f"fabricated_number:{label}")

    # ── Strong/Weak 2: query-answer intent alignment ──
    query_content = _extract_content_tokens(query)
    answer_content = _extract_content_tokens(answer)
    if query_content:
        missing_terms = query_content - answer_content
        important_missing = [
            t for t in missing_terms
            if t not in GENERIC_TERMS and len(t) >= 2
        ]
        if important_missing:
            strong.append(
                f"intent_misalignment:{','.join(important_missing[:3])}"
            )
        elif len(missing_terms) > len(query_content) * 0.5:
            weak.append(
                f"intent_misalignment_generic:"
                f"missing({','.join(list(missing_terms)[:5])})"
            )

    # ── Weak 1: uncited claim ──
    sentences = re.split(r'(?<=[.!?。])\s+', answer)
    for s in sentences:
        if len(s.strip()) > 20 and not re.search(r'\[\d+\]', s):
            weak.append(f"uncited_claim:{s[:40]}")

    # ── Weak: evidence 간 숫자 충돌 (Phase 3.5b) ──
    conflicts = _check_evidence_numeric_conflicts(evidence)
    weak.extend(conflicts)

    # ── Weak 2: token overlap ──
    answer_tokens = _extract_content_tokens(answer)
    evidence_tokens = _extract_content_tokens(evidence_text)
    if answer_tokens:
        overlap = len(answer_tokens & evidence_tokens) / len(answer_tokens)
        if overlap < 0.4:
            weak.append(f"low_overlap:{overlap:.2f}")

    if strong or weak:
        logger.info(
            "grounding query=%r strong=%d weak=%d flags=%s",
            query[:60],
            len(strong),
            len(weak),
            ",".join(strong[:3] + weak[:3]),
        )

    return GroundingResult(strong, weak)
