"""
clusterer.py - Stage 2: deterministic co-location clustering (no LLM).

Collapses the N-by-N sheet-comparison explosion into a handful of clusters
where assertions from different disciplines (or a schedule vs a plan within one
discipline) refer to the SAME place or element. Only clusters worth reasoning
about reach Stage 3, which keeps each reasoning call small and cheap.

A cluster is emitted when, for a shared normalized location key, either:
  - assertions come from >= 2 distinct disciplines (cross-discipline), or
  - one discipline mixes a schedule/count fact with a plan/location/dimension
    fact for the same element (schedule-vs-plan).

Limitation (v1): a location that appears in only ONE discipline never forms a
cluster, so pure "missing element" gaps are not caught deterministically.
Stage 3 still catches missing counterparts whenever the location is co-located.
"""

import re
from typing import List, Dict, Tuple, Optional

# Caps to keep Stage 3 token usage bounded.
MAX_CLUSTERS = 120
MAX_ASSERTIONS_PER_CLUSTER = 24

_SCHEDULE_HINTS = ("count", "schedule", "_entry")
_PLAN_HINTS = ("location", "dim", "elev")


# ---------------------------------------------------------------------------
# Location-key normalization
# ---------------------------------------------------------------------------

def _norm_grid(v: str) -> Optional[str]:
    """'A/3', '3-A', 'A.3' -> canonical sorted 'token/token'."""
    toks = [t for t in re.split(r"[^A-Za-z0-9]+", v.upper()) if t]
    if not toks:
        return None
    return "/".join(sorted(toks))


def _norm_room(v: str) -> Optional[str]:
    v = re.sub(r"(?i)\b(room|rm|space)\b", "", v).strip()
    v = re.sub(r"\s+", " ", v).upper()
    return v or None


def _norm_tag(v: str) -> Optional[str]:
    toks = [t for t in re.split(r"[^A-Za-z0-9]+", v.upper()) if t]
    if not toks:
        return None
    return "-".join(toks)


def cluster_keys_for(assertion: Dict) -> List[Tuple[str, str]]:
    """All (key_type, normalized_value) keys this assertion participates in."""
    lk = assertion.get("location_key") or {}
    keys: List[Tuple[str, str]] = []
    if lk.get("grid"):
        g = _norm_grid(str(lk["grid"]))
        if g:
            keys.append(("grid", g))
    if lk.get("room"):
        r = _norm_room(str(lk["room"]))
        if r:
            keys.append(("room", r))
    if lk.get("tag"):
        t = _norm_tag(str(lk["tag"]))
        if t:
            keys.append(("tag", t))
    return keys


# ---------------------------------------------------------------------------
# Clustering
# ---------------------------------------------------------------------------

def _is_schedule(attr: str) -> bool:
    a = (attr or "").lower()
    return any(h in a for h in _SCHEDULE_HINTS)


def _is_plan(attr: str) -> bool:
    a = (attr or "").lower()
    return any(h in a for h in _PLAN_HINTS)


def _flatten(sheets: List[Dict]) -> List[Dict]:
    """Attach sheet-level context onto each assertion for clustering/evidence."""
    flat: List[Dict] = []
    for sheet in sheets:
        for a in sheet.get("assertions", []):
            flat.append({
                **a,
                "discipline": sheet.get("discipline", "Unknown"),
                "sheet_number": sheet.get("sheet_number"),
                "page_number": sheet.get("page_number"),
            })
    return flat


def _human_location(key_type: str, value: str, members: List[Dict]) -> str:
    label = {"room": "Room", "grid": "Grid", "tag": "Tag"}.get(key_type, key_type)
    levels = {m["location_key"].get("level") for m in members if m.get("location_key")}
    levels.discard(None)
    lvl = f" / {sorted(levels)[0]}" if len(levels) == 1 else ""
    return f"{label} {value}{lvl}"


def cluster_by_location(sheets: List[Dict]) -> List[Dict]:
    """
    Build co-location clusters from per-sheet extractions.

    Returns a list of clusters:
      {
        "key": "room:204",
        "location": "Room 204 / Level 2",
        "disciplines": ["Architectural", "Mechanical"],
        "page_numbers": [3, 14],
        "sheets": ["A2.1", "M2.1"],
        "assertions": [ {discipline, sheet_number, attribute, value, source_text, ...}, ... ]
      }
    """
    flat = _flatten(sheets)

    buckets: Dict[Tuple[str, str], List[Dict]] = {}
    for a in flat:
        for key in cluster_keys_for(a):
            buckets.setdefault(key, []).append(a)

    clusters: List[Dict] = []
    for (key_type, value), members in buckets.items():
        disciplines = {m["discipline"] for m in members}
        cross_discipline = len(disciplines) >= 2
        schedule_vs_plan = (
            len(disciplines) == 1
            and any(_is_schedule(m["attribute"]) for m in members)
            and any(_is_plan(m["attribute"]) for m in members)
        )
        if not (cross_discipline or schedule_vs_plan):
            continue

        # Rank members so the cap keeps the most informative ones (high
        # confidence first), but always keep at least one per discipline.
        members = sorted(
            members,
            key=lambda m: {"high": 0, "medium": 1, "low": 2}.get(m.get("confidence"), 1),
        )[:MAX_ASSERTIONS_PER_CLUSTER]

        pages = sorted({m["page_number"] for m in members if m.get("page_number")})
        sheet_nums = sorted({m["sheet_number"] for m in members if m.get("sheet_number")})

        clusters.append({
            "key": f"{key_type}:{value}",
            "location": _human_location(key_type, value, members),
            "disciplines": sorted(disciplines),
            "page_numbers": pages,
            "sheets": sheet_nums,
            "assertions": members,
            "kind": "cross_discipline" if cross_discipline else "schedule_vs_plan",
        })

    # Most disciplines / most assertions first; deterministic tie-break by key.
    clusters.sort(key=lambda c: (-len(c["disciplines"]), -len(c["assertions"]), c["key"]))
    if len(clusters) > MAX_CLUSTERS:
        print(f"[Cluster] Capping {len(clusters)} clusters to {MAX_CLUSTERS}")
        clusters = clusters[:MAX_CLUSTERS]

    print(f"[Cluster] {len(clusters)} candidate clusters "
          f"({sum(1 for c in clusters if c['kind']=='cross_discipline')} cross-discipline, "
          f"{sum(1 for c in clusters if c['kind']=='schedule_vs_plan')} schedule-vs-plan)")
    return clusters
