"""BLCE Report-to-Model Mapper — P1.1.

Scores extracted KPIs/dimensions from reports against proposed model
objects (facts/dimensions) and produces a MappingReport showing
which extracted items map to which model objects.
"""
from __future__ import annotations

import re
from typing import List, Optional, Set

from .contracts import (
    DimDimMapping,
    ExtractedDimension,
    ExtractedKPI,
    KPIFactMapping,
    MappingReport,
    ProposedDimension,
    ProposedFact,
    ProposedModel,
    ReportAnalysis,
)
from .report_patterns import DIMENSION_KEYWORD_MAP, MEASURE_KEYWORD_MAP


def _tokenize(text: str) -> Set[str]:
    """Split text into lowercase word tokens."""
    return set(re.findall(r"[a-z0-9]+", text.lower()))


def _jaccard(a: Set[str], b: Set[str]) -> float:
    """Jaccard similarity between two token sets."""
    if not a or not b:
        return 0.0
    return len(a & b) / len(a | b)


# ---------------------------------------------------------------------------
# KPI → Fact scoring
# ---------------------------------------------------------------------------

def _score_kpi_to_fact(kpi: ExtractedKPI, fact: ProposedFact) -> float:
    """Score how well a KPI maps to a proposed fact (0.0–1.0).

    Weights:
        +0.4  name word overlap (Jaccard on tokens)
        +0.3  grain match (shared dimension FK names)
        +0.2  source table overlap
        +0.1  measure keyword match
    """
    score = 0.0

    # Name overlap
    kpi_tokens = _tokenize(kpi.name) | _tokenize(kpi.business_name)
    fact_tokens = _tokenize(fact.name) | _tokenize(fact.business_name)
    score += 0.4 * _jaccard(kpi_tokens, fact_tokens)

    # Grain match — KPI departments/granularity vs fact grain columns
    kpi_grain_tokens = _tokenize(kpi.granularity)
    for dept in kpi.departments:
        kpi_grain_tokens |= _tokenize(dept)
    fact_grain_tokens: Set[str] = set()
    for gc in fact.grain_columns:
        fact_grain_tokens |= _tokenize(gc)
    for fk in fact.dimension_fks:
        fact_grain_tokens |= _tokenize(fk)
    score += 0.3 * _jaccard(kpi_grain_tokens, fact_grain_tokens)

    # Source table overlap
    kpi_src = _tokenize(kpi.source)
    fact_src: Set[str] = set()
    for st in fact.source_tables:
        fact_src |= _tokenize(st)
    score += 0.2 * _jaccard(kpi_src, fact_src)

    # Measure keyword match
    canonical = MEASURE_KEYWORD_MAP.get(kpi.name.lower(), "")
    if not canonical:
        for kw, canon in MEASURE_KEYWORD_MAP.items():
            if kw in kpi.name.lower() or kw in kpi.business_name.lower():
                canonical = canon
                break
    if canonical:
        canonical_tokens = _tokenize(canonical)
        measure_tokens: Set[str] = set()
        for m in fact.measures:
            measure_tokens |= _tokenize(m)
        score += 0.1 * _jaccard(canonical_tokens, measure_tokens)

    return min(score, 1.0)


# ---------------------------------------------------------------------------
# Extracted Dimension → Proposed Dimension scoring
# ---------------------------------------------------------------------------

def _score_dim_to_dim(
    extracted: ExtractedDimension,
    proposed: ProposedDimension,
) -> float:
    """Score how well an extracted dimension maps to a proposed one (0.0–1.0).

    Weights:
        +0.4  name word overlap
        +0.3  source table overlap
        +0.2  key column match
        +0.1  attribute keyword overlap
    """
    score = 0.0

    # Name overlap
    ext_tokens = _tokenize(extracted.name) | _tokenize(extracted.business_name)
    prop_tokens = _tokenize(proposed.name) | _tokenize(proposed.business_name)
    score += 0.4 * _jaccard(ext_tokens, prop_tokens)

    # Source table overlap
    ext_src: Set[str] = set()
    for st in extracted.source_tables:
        ext_src |= _tokenize(st)
    prop_src: Set[str] = set()
    for st in proposed.source_tables:
        prop_src |= _tokenize(st)
    score += 0.3 * _jaccard(ext_src, prop_src)

    # Key column match
    ext_keys = set(k.lower() for k in extracted.key_columns)
    prop_key = proposed.key_column.lower() if proposed.key_column else ""
    prop_nk = proposed.natural_key.lower() if proposed.natural_key else ""
    prop_keys = {prop_key, prop_nk} - {""}
    if ext_keys and prop_keys and ext_keys & prop_keys:
        score += 0.2
    elif ext_keys and prop_keys:
        # Partial: tokenized overlap
        ek_tok: Set[str] = set()
        for k in ext_keys:
            ek_tok |= _tokenize(k)
        pk_tok: Set[str] = set()
        for k in prop_keys:
            pk_tok |= _tokenize(k)
        score += 0.2 * _jaccard(ek_tok, pk_tok)

    # Attribute keyword overlap
    ext_attrs: Set[str] = set()
    for a in extracted.attributes:
        ext_attrs |= _tokenize(a)
    prop_attrs: Set[str] = set()
    for a in proposed.attributes:
        prop_attrs |= _tokenize(a)
    score += 0.1 * _jaccard(ext_attrs, prop_attrs)

    return min(score, 1.0)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def map_report_to_model(
    report_analyses: List[ReportAnalysis],
    model: ProposedModel,
    threshold: float = 0.15,
) -> MappingReport:
    """Map extracted KPIs/dims from reports to proposed model objects.

    Args:
        report_analyses: List of report analysis results.
        model: The proposed Kimball model.
        threshold: Minimum confidence to count as a mapping.

    Returns:
        MappingReport with per-item mappings and coverage.
    """
    kpi_mappings: List[KPIFactMapping] = []
    dim_mappings: List[DimDimMapping] = []
    unmapped_kpis: List[str] = []
    unmapped_dims: List[str] = []

    # Collect all extracted KPIs and dims across reports
    all_kpis: List[ExtractedKPI] = []
    all_dims: List[ExtractedDimension] = []
    for ra in report_analyses:
        all_kpis.extend(ra.kpis)
        all_dims.extend(ra.dimensions)

    # Map KPIs → facts
    for kpi in all_kpis:
        best_fact: Optional[str] = None
        best_score = 0.0
        best_rationale = ""

        for fact in model.facts:
            s = _score_kpi_to_fact(kpi, fact)
            if s > best_score:
                best_score = s
                best_fact = fact.name
                parts = []
                kpi_tokens = _tokenize(kpi.name) | _tokenize(kpi.business_name)
                fact_tokens = _tokenize(fact.name) | _tokenize(fact.business_name)
                overlap = kpi_tokens & fact_tokens
                if overlap:
                    parts.append(f"name overlap: {', '.join(sorted(overlap))}")
                if set(kpi.source.upper().split()) & set(
                    st.upper() for st in fact.source_tables
                ):
                    parts.append("source table match")
                best_rationale = "; ".join(parts) if parts else "token similarity"

        if best_score >= threshold and best_fact:
            kpi_mappings.append(KPIFactMapping(
                kpi_name=kpi.name,
                kpi_business_name=kpi.business_name,
                proposed_fact=best_fact,
                confidence=round(best_score, 3),
                rationale=best_rationale,
            ))
        else:
            unmapped_kpis.append(kpi.business_name or kpi.name)

    # Map extracted dims → proposed dims
    for ed in all_dims:
        best_dim: Optional[str] = None
        best_score = 0.0
        best_rationale = ""

        for pd in model.dimensions:
            s = _score_dim_to_dim(ed, pd)
            if s > best_score:
                best_score = s
                best_dim = pd.name
                ext_tok = _tokenize(ed.name) | _tokenize(ed.business_name)
                prop_tok = _tokenize(pd.name) | _tokenize(pd.business_name)
                overlap = ext_tok & prop_tok
                best_rationale = (
                    f"name overlap: {', '.join(sorted(overlap))}"
                    if overlap
                    else "token similarity"
                )

        if best_score >= threshold and best_dim:
            dim_mappings.append(DimDimMapping(
                extracted_dim=ed.name,
                extracted_business_name=ed.business_name,
                proposed_dim=best_dim,
                confidence=round(best_score, 3),
                rationale=best_rationale,
            ))
        else:
            unmapped_dims.append(ed.business_name or ed.name)

    total_items = len(all_kpis) + len(all_dims)
    mapped_items = len(kpi_mappings) + len(dim_mappings)
    coverage = mapped_items / total_items if total_items > 0 else 0.0

    return MappingReport(
        kpi_mappings=kpi_mappings,
        dimension_mappings=dim_mappings,
        unmapped_kpis=unmapped_kpis,
        unmapped_dims=unmapped_dims,
        coverage_score=round(coverage, 3),
    )
