"""Shared canonical-JSON builders for BOTH diff-harness extractors
(spec.md, spec_version 1).

extract_legacy.py (old side: Supabase ``public`` / landed ``legacy``
schema) and extract_new.py (new side: the R1 ``public`` schema) feed
these pure builders from their own SQL; the serialization rules,
grouping keys, and quantization live HERE so the two sides cannot
drift. A change to this file is a spec change (spec_version bump).
"""

from __future__ import annotations

import json
from collections import defaultdict
from collections.abc import Iterable
from decimal import ROUND_HALF_UP, Decimal
from typing import Any

SPEC_VERSION = 1
CURRENCY = "CAD"  # committed legacy default (07-judgment-calls #21)


# --- canonical-form helpers (spec.md "Serialization rules") ------------

def qstr(value: Decimal | int | str, places: int) -> str:
    """Quantize to `places` decimals, ROUND_HALF_UP, as a plain string."""
    d = Decimal(value).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP)
    if d == 0:
        d = abs(d)  # normalize -0.00
    return format(d, "f")


def money(value: Decimal | None, places: int = 2) -> dict[str, str] | None:
    """Scalar money: per-currency map, or None for NULL."""
    return None if value is None else {CURRENCY: qstr(value, places)}


def money_sum(value: Decimal | None, places: int = 2) -> dict[str, str]:
    """Aggregate money: always present; all-NULL sums are zero."""
    return {CURRENCY: qstr(value if value is not None else 0, places)}


def unassigned_key(operator: str | None, client: str | None, project: str | None) -> str:
    triple = json.dumps([operator, client, project], ensure_ascii=False,
                        separators=(",", ":"))
    return f"unassigned:{triple}"


def _reject_floats(obj: Any, path: str = "$") -> None:
    if isinstance(obj, float):
        raise TypeError(f"float at {path} — non-integer numerics must be strings")
    if isinstance(obj, dict):
        for k, v in obj.items():
            _reject_floats(v, f"{path}/{k}")
    elif isinstance(obj, list):
        for i, v in enumerate(obj):
            _reject_floats(v, f"{path}/{i}")


def canonical_dumps(doc: dict) -> str:
    _reject_floats(doc)
    return json.dumps(doc, ensure_ascii=False, indent=2, sort_keys=True) + "\n"


# --- section builders (pure — fed by SQL rows, unit-testable) ----------

def adjusted_billout(billout_sum: Decimal, pct: Decimal | None,
                     amount: Decimal | None) -> str:
    """ADR #040 read-time adjustment per (month x project) window; 2dp,
    rounded once at the end."""
    raw = billout_sum * (1 + (pct or Decimal(0)) / 100) + (amount or Decimal(0))
    return qstr(raw, 2)


def _month_group(entry_count: int, source_seconds: int, cost: Decimal | None,
                 billout: Decimal | None, rate_sum: Decimal | None,
                 amount_sum: Decimal | None, project_name: str | None,
                 adj_pct: Decimal | None, adj_amount: Decimal | None) -> dict:
    return {
        "project_name": project_name,
        "entry_count": entry_count,
        "source_seconds": source_seconds,
        "cost": money_sum(cost),
        "billout": money_sum(billout),
        "billout_adjusted": {
            CURRENCY: adjusted_billout(billout or Decimal(0), adj_pct, adj_amount)
        },
        "source_rate_sum": money_sum(rate_sum, 4),
        "source_amount_sum": money_sum(amount_sum, 4),
    }


def build_months(assigned_rows: Iterable[tuple], unassigned_rows: Iterable[tuple],
                 projects: dict[str, dict]) -> dict:
    """assigned_rows: (month, project_id, count, seconds, cost, billout,
    rate_sum, amount_sum); unassigned_rows adds the raw (operator, client,
    project) triple; projects: entity map from build_entities."""
    months: dict[str, dict] = defaultdict(dict)
    for month, pid, n, secs, cost, billout, rsum, asum in assigned_rows:
        p = projects[pid]
        months[month][pid] = _month_group(
            n, secs, cost, billout, rsum, asum, p["name"],
            p["_adj_pct"], p["_adj_amount"])
    for month, op, cl, pr, n, secs, cost, billout, rsum, asum in unassigned_rows:
        months[month][unassigned_key(op, cl, pr)] = _month_group(
            n, secs, cost, billout, rsum, asum, None, None, None)
    return dict(months)


def build_invoices(invoice_rows: Iterable[tuple],
                   attached_rows: Iterable[tuple]) -> dict:
    """invoice_rows: (id, name, status, invoice_date, manual_total)
    attached_rows: (invoice_id, entry_id, billout_amount)"""
    ids: dict[str, list[str]] = defaultdict(list)
    totals: dict[str, Decimal] = defaultdict(lambda: Decimal(0))
    for inv_id, entry_id, amount in attached_rows:
        ids[inv_id].append(entry_id)
        if amount is not None:
            totals[inv_id] += amount
    return {
        inv_id: {
            "name": name,
            "status": status,
            "invoice_date": invoice_date.isoformat() if invoice_date else None,
            "manual_total": money(manual_total),
            "entry_count": len(ids[inv_id]),
            "attached_entry_ids": sorted(ids[inv_id]),
            "computed_total": money_sum(totals.get(inv_id)),
        }
        for inv_id, name, status, invoice_date, manual_total in invoice_rows
    }


def build_cc(batch_rows: Iterable[tuple], line_group_rows: Iterable[tuple],
             highlight_keyword_count: int, auto_rule_count: int) -> dict:
    """batch_rows: (id, name)
    line_group_rows: (batch_id, project_id|None, line_count, amount_sum)"""
    groups: dict[str, dict[str, dict]] = defaultdict(dict)
    line_counts: dict[str, int] = defaultdict(int)
    for batch_id, project_id, n, amount in line_group_rows:
        groups[batch_id][project_id or "unassigned"] = {
            "line_count": n,
            "amount": money_sum(amount),
        }
        line_counts[batch_id] += n
    return {
        "batches": {
            batch_id: {
                "name": name,
                "line_count": line_counts[batch_id],
                "assigned_totals": groups.get(batch_id, {}),
            }
            for batch_id, name in batch_rows
        },
        "highlight_keyword_count": highlight_keyword_count,
        "auto_rule_count": auto_rule_count,
    }


def build_entities(operator_rows: Iterable[tuple], client_rows: Iterable[tuple],
                   project_rows: Iterable[tuple],
                   project_entry_counts: dict[str, int],
                   member_rows: Iterable[tuple],
                   member_entry_counts: dict[str, int],
                   unassigned_entry_count: int) -> dict:
    """operator_rows: (name,); client_rows: (operator_name, name)
    project_rows: (id, name, client_name, operator_name, income, adj_pct, adj_amount)
    member_rows: (lower_email, display_name, cost_rate, billout_rate, is_active)

    Project dicts carry two private keys (_adj_pct/_adj_amount, Decimals)
    consumed by build_months and stripped before emission."""
    operators: dict[str, dict] = {}
    for (name,) in operator_rows:
        if name in operators:
            raise ValueError(
                f"duplicate operator name {name!r} (two orgs?) — the spec keys"
                " operators by name; disambiguate before extraction")
        operators[name] = {"entry_count": 0, "clients": {}}
    for op_name, name in client_rows:
        clients = operators[op_name]["clients"]
        if name in clients:
            raise ValueError(
                f"duplicate client name {name!r} under operator {op_name!r}")
        clients[name] = {"entry_count": 0}

    projects = {}
    for pid, name, client_name, op_name, income, adj_pct, adj_amount in project_rows:
        n = project_entry_counts.get(pid, 0)
        projects[pid] = {
            "name": name,
            "client": client_name,
            "operator": op_name,
            "entry_count": n,
            "income": money(income),
            "billout_adjustment_pct": qstr(adj_pct, 4) if adj_pct is not None else None,
            "billout_adjustment_amount": money(adj_amount),
            "_adj_pct": adj_pct,
            "_adj_amount": adj_amount,
        }
        operators[op_name]["entry_count"] += n
        operators[op_name]["clients"][client_name]["entry_count"] += n

    members: dict[str, dict] = {}
    for email, display_name, cost, billout, active in member_rows:
        m = members.setdefault(email, {"display_names": set(), "rates": []})
        m["display_names"].add(display_name)
        m["rates"].append({"cost": money(cost), "billout": money(billout),
                           "active": active})
    team_members = {
        email: {
            "display_names": sorted(m["display_names"]),
            "entry_count": member_entry_counts.get(email, 0),
            "rates": sorted(m["rates"], key=_rate_sort_key),
        }
        for email, m in members.items()
    }
    return {
        "operators": operators,
        "projects": projects,
        "team_members": team_members,
        "unassigned_entry_count": unassigned_entry_count,
    }


def _rate_sort_key(rate: dict) -> tuple:
    def k(v):  # money map | None -> (nulls-first, comparable Decimal)
        return (v is None, Decimal(v[CURRENCY]) if v is not None else Decimal(0))
    return (*k(rate["cost"]), *k(rate["billout"]), rate["active"])


def assemble(months: dict, invoices: dict, cc: dict, entities: dict,
             meta: dict) -> dict:
    entities = {**entities, "projects": {
        pid: {k: v for k, v in p.items() if not k.startswith("_")}
        for pid, p in entities["projects"].items()
    }}
    return {
        "spec_version": SPEC_VERSION,
        "months": months,
        "invoices": invoices,
        "cc": cc,
        "entities": entities,
        "meta": meta,
    }


# --- shared plumbing ------------------------------------------------------

def load_env_file(path: str) -> dict[str, str]:
    values = {}
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                key, _, value = line.partition("=")
                values[key.strip()] = value.strip()
    return values
