"""
Build a Gross Revenue Retention (GRR) model in Google Sheets.
"""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any, Dict, Iterable, List, Optional, Tuple

logger = logging.getLogger(__name__)

DATE_SOURCE_PRIORITY = {"line_item": 3, "opportunity": 2, "derived": 1, "none": 0}


@dataclass
class AccountMonthFact:
    account_id: str
    account_name: str
    month: date
    active_arr: float = 0.0
    acquisition_arr: float = 0.0
    date_source: str = "none"
    fallback_used: bool = False
    component_count: int = 0


@dataclass
class ExtractionCounters:
    total_records: int = 0
    skipped_no_account: int = 0
    skipped_no_arr: int = 0
    missing_dates: int = 0
    invalid_date_range: int = 0

    @property
    def total_processed(self) -> int:
        return max(self.total_records, 0)


def _inputs_to_dict(inputs: Any) -> Dict[str, Any]:
    if hasattr(inputs, "dict"):
        return inputs.dict()
    if isinstance(inputs, dict):
        return inputs
    return dict(inputs)


def _format_value_list(values: Any, *, max_items: int = 3) -> str:
    normalized = _normalize_values(values)
    if not normalized:
        return ""
    if len(normalized) <= max_items:
        return ", ".join(normalized)
    shown = ", ".join(normalized[:max_items])
    return f"{shown} (+{len(normalized) - max_items} more)"


async def _is_multi_currency_enabled(sf_client) -> bool:
    describe = getattr(sf_client, "describe", None)
    if not callable(describe):
        return False
    try:
        org_desc = await describe("Organization")
    except Exception as exc:
        logger.debug("Failed to describe Organization for GRR multi-currency check: %s", exc)
        return False
    fields = org_desc.get("fields") if isinstance(org_desc, dict) else []
    return any(str(field.get("name") or "") == "IsMultiCurrencyEnabled" for field in (fields or []))


def _describe_empty_grr_result(
    config: Dict[str, Any],
    counters: "ExtractionCounters",
    facts: Dict[Tuple[str, date], "AccountMonthFact"],
    metadata: Dict[str, Any],
) -> str:
    source_label = (
        "OpportunityLineItem"
        if config.get("arr_source") == "opportunity_line_item"
        else "Opportunity"
    )
    stage_label = _format_value_list(config.get("extraction_stage_values") or []) or "selected stages"
    start_field = config.get("start_date_field") or "start date"
    end_field = config.get("end_date_field") or "fallback term"

    if counters.total_records == 0:
        return (
            "No qualifying records returned for GRR model. "
            f"Salesforce returned 0 {source_label} rows after filters "
            f"(stages: {stage_label})."
        )

    if not facts:
        reason_parts: List[str] = []
        if counters.skipped_no_arr:
            reason_parts.append(
                f"missing/zero ARR={counters.skipped_no_arr} on {config.get('arr_field') or 'selected ARR field'}"
            )
        if counters.missing_dates:
            reason_parts.append(
                f"missing dates={counters.missing_dates} using {start_field}/{end_field}"
            )
        if counters.invalid_date_range:
            reason_parts.append(f"invalid date range={counters.invalid_date_range}")
        if counters.skipped_no_account:
            reason_parts.append(f"missing account={counters.skipped_no_account}")
        details = "; ".join(reason_parts) or "all returned rows were filtered out"
        return (
            "No qualifying records returned for GRR model. "
            f"Salesforce returned {counters.total_records} raw row(s), but none qualified: {details}."
        )

    account_count = int(metadata.get("input_accounts") or 0)
    reason_parts = []
    accounts_without_cohort = int(metadata.get("accounts_without_cohort") or 0)
    if accounts_without_cohort:
        if config.get("enable_fallback_cohort"):
            reason_parts.append(f"accounts without cohort={accounts_without_cohort}")
        else:
            acq_field = config.get("acquisition_type_field") or "selected acquisition field"
            acq_values = _format_value_list(config.get("acquisition_type_values") or []) or "selected values"
            reason_parts.append(
                f"no acquisition event matching {acq_field} ({acq_values}) for {accounts_without_cohort} account(s) while fallback cohort is disabled"
            )
    zero_baseline_accounts = int(metadata.get("zero_baseline_accounts") or 0)
    if zero_baseline_accounts:
        reason_parts.append(f"zero baseline ARR for {zero_baseline_accounts} account(s)")
    missing_cohort_fact = int(metadata.get("accounts_missing_cohort_fact") or 0)
    if missing_cohort_fact:
        reason_parts.append(f"missing cohort fact for {missing_cohort_fact} account(s)")
    details = "; ".join(reason_parts) or "accounts were filtered during cohort construction"
    return (
        "No qualifying records returned for GRR model. "
        f"Extracted {len(facts)} account-month fact(s) across {account_count} account(s), "
        f"but no ledger rows survived: {details}."
    )


def _escape_soql_literal(value: str) -> str:
    return str(value).replace("\\", "\\\\").replace("'", "\\'")


def _normalize_values(values: Any) -> List[str]:
    if values is None:
        return []
    if isinstance(values, str):
        raw = values.split(",")
        return [v.strip() for v in raw if v and v.strip()]
    if isinstance(values, (list, tuple, set)):
        return [str(v).strip() for v in values if str(v).strip()]
    return [str(values).strip()] if str(values).strip() else []


def _parse_date(value: Any) -> Optional[date]:
    if value is None:
        return None
    if isinstance(value, datetime):
        return value.date()
    if isinstance(value, date):
        return value
    text = str(value).strip()
    if not text:
        return None
    if len(text) >= 10:
        try:
            return datetime.strptime(text[:10], "%Y-%m-%d").date()
        except ValueError:
            pass
    try:
        return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
    except ValueError:
        return None


def _month_start(dt: date) -> date:
    return date(dt.year, dt.month, 1)


def _add_months(dt: date, months: int) -> date:
    if months == 0:
        return dt
    year = dt.year + (dt.month - 1 + months) // 12
    month = (dt.month - 1 + months) % 12 + 1
    return date(year, month, 1)


def _subtract_years(dt: date, years: int) -> date:
    try:
        return dt.replace(year=dt.year - years)
    except ValueError:
        # Handle Feb 29 on non-leap years
        return dt.replace(year=dt.year - years, month=2, day=28)


def months_between(start: date, end: date) -> int:
    return (end.year - start.year) * 12 + (end.month - start.month)


def expand_months(start: date, end: date) -> Iterable[date]:
    current = _month_start(start)
    end_month = _month_start(end)
    while current <= end_month:
        yield current
        current = _add_months(current, 1)


def _merge_date_source(existing: str, new: str) -> str:
    return existing if DATE_SOURCE_PRIORITY.get(existing, 0) >= DATE_SOURCE_PRIORITY.get(new, 0) else new


def _get_record_field(record: Dict[str, Any], field: Optional[str]) -> Any:
    if not field:
        return None
    if "." in field:
        value: Any = record
        for key in field.split("."):
            if not isinstance(value, dict):
                value = None
                break
            value = value.get(key)
        if value is not None:
            return value
        if "Opportunity" in record and isinstance(record.get("Opportunity"), dict):
            value = record.get("Opportunity")
            for key in field.split("."):
                if not isinstance(value, dict):
                    return None
                value = value.get(key)
            return value
        return None
    if field in record:
        return record.get(field)
    if "Opportunity" in record and isinstance(record.get("Opportunity"), dict):
        return record.get("Opportunity", {}).get(field)
    return None


def _resolve_term_months(value: Any) -> Optional[int]:
    if value is None:
        return None
    try:
        months = int(float(value))
    except (TypeError, ValueError):
        return None
    return months if months > 0 else None


def resolve_dates(
    record: Dict[str, Any],
    config: Dict[str, Any],
    *,
    arr_source: str,
) -> Tuple[Optional[date], Optional[date], str, bool]:
    date_mode = (config.get("date_source_mode") or "hybrid").strip().lower()
    start_field = config.get("start_date_field")
    end_field = config.get("end_date_field")
    fallback_mode = (config.get("fallback_term_mode") or "constant").strip().lower()
    fallback_field = config.get("fallback_term_field")
    fallback_months = int(config.get("fallback_term_months") or 12)

    start_date: Optional[date] = None
    end_date: Optional[date] = None
    date_source = "none"
    fallback_used = False

    if arr_source == "opportunity":
        start_date = _parse_date(_get_record_field(record, start_field))
        if start_date:
            date_source = "opportunity"
        if end_field:
            end_date = _parse_date(_get_record_field(record, end_field))
    else:
        start_field = start_field or ""
        end_field = end_field or ""
        is_opp_start = start_field.startswith("Opportunity.")
        is_opp_end = end_field.startswith("Opportunity.")

        li_start = _parse_date(_get_record_field(record, start_field)) if start_field and not is_opp_start else None
        li_end = _parse_date(_get_record_field(record, end_field)) if end_field and not is_opp_end else None

        opp_start_field = start_field if is_opp_start else "Opportunity.CloseDate"
        opp_start = _parse_date(_get_record_field(record, opp_start_field))
        opp_end = _parse_date(_get_record_field(record, end_field)) if is_opp_end else None

        if date_mode == "line_item":
            start_date = li_start
            end_date = li_end
            if start_date:
                date_source = "line_item"
        elif date_mode == "opportunity":
            start_date = opp_start
            end_date = opp_end
            if start_date:
                date_source = "opportunity"
        else:
            if li_start:
                start_date = li_start
                date_source = "line_item"
            else:
                start_date = opp_start
                if start_date:
                    date_source = "opportunity"
            if li_end:
                end_date = li_end
            elif opp_end:
                end_date = opp_end

    if start_date and not end_date:
        term_value = None
        if fallback_mode == "field" and fallback_field:
            term_value = _resolve_term_months(_get_record_field(record, fallback_field))
        if term_value is None:
            term_value = fallback_months
        if term_value and term_value > 0:
            fallback_used = True
            end_date = _add_months(_month_start(start_date), term_value - 1)
            if date_source == "none":
                date_source = "derived"

    return start_date, end_date, date_source, fallback_used


def resolve_arr(record: Dict[str, Any], config: Dict[str, Any]) -> Optional[float]:
    strategy = (config.get("multi_currency_strategy") or "").strip()
    arr_field = config.get("arr_field")
    converted_field = config.get("converted_amount_field")

    field_to_use = arr_field
    if strategy == "corporate_converted" and converted_field:
        field_to_use = converted_field

    value = _get_record_field(record, field_to_use)
    if value is None:
        return None
    try:
        amount = float(value)
    except (TypeError, ValueError):
        return None
    return amount


def is_acquisition_event(record: Dict[str, Any], config: Dict[str, Any]) -> bool:
    field = config.get("acquisition_type_field") or ""
    values = set(_normalize_values(config.get("acquisition_type_values") or []))
    if not field or not values:
        return False
    val = _get_record_field(record, field)
    if val is None:
        return False
    return str(val) in values


def build_extraction_soql(config: Dict[str, Any]) -> str:
    arr_source = config.get("arr_source")
    arr_field = config.get("arr_field")
    start_field = config.get("start_date_field")
    end_field = config.get("end_date_field")
    fallback_field = config.get("fallback_term_field")
    converted_field = config.get("converted_amount_field")
    currency_strategy = (config.get("multi_currency_strategy") or "").strip()
    currency_filter = config.get("currency_filter_value")
    stage_values = _normalize_values(config.get("extraction_stage_values") or [])
    exclusion_field = config.get("exclusion_field")
    exclusion_values = _normalize_values(config.get("exclusion_values") or [])

    window_start = _subtract_years(datetime.utcnow().date(), 5).strftime("%Y-%m-%d")
    lookback = "LAST_N_YEARS:5"

    select_fields: List[str] = []
    where_clauses: List[str] = []

    def _opp_field(field: Optional[str]) -> Optional[str]:
        if not field:
            return field
        return field if field.startswith("Opportunity.") else f"Opportunity.{field}"

    if arr_source == "opportunity_line_item":
        # IsWon is on Opportunity, not OpportunityLineItem
        where_clauses.append("Opportunity.IsWon = true")
        select_fields = [
            "Id",
            "OpportunityId",
            "Opportunity.AccountId",
            "Opportunity.Account.Name",
            arr_field,
            start_field,
            "Opportunity.CloseDate",
            "Opportunity.StageName",
            "Opportunity.Type",
        ]
        # Only include CurrencyIsoCode if filtering by currency
        if currency_strategy == "single_currency_filter" and currency_filter:
            select_fields.append("CurrencyIsoCode")
        if end_field:
            select_fields.append(end_field)
        if fallback_field:
            select_fields.append(fallback_field)
        if converted_field:
            select_fields.append(converted_field)
        if exclusion_field:
            select_fields.append(_opp_field(exclusion_field))
        if config.get("acquisition_type_field"):
            field = config.get("acquisition_type_field")
            if field and field not in select_fields:
                select_fields.append(_opp_field(field))

        if start_field and end_field:
            where_clauses.append(f"({start_field} >= {window_start} OR {end_field} >= {window_start})")
        elif start_field:
            where_clauses.append(f"({start_field} >= {window_start} OR Opportunity.CloseDate >= {lookback})")
        else:
            where_clauses.append(f"Opportunity.CloseDate >= {lookback}")

        where_clauses.append("Opportunity.AccountId != null")

        if stage_values:
            rendered = ", ".join([f"'{_escape_soql_literal(v)}'" for v in stage_values])
            where_clauses.append(f"Opportunity.StageName IN ({rendered})")

        if exclusion_field and exclusion_values:
            rendered = ", ".join([f"'{_escape_soql_literal(v)}'" for v in exclusion_values])
            field_expr = _opp_field(exclusion_field)
            where_clauses.append(f"{field_expr} NOT IN ({rendered})")

        if currency_strategy == "single_currency_filter" and currency_filter:
            where_clauses.append(f"CurrencyIsoCode = '{_escape_soql_literal(currency_filter)}'")

        select_fields = [f for f in select_fields if f]
        soql = (
            f"SELECT {', '.join(select_fields)} FROM OpportunityLineItem "
            f"WHERE {' AND '.join(where_clauses)} "
            f"ORDER BY Opportunity.AccountId, ServiceDate"
        )
        return soql

    # Opportunity-level extraction
    # Reset where_clauses for Opportunity (OLI path returns early above)
    where_clauses = ["IsWon = true"]
    select_fields = [
        "Id",
        "AccountId",
        "Account.Name",
        arr_field,
        start_field,
        "StageName",
        "Type",
    ]
    # Only include CurrencyIsoCode if filtering by currency
    if currency_strategy == "single_currency_filter" and currency_filter:
        select_fields.append("CurrencyIsoCode")
    if end_field:
        select_fields.append(end_field)
    if fallback_field:
        select_fields.append(fallback_field)
    if converted_field:
        select_fields.append(converted_field)
    if exclusion_field:
        select_fields.append(exclusion_field)
    if config.get("acquisition_type_field"):
        field = config.get("acquisition_type_field")
        if field and field not in select_fields:
            select_fields.append(field)

    if end_field:
        where_clauses.append(f"({end_field} >= {window_start} OR CloseDate >= {lookback})")
    else:
        where_clauses.append(f"CloseDate >= {lookback}")

    where_clauses.append("AccountId != null")

    if stage_values:
        rendered = ", ".join([f"'{_escape_soql_literal(v)}'" for v in stage_values])
        where_clauses.append(f"StageName IN ({rendered})")

    if exclusion_field and exclusion_values:
        rendered = ", ".join([f"'{_escape_soql_literal(v)}'" for v in exclusion_values])
        where_clauses.append(f"{exclusion_field} NOT IN ({rendered})")

    if currency_strategy == "single_currency_filter" and currency_filter:
        where_clauses.append(f"CurrencyIsoCode = '{_escape_soql_literal(currency_filter)}'")

    select_fields = [f for f in select_fields if f]
    soql = (
        f"SELECT {', '.join(select_fields)} FROM Opportunity "
        f"WHERE {' AND '.join(where_clauses)} "
        f"ORDER BY AccountId, CloseDate"
    )
    return soql


async def stream_extract_aggregate(
    sf_client,
    config: Dict[str, Any],
) -> Tuple[Dict[Tuple[str, date], AccountMonthFact], ExtractionCounters, List[Dict[str, Any]]]:
    facts: Dict[Tuple[str, date], AccountMonthFact] = {}
    counters = ExtractionCounters()
    audit_records: List[Dict[str, Any]] = []
    soql = build_extraction_soql(config)
    arr_source = config.get("arr_source")
    source_object = "OpportunityLineItem" if arr_source == "opportunity_line_item" else "Opportunity"
    acquisition_field = config.get("acquisition_type_field") or ""

    async for batch in sf_client.paginate_soql(soql):
        # paginate_soql yields lists of records directly, not dicts with a "records" key
        for record in batch or []:
            counters.total_records += 1
            if arr_source == "opportunity_line_item":
                account_id = _get_record_field(record, "Opportunity.AccountId")
                account_name = _get_record_field(record, "Opportunity.Account.Name")
                opportunity_id = _get_record_field(record, "OpportunityId")
                stage_name = _get_record_field(record, "Opportunity.StageName")
            else:
                account_id = _get_record_field(record, "AccountId")
                account_name = _get_record_field(record, "Account.Name")
                opportunity_id = _get_record_field(record, "Id")
                stage_name = _get_record_field(record, "StageName")

            source_record_id = str(_get_record_field(record, "Id") or "")
            acquisition_value = str(_get_record_field(record, acquisition_field) or "")

            if not account_id:
                counters.skipped_no_account += 1
                audit_records.append(
                    {
                        "AccountId": "",
                        "AccountName": str(account_name or ""),
                        "SourceObject": source_object,
                        "SourceRecordId": source_record_id,
                        "OpportunityId": str(opportunity_id or ""),
                        "StageName": str(stage_name or ""),
                        "AcquisitionField": acquisition_field,
                        "AcquisitionValue": acquisition_value,
                        "AcquisitionMatch": False,
                        "ARR": "",
                        "StartDate": "",
                        "EndDate": "",
                        "StartMonth": "",
                        "EndMonth": "",
                        "DateSource": "none",
                        "FallbackTermUsed": False,
                        "MonthsCovered": "",
                        "QualifiedForLedger": False,
                        "QualificationStatus": "excluded",
                        "QualificationDetail": "missing_account",
                    }
                )
                continue

            arr_amount = resolve_arr(record, config)
            acquisition = is_acquisition_event(record, config)
            if arr_amount is None or arr_amount == 0:
                counters.skipped_no_arr += 1
                audit_records.append(
                    {
                        "AccountId": str(account_id),
                        "AccountName": str(account_name or ""),
                        "SourceObject": source_object,
                        "SourceRecordId": source_record_id,
                        "OpportunityId": str(opportunity_id or ""),
                        "StageName": str(stage_name or ""),
                        "AcquisitionField": acquisition_field,
                        "AcquisitionValue": acquisition_value,
                        "AcquisitionMatch": bool(acquisition),
                        "ARR": "",
                        "StartDate": "",
                        "EndDate": "",
                        "StartMonth": "",
                        "EndMonth": "",
                        "DateSource": "none",
                        "FallbackTermUsed": False,
                        "MonthsCovered": "",
                        "QualifiedForLedger": False,
                        "QualificationStatus": "excluded",
                        "QualificationDetail": "missing_or_zero_arr",
                    }
                )
                continue

            start_date, end_date, date_source, fallback_used = resolve_dates(
                record, config, arr_source=arr_source
            )
            if not start_date or not end_date:
                counters.missing_dates += 1
                audit_records.append(
                    {
                        "AccountId": str(account_id),
                        "AccountName": str(account_name or ""),
                        "SourceObject": source_object,
                        "SourceRecordId": source_record_id,
                        "OpportunityId": str(opportunity_id or ""),
                        "StageName": str(stage_name or ""),
                        "AcquisitionField": acquisition_field,
                        "AcquisitionValue": acquisition_value,
                        "AcquisitionMatch": bool(acquisition),
                        "ARR": round(arr_amount, 2),
                        "StartDate": _format_iso_date(start_date),
                        "EndDate": _format_iso_date(end_date),
                        "StartMonth": _format_month(_month_start(start_date)) if start_date else "",
                        "EndMonth": _format_month(_month_start(end_date)) if end_date else "",
                        "DateSource": date_source,
                        "FallbackTermUsed": bool(fallback_used),
                        "MonthsCovered": "",
                        "QualifiedForLedger": False,
                        "QualificationStatus": "excluded",
                        "QualificationDetail": "missing_dates",
                    }
                )
                continue
            if end_date < start_date:
                counters.invalid_date_range += 1
                audit_records.append(
                    {
                        "AccountId": str(account_id),
                        "AccountName": str(account_name or ""),
                        "SourceObject": source_object,
                        "SourceRecordId": source_record_id,
                        "OpportunityId": str(opportunity_id or ""),
                        "StageName": str(stage_name or ""),
                        "AcquisitionField": acquisition_field,
                        "AcquisitionValue": acquisition_value,
                        "AcquisitionMatch": bool(acquisition),
                        "ARR": round(arr_amount, 2),
                        "StartDate": _format_iso_date(start_date),
                        "EndDate": _format_iso_date(end_date),
                        "StartMonth": _format_month(_month_start(start_date)),
                        "EndMonth": _format_month(_month_start(end_date)),
                        "DateSource": date_source,
                        "FallbackTermUsed": bool(fallback_used),
                        "MonthsCovered": "",
                        "QualifiedForLedger": False,
                        "QualificationStatus": "excluded",
                        "QualificationDetail": "invalid_date_range",
                    }
                )
                continue

            acquisition_month = _month_start(start_date)
            start_month = _month_start(start_date)
            end_month = _month_start(end_date)

            audit_records.append(
                {
                    "AccountId": str(account_id),
                    "AccountName": str(account_name or ""),
                    "SourceObject": source_object,
                    "SourceRecordId": source_record_id,
                    "OpportunityId": str(opportunity_id or ""),
                    "StageName": str(stage_name or ""),
                    "AcquisitionField": acquisition_field,
                    "AcquisitionValue": acquisition_value,
                    "AcquisitionMatch": bool(acquisition),
                    "ARR": round(arr_amount, 2),
                    "StartDate": _format_iso_date(start_date),
                    "EndDate": _format_iso_date(end_date),
                    "StartMonth": _format_month(start_month),
                    "EndMonth": _format_month(end_month),
                    "DateSource": date_source,
                    "FallbackTermUsed": bool(fallback_used),
                    "MonthsCovered": months_between(start_month, end_month) + 1,
                    "QualifiedForLedger": True,
                    "QualificationStatus": "included",
                    "QualificationDetail": "qualified",
                }
            )

            for month in expand_months(start_date, end_date):
                key = (str(account_id), month)
                if key not in facts:
                    facts[key] = AccountMonthFact(
                        account_id=str(account_id),
                        account_name=str(account_name or ""),
                        month=month,
                    )
                fact = facts[key]
                fact.active_arr += arr_amount
                fact.component_count += 1
                fact.date_source = _merge_date_source(fact.date_source, date_source)
                fact.fallback_used = fact.fallback_used or fallback_used
                if acquisition and month == acquisition_month:
                    fact.acquisition_arr += arr_amount

    return facts, counters, audit_records


def determine_cohort(
    account_facts: List[AccountMonthFact],
    config: Dict[str, Any],
) -> Tuple[Optional[date], bool]:
    for fact in sorted(account_facts, key=lambda f: f.month):
        if fact.acquisition_arr > 0:
            return fact.month, False
    if config.get("enable_fallback_cohort"):
        for fact in sorted(account_facts, key=lambda f: f.month):
            if fact.active_arr > 0:
                return fact.month, True
    return None, False


def build_monthly_time_series(
    account_facts: List[AccountMonthFact],
    cohort_month: date,
    horizon_month: Optional[date] = None,
) -> List[Dict[str, Any]]:
    # Use global horizon if provided, otherwise fall back to account's max month
    # Extending to horizon ensures churned accounts get zero-ARR months for churn detection
    account_max = max(fact.month for fact in account_facts)
    max_month = horizon_month if horizon_month and horizon_month > account_max else account_max
    fact_lookup = {fact.month: fact for fact in account_facts}

    series: List[Dict[str, Any]] = []
    current = cohort_month
    while current <= max_month:
        fact = fact_lookup.get(current)
        series.append(
            {
                "month": current,
                "active_arr": fact.active_arr if fact else 0.0,
                "date_source": fact.date_source if fact else "none",
                "fallback_used": fact.fallback_used if fact else False,
                "component_count": fact.component_count if fact else 0,
                "is_gap_smoothed": False,
                "gap_months": 0,
                "retained_arr": 0.0,
                "reactivation_arr": 0.0,
                "is_churned": False,
            }
        )
        current = _add_months(current, 1)
    return series


def apply_gap_smoothing_time_series(
    series: List[Dict[str, Any]],
    tolerance_months: int,
) -> List[Dict[str, Any]]:
    if tolerance_months <= 0:
        return series
    i = 0
    while i < len(series):
        if series[i]["active_arr"] == 0:
            gap_start = i
            while i < len(series) and series[i]["active_arr"] == 0:
                i += 1
            gap_end = i
            if gap_start > 0 and gap_end < len(series):
                gap_months = gap_end - gap_start
                if gap_months <= tolerance_months:
                    prev = series[gap_start - 1]
                    for j in range(gap_start, gap_end):
                        series[j]["active_arr"] = prev["active_arr"]
                        series[j]["date_source"] = prev["date_source"]
                        series[j]["fallback_used"] = prev["fallback_used"]
                        series[j]["component_count"] = prev["component_count"]
                        series[j]["is_gap_smoothed"] = True
                        series[j]["gap_months"] = gap_months
        else:
            i += 1
    return series


def apply_terminal_churn_logic(
    series: List[Dict[str, Any]],
    baseline_arr: float,
    tolerance_months: int,
) -> List[Dict[str, Any]]:
    is_churned = False
    for month in series:
        active_arr = month["active_arr"]
        if not is_churned:
            if active_arr == 0 and not month["is_gap_smoothed"]:
                is_churned = True
                month["is_churned"] = True
                month["retained_arr"] = 0.0
                month["reactivation_arr"] = 0.0
            else:
                month["is_churned"] = False
                # Clamp retained_arr to non-negative to handle credits/downsells
                month["retained_arr"] = max(0.0, min(active_arr, baseline_arr))
                month["reactivation_arr"] = 0.0
        else:
            month["is_churned"] = True
            month["retained_arr"] = 0.0
            month["reactivation_arr"] = active_arr if active_arr > 0 else 0.0
    return series


def _format_month(dt: date) -> str:
    return f"{dt.year:04d}-{dt.month:02d}"


def _format_iso_date(dt: Optional[date]) -> str:
    return dt.isoformat() if dt else ""


def _literal_text(value: str) -> str:
    text = str(value or "")
    return f"'{text}" if text else ""


def _col_letter(idx: int) -> str:
    result = ""
    idx_val = idx
    while True:
        idx_val, rem = divmod(idx_val, 26)
        result = chr(ord("A") + rem) + result
        if idx_val == 0:
            break
        idx_val -= 1
    return result


def _quote_tab(tab_name: str) -> str:
    escaped = tab_name.replace("'", "''")
    return f"'{escaped}'"


def _sheet_cell(tab_name: str, cell_ref: str) -> str:
    return f"{_quote_tab(tab_name)}!{cell_ref}"


def _ensure_sheet(service, spreadsheet_id: str, sheet_name: str, replace: bool = False) -> int:
    try:
        ss = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
        for sheet in ss.get("sheets", []):
            if sheet["properties"]["title"] == sheet_name:
                if replace:
                    sheet_id = sheet["properties"]["sheetId"]
                    service.spreadsheets().batchUpdate(
                        spreadsheetId=spreadsheet_id,
                        body={"requests": [{"deleteSheet": {"sheetId": sheet_id}}]},
                    ).execute()
                    break
                return sheet["properties"]["sheetId"]
    except Exception:
        pass

    resp = service.spreadsheets().batchUpdate(
        spreadsheetId=spreadsheet_id,
        body={"requests": [{"addSheet": {"properties": {"title": sheet_name}}}]},
    ).execute()
    return resp["replies"][0]["addSheet"]["properties"]["sheetId"]


def _set_sheet_hidden(service, spreadsheet_id: str, sheet_id: int, hidden: bool) -> None:
    service.spreadsheets().batchUpdate(
        spreadsheetId=spreadsheet_id,
        body={
            "requests": [
                {
                    "updateSheetProperties": {
                        "properties": {"sheetId": sheet_id, "hidden": hidden},
                        "fields": "hidden",
                    }
                }
            ]
        },
    ).execute()


def _write_rows(
    service,
    spreadsheet_id: str,
    sheet_name: str,
    rows: List[List[Any]],
    *,
    value_input_option: str = "USER_ENTERED",
) -> None:
    if not rows:
        return
    num_cols = max(len(row) for row in rows)
    end_col = _col_letter(num_cols - 1)
    range_str = f"{sheet_name}!A1:{end_col}{len(rows)}"
    padded_rows = [list(row) + [""] * (num_cols - len(row)) for row in rows]
    service.spreadsheets().values().update(
        spreadsheetId=spreadsheet_id,
        range=range_str,
        valueInputOption=value_input_option,
        body={"values": padded_rows},
    ).execute()


def _write_rows_batched(
    service,
    spreadsheet_id: str,
    sheet_name: str,
    rows: List[List[Any]],
    batch_size: int = 10000,
    *,
    value_input_option: str = "USER_ENTERED",
) -> None:
    if not rows:
        return
    num_cols = max(len(row) for row in rows)
    end_col = _col_letter(num_cols - 1)
    start_row = 1
    for idx in range(0, len(rows), batch_size):
        chunk = rows[idx: idx + batch_size]
        end_row = start_row + len(chunk) - 1
        range_str = f"{sheet_name}!A{start_row}:{end_col}{end_row}"
        padded_rows = [list(row) + [""] * (num_cols - len(row)) for row in chunk]
        service.spreadsheets().values().update(
            spreadsheetId=spreadsheet_id,
            range=range_str,
            valueInputOption=value_input_option,
            body={"values": padded_rows},
        ).execute()
        start_row = end_row + 1


def _grr_at_month(ledger_rows: List[Dict[str, Any]], month_idx: int) -> Optional[float]:
    baseline_by_account: Dict[str, float] = {}
    retained_by_account: Dict[str, float] = {}
    for row in ledger_rows:
        if row["MonthsSinceCohort"] == 0:
            baseline_by_account[row["AccountId"]] = float(row["BaselineARR"])
        if row["MonthsSinceCohort"] == month_idx:
            retained_by_account[row["AccountId"]] = float(row["RetainedARR"])
    if not baseline_by_account:
        return None
    # Denominator is ALL baseline accounts, not just those with rows at month_idx
    # Accounts without rows at month_idx are treated as retained_arr=0 (churned)
    denom = sum(baseline_by_account.values())
    if denom == 0:
        return None
    # Numerator sums retained ARR for all baseline accounts (0 if missing)
    num = sum(retained_by_account.get(account_id, 0.0) for account_id in baseline_by_account.keys())
    return num / denom


def build_monthly_ledger(
    facts: Dict[Tuple[str, date], AccountMonthFact],
    config: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    accounts: Dict[str, List[AccountMonthFact]] = {}
    for (_, _month), fact in facts.items():
        accounts.setdefault(fact.account_id, []).append(fact)

    # Calculate global horizon - extends all accounts to the same end month
    # This ensures churned accounts get zero-ARR months for proper churn detection
    global_horizon = max(fact.month for fact in facts.values()) if facts else None

    ledger_rows: List[Dict[str, Any]] = []
    cohort_inferred_accounts: set[str] = set()
    reactivated_accounts: set[str] = set()
    gap_smoothed_rows = 0
    multi_product_rows = 0
    zero_baseline_accounts = 0
    accounts_without_cohort = 0
    accounts_missing_cohort_fact = 0
    logo_retained_by_account: Dict[str, bool] = {}
    logo_by_cohort: Dict[date, List[bool]] = {}

    tolerance_months = math.ceil(int(config.get("gap_tolerance_days") or 0) / 30)
    policy = (config.get("continuity_policy") or "hybrid").strip().lower()

    for account_id, account_facts in accounts.items():
        account_facts.sort(key=lambda f: f.month)
        cohort_month, cohort_inferred = determine_cohort(account_facts, config)
        if cohort_month is None:
            accounts_without_cohort += 1
            continue

        cohort_fact = next((f for f in account_facts if f.month == cohort_month), None)
        if cohort_fact is None:
            accounts_missing_cohort_fact += 1
            continue

        baseline_arr = cohort_fact.acquisition_arr
        if baseline_arr == 0:
            if not config.get("enable_fallback_cohort"):
                zero_baseline_accounts += 1
                continue
            baseline_arr = cohort_fact.active_arr
            cohort_inferred = True

        if baseline_arr == 0:
            zero_baseline_accounts += 1
            continue

        if cohort_inferred:
            cohort_inferred_accounts.add(account_id)

        base_series = build_monthly_time_series(account_facts, cohort_month, global_horizon)
        if policy in ("operational", "hybrid"):
            series = apply_gap_smoothing_time_series(base_series, tolerance_months)
        else:
            series = base_series
        series = apply_terminal_churn_logic(series, baseline_arr, tolerance_months)

        if any(month["reactivation_arr"] > 0 for month in series):
            reactivated_accounts.add(account_id)

        gap_smoothed_rows += sum(1 for month in series if month["is_gap_smoothed"])
        multi_product_rows += sum(1 for month in series if month["component_count"] > 1)

        logo_series = series
        if policy == "hybrid":
            strict_series = build_monthly_time_series(account_facts, cohort_month, global_horizon)
            strict_series = apply_terminal_churn_logic(strict_series, baseline_arr, 0)
            logo_series = strict_series

        if len(logo_series) > 12:
            retained = not logo_series[12]["is_churned"]
            logo_retained_by_account[account_id] = retained
            logo_by_cohort.setdefault(cohort_month, []).append(retained)

        for month_data in series:
            ledger_rows.append(
                {
                    "AccountId": account_id,
                    "AccountName": account_facts[0].account_name,
                    "CohortMonth": _format_month(cohort_month),
                    "CalendarMonth": _format_month(month_data["month"]),
                    "MonthsSinceCohort": months_between(cohort_month, month_data["month"]),
                    "ActiveARR": round(month_data["active_arr"], 2),
                    "BaselineARR": round(baseline_arr, 2),
                    "RetainedARR": round(month_data["retained_arr"], 2),
                    "ReactivationARR": round(month_data["reactivation_arr"], 2),
                    "IsChurned": bool(month_data["is_churned"]),
                    "IsGapSmoothed": bool(month_data["is_gap_smoothed"]),
                    "GapMonths": int(month_data["gap_months"] or 0),
                    "DateSource": month_data["date_source"],
                    "FallbackTermUsed": bool(month_data["fallback_used"]),
                    "CohortInferred": bool(cohort_inferred),
                    "MultiProductARR": bool(month_data["component_count"] > 1),
                }
            )

    ledger_rows.sort(key=lambda r: (r["AccountId"], r["CalendarMonth"]))

    metadata = {
        "input_accounts": len(accounts),
        "cohort_inferred_accounts": cohort_inferred_accounts,
        "reactivated_accounts": reactivated_accounts,
        "gap_smoothed_rows": gap_smoothed_rows,
        "multi_product_rows": multi_product_rows,
        "accounts_without_cohort": accounts_without_cohort,
        "accounts_missing_cohort_fact": accounts_missing_cohort_fact,
        "zero_baseline_accounts": zero_baseline_accounts,
        "logo_retained_by_account": logo_retained_by_account,
        "logo_by_cohort": logo_by_cohort,
    }
    return ledger_rows, metadata


def _build_model_config_rows(config: Dict[str, Any]) -> List[List[Any]]:
    built_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
    arr_source_label = "OpportunityLineItem" if config.get("arr_source") == "opportunity_line_item" else "Opportunity"
    mc_strategy = config.get("multi_currency_strategy") or ""
    strategy_label = {
        "corporate_converted": "Corporate Converted",
        "normalized_field": "Normalized Field",
        "single_currency_filter": "Single Currency Filter",
        "": "",
        None: "",
    }.get(mc_strategy, mc_strategy)
    fallback_label = (
        f"{config.get('fallback_term_months')} months"
        if config.get("fallback_term_mode") == "constant"
        else str(config.get("fallback_term_field") or "")
    )

    rows = [
        ["Parameter", "Value", "Description"],
        ["ARR Source", arr_source_label, "Data object used"],
        ["ARR Field", config.get("arr_field") or "", "Field mapped to ARR"],
        ["Multi-Currency Strategy", strategy_label, "How currency handled"],
        ["Converted Amount Field", config.get("converted_amount_field") or "", "Field used for conversion"],
        ["Date Source Mode", (config.get("date_source_mode") or "").title(), "Date resolution logic"],
        ["Start Date Field", config.get("start_date_field") or "", ""],
        ["End Date Field", config.get("end_date_field") or "", ""],
        ["Fallback Term", fallback_label, "When end date missing"],
        ["Continuity Policy", (config.get("continuity_policy") or "").title(), "Gap handling mode"],
        ["Gap Tolerance", f"{config.get('gap_tolerance_days')} days", "Max bridgeable gap"],
        ["Extraction Stage", ", ".join(_normalize_values(config.get("extraction_stage_values") or [])), "For fetching all ARR"],
        ["Acquisition Type", ", ".join(_normalize_values(config.get("acquisition_type_values") or [])), "For baseline calculation"],
        ["Fallback Cohort", "Enabled" if config.get("enable_fallback_cohort") else "Disabled", "Infer from first ARR"],
        ["Built At", built_at, "Timestamp"],
    ]
    return rows


def _build_methodology_rows(config: Dict[str, Any], dq: Dict[str, Any]) -> List[List[Any]]:
    lines = []
    lines.append("# GRR Model Methodology")
    lines.append("")
    lines.append("## Data Source")
    lines.append(f"- Object: {'OpportunityLineItem' if config.get('arr_source') == 'opportunity_line_item' else 'Opportunity'}")
    lines.append(f"- ARR Field: {config.get('arr_field')}")
    if config.get("multi_currency_strategy"):
        lines.append(
            f"- Currency Handling: {config.get('multi_currency_strategy')}"
            + (f" (field: {config.get('converted_amount_field')})" if config.get("converted_amount_field") else "")
        )

    lines.append("")
    lines.append("## Extraction vs Acquisition")
    lines.append("- Extraction Filter: IsWon = true (all ARR-bearing events)")
    lines.append(
        f"- Acquisition Filter: {config.get('acquisition_type_field')} in {config.get('acquisition_type_values')}"
    )
    lines.append("- Why separate? Extraction includes renewals/expansions for ActiveARR.")
    lines.append("  Only acquisition events contribute to BaselineARR.")

    lines.append("")
    lines.append("## Service Term Resolution")
    lines.append(f"- Mode: {config.get('date_source_mode')}")
    lookback_method = "service_end" if config.get("end_date_field") else "close_date"
    lines.append(f"- Lookback Method: {lookback_method} >= 5 years ago")
    lines.append(f"- Start Date: {config.get('start_date_field')}")
    lines.append(f"- End Date: {config.get('end_date_field') or 'N/A'} with {config.get('fallback_term_months')} month fallback")

    lines.append("")
    lines.append("## Cohort Assignment")
    lines.append("Accounts are assigned to their acquisition cohort based on:")
    lines.append("- First month with qualifying acquisition event")
    lines.append("- Fallback: First observed ARR month (flagged as CohortInferred)")

    lines.append("")
    lines.append("## Baseline Calculation")
    lines.append("- BaselineARR = SUM(ARR from acquisition-qualified rows in cohort month)")
    lines.append("- Expansions in Month 0 are in ActiveARR but NOT in BaselineARR")
    lines.append("- This ensures honest GRR even with same-month upsells")

    lines.append("")
    lines.append("## Continuity Policy")
    lines.append(f"- Policy: {config.get('continuity_policy')}")
    lines.append("- Gap tolerance in days is converted to months: ceil(days / 30)")

    lines.append("")
    lines.append("## Terminal Churn Behavior")
    lines.append("- Once an account churns (gap exceeds tolerance), RetainedARR = 0 permanently")
    lines.append("- Returning ARR is tracked as ReactivationARR and excluded from GRR")

    lines.append("")
    lines.append("## Retention Calculations")
    lines.append("- GRR = SUM(RetainedARR) / SUM(BaselineARR)")
    lines.append("- RetainedARR = MIN(ActiveARR, BaselineARR), but 0 after terminal churn")

    lines.append("")
    lines.append("## Data Quality Notes")
    lines.append(f"- {round(dq.get('pct_fallback_term_used', 0) * 100, 1)}% of records used fallback term length")
    lines.append(f"- {round(dq.get('pct_cohort_inferred', 0) * 100, 1)}% of cohorts were inferred")
    lines.append(f"- {round(dq.get('pct_gap_smoothed', 0) * 100, 1)}% of months had gap smoothing applied")
    lines.append("- Missing ARR / date metrics are scoped to accounts that made the modeled cohort")
    lines.append(f"- {dq.get('reactivation_count', 0)} accounts reactivated after churn")
    lines.append("- Use the Source_Audit tab to trace which Salesforce records drive cohort and ending ARR")

    lines.append("")
    lines.append("## Build Information")
    lines.append(f"- Built: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC")
    lines.append("- Model Version: 1.0")

    return [[line] for line in lines]


def _build_customers_rows(ledger_tab: str) -> List[List[Any]]:
    ledger = _quote_tab(ledger_tab)
    rows = [
        [
            "Account ID",
            "Account Name",
            "Cohort Month",
            "Baseline ARR",
            "Current ARR",
            "Retained ARR",
            "Reactivation ARR",
            "GRR %",
            "Status",
            "Is Churned",
            "Cohort Inferred",
            "Data Quality Flag",
        ]
    ]

    rows.append([
        f"=UNIQUE(FILTER({ledger}!A2:A,{ledger}!A2:A<>\"\"))",
        f"=ARRAYFORMULA(IF(A2:A=\"\",\"\",VLOOKUP(A2:A,{ledger}!A:B,2,FALSE)))",
        f"=ARRAYFORMULA(IF(A2:A=\"\",\"\",VLOOKUP(A2:A,{ledger}!A:C,3,FALSE)))",
        f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\",SUMIFS({ledger}!G:G,{ledger}!A:A,acct,{ledger}!E:E,0))))",
        (
            f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\","
            f"SUMIFS({ledger}!F:F,{ledger}!A:A,acct,{ledger}!D:D,MAXIFS({ledger}!D:D,{ledger}!A:A,acct)))))"
        ),
        (
            f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\","
            f"SUMIFS({ledger}!H:H,{ledger}!A:A,acct,{ledger}!D:D,MAXIFS({ledger}!D:D,{ledger}!A:A,acct)))))"
        ),
        (
            f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\","
            f"SUMIFS({ledger}!I:I,{ledger}!A:A,acct,{ledger}!D:D,MAXIFS({ledger}!D:D,{ledger}!A:A,acct)))))"
        ),
        "=ARRAYFORMULA(IF(A2:A=\"\",\"\",IFERROR(F2:F/D2:D,\"\")))",
        "=ARRAYFORMULA(IF(A2:A=\"\",\"\",IF(J2:J=TRUE,\"Churned\",IF(G2:G>0,\"Reactivated\",\"Active\"))))",
        (
            f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\","
            f"COUNTIFS({ledger}!A:A,acct,{ledger}!D:D,MAXIFS({ledger}!D:D,{ledger}!A:A,acct),{ledger}!J:J,TRUE)>0)))"
        ),
        f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\",COUNTIFS({ledger}!A:A,acct,{ledger}!O:O,TRUE)>0)))",
        (
            f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\",IF((COUNTIFS({ledger}!A:A,acct,{ledger}!O:O,TRUE)>0)+"
            f"(COUNTIFS({ledger}!A:A,acct,{ledger}!N:N,TRUE)>0)+"
            f"(COUNTIFS({ledger}!A:A,acct,{ledger}!K:K,TRUE)>0),\"Review\",\"OK\"))))"
        ),
    ])
    return rows


def _build_source_audit_rows(
    audit_records: List[Dict[str, Any]],
    ledger_rows: List[Dict[str, Any]],
) -> List[List[Any]]:
    header = [
        "Account ID",
        "Account Name",
        "Source Object",
        "Source Record ID",
        "Opportunity ID",
        "Stage Name",
        "Acquisition Field",
        "Acquisition Value",
        "Acquisition Match",
        "ARR",
        "Start Date",
        "End Date",
        "Start Month",
        "End Month",
        "Date Source",
        "Fallback Term Used",
        "Months Covered",
        "Qualified For Ledger",
        "Qualification Status",
        "Qualification Detail",
        "Account Included In Model",
        "Model Cohort Month",
        "Model Ending Month",
        "Contributes To Baseline ARR",
        "Contributes To Ending Active ARR",
    ]
    rows: List[List[Any]] = [header]
    if not audit_records:
        return rows

    model_state: Dict[str, Dict[str, str]] = {}
    for row in sorted(ledger_rows, key=lambda item: (item["AccountId"], item["CalendarMonth"])):
        state = model_state.setdefault(
            row["AccountId"],
            {
                "cohort_month": row["CohortMonth"],
                "ending_month": row["CalendarMonth"],
            },
        )
        state["ending_month"] = row["CalendarMonth"]

    def _contributes_to_ending_arr(record: Dict[str, Any], ending_month: str) -> bool:
        start_month = str(record.get("StartMonth") or "")
        end_month = str(record.get("EndMonth") or "")
        if not start_month or not end_month or not ending_month:
            return False
        return start_month <= ending_month <= end_month

    ordered_records = sorted(
        audit_records,
        key=lambda item: (
            str(item.get("AccountId") or ""),
            str(item.get("QualificationStatus") or ""),
            str(item.get("StartDate") or ""),
            str(item.get("SourceRecordId") or ""),
        ),
    )

    for record in ordered_records:
        account_id = str(record.get("AccountId") or "")
        state = model_state.get(account_id, {})
        cohort_month = str(state.get("cohort_month") or "")
        ending_month = str(state.get("ending_month") or "")
        account_in_model = bool(state)
        qualified = bool(record.get("QualifiedForLedger"))
        contributes_to_baseline = (
            account_in_model
            and qualified
            and bool(record.get("AcquisitionMatch"))
            and str(record.get("StartMonth") or "") == cohort_month
        )
        contributes_to_ending_arr = (
            account_in_model
            and qualified
            and _contributes_to_ending_arr(record, ending_month)
        )
        rows.append(
            [
                account_id,
                record.get("AccountName") or "",
                record.get("SourceObject") or "",
                record.get("SourceRecordId") or "",
                record.get("OpportunityId") or "",
                record.get("StageName") or "",
                record.get("AcquisitionField") or "",
                record.get("AcquisitionValue") or "",
                bool(record.get("AcquisitionMatch")),
                record.get("ARR") if record.get("ARR") != "" else "",
                record.get("StartDate") or "",
                record.get("EndDate") or "",
                record.get("StartMonth") or "",
                record.get("EndMonth") or "",
                record.get("DateSource") or "",
                bool(record.get("FallbackTermUsed")),
                record.get("MonthsCovered") if record.get("MonthsCovered") != "" else "",
                qualified,
                record.get("QualificationStatus") or "",
                record.get("QualificationDetail") or "",
                account_in_model,
                cohort_month,
                ending_month,
                contributes_to_baseline,
                contributes_to_ending_arr,
            ]
        )
    return rows


def _build_data_quality(
    ledger_rows: List[Dict[str, Any]],
    meta: Dict[str, Any],
    audit_records: List[Dict[str, Any]],
) -> Dict[str, Any]:
    total_accounts = len({row["AccountId"] for row in ledger_rows})
    total_records = len(ledger_rows)
    modeled_account_ids = {row["AccountId"] for row in ledger_rows}

    dq = {
        "total_accounts": total_accounts,
        "total_records": total_records,
        "pct_fallback_term_used": 0.0,
        "pct_cohort_inferred": 0.0,
        "pct_gap_smoothed": 0.0,
        "pct_multi_product": 0.0,
        "pct_missing_arr": 0.0,
        "pct_missing_dates": 0.0,
        "invalid_date_range_count": 0,
        "zero_baseline_arr_count": meta.get("zero_baseline_accounts", 0),
        "reactivation_count": len(meta.get("reactivated_accounts", set())),
    }

    if total_records:
        dq["pct_fallback_term_used"] = sum(1 for row in ledger_rows if row.get("FallbackTermUsed")) / total_records
        dq["pct_gap_smoothed"] = meta.get("gap_smoothed_rows", 0) / total_records
        dq["pct_multi_product"] = meta.get("multi_product_rows", 0) / total_records

    if total_accounts:
        dq["pct_cohort_inferred"] = len(meta.get("cohort_inferred_accounts", set())) / total_accounts

    modeled_audit_records = [
        row for row in audit_records if str(row.get("AccountId") or "") in modeled_account_ids
    ]
    if modeled_audit_records:
        dq["pct_missing_arr"] = (
            sum(1 for row in modeled_audit_records if row.get("QualificationDetail") == "missing_or_zero_arr")
            / len(modeled_audit_records)
        )
        dq["pct_missing_dates"] = (
            sum(1 for row in modeled_audit_records if row.get("QualificationDetail") == "missing_dates")
            / len(modeled_audit_records)
        )
        dq["invalid_date_range_count"] = sum(
            1 for row in modeled_audit_records if row.get("QualificationDetail") == "invalid_date_range"
        )

    return dq


def _build_summary_rows(
    ledger_tab: str,
    config: Dict[str, Any],
    dq: Dict[str, Any],
    summary: Dict[str, Any],
    cohort_months: List[str],
    logo_by_cohort: Dict[str, Optional[float]],
) -> List[List[Any]]:
    ledger = _quote_tab(ledger_tab)
    rows: List[List[Any]] = []

    rows.append(["Data Quality Metric", "Value", "Status"])
    rows.append(["Total Accounts Analyzed", f"=COUNTA(UNIQUE(FILTER({ledger}!A2:A,{ledger}!A2:A<>\"\")))", "Info"])
    rows.append(["Total Ledger Records", f"=COUNTA({ledger}!A2:A)", "Info"])
    rows.append(["% Fallback Term Used", dq.get("pct_fallback_term_used", 0), "=IF(B4>0.2,\"Warning\",\"OK\")"])
    rows.append(["% Cohort Inferred", dq.get("pct_cohort_inferred", 0), "=IF(B5>0.1,\"Warning\",\"OK\")"])
    rows.append(["% Gap Smoothed", dq.get("pct_gap_smoothed", 0), "=IF(B6>0.25,\"Warning\",\"OK\")"])
    rows.append(["% Multi-Product", dq.get("pct_multi_product", 0), "Info"])
    rows.append(["% Missing ARR (modeled accounts)", dq.get("pct_missing_arr", 0), "=IF(B8>0.05,\"Warning\",\"OK\")"])
    rows.append(["% Missing Dates (modeled accounts)", dq.get("pct_missing_dates", 0), "=IF(B9>0.1,\"Warning\",\"OK\")"])
    rows.append(["Invalid Date Ranges", dq.get("invalid_date_range_count", 0), "=IF(B10>0,\"Warning\",\"OK\")"])
    rows.append(["Zero Baseline Accounts", dq.get("zero_baseline_arr_count", 0), "=IF(B11>0,\"Warning\",\"OK\")"])
    rows.append(["Reactivation Count", dq.get("reactivation_count", 0), "Info"])

    mc_strategy = config.get("multi_currency_strategy") or ""
    rows.append(["Multi-Currency Status", mc_strategy or "single_currency", "Info"])
    lookback_method = "service_end" if config.get("end_date_field") else "close_date"
    rows.append(["Lookback Method", lookback_method, "Info"])

    rows.append([])
    rows.append(["Metric", "Value"])
    rows.append(["Total Cohort ARR", f"=SUMIFS({ledger}!G:G,{ledger}!E:E,0)"])
    rows.append(["GRR @ M3", summary.get("grr_m3") if summary.get("grr_m3") is not None else ""])
    rows.append(["GRR @ M6", summary.get("grr_m6") if summary.get("grr_m6") is not None else ""])
    rows.append(["GRR @ M12", summary.get("grr_m12") if summary.get("grr_m12") is not None else ""])
    rows.append(["Logo Retention @ M12", summary.get("logo_retention_m12") if summary.get("logo_retention_m12") is not None else ""])

    rows.append([])
    header = ["Cohort", "M0 ARR"]
    for i in range(1, 13):
        header.append(f"M{i} GRR")
    header.append("Logo M12")
    rows.append(header)

    base_row_index = len(rows) + 1
    for idx, cohort in enumerate(cohort_months):
        row_num = base_row_index + idx
        row: List[Any] = [_literal_text(cohort)]
        row.append(
            f"=IFERROR(SUMIFS({ledger}!G:G,{ledger}!C:C,$A{row_num},{ledger}!E:E,0),\"\")"
        )
        for month_idx in range(1, 13):
            row.append(
                f"=IFERROR(SUMIFS({ledger}!H:H,{ledger}!C:C,$A{row_num},{ledger}!E:E,{month_idx})/"
                f"SUMIFS({ledger}!G:G,{ledger}!C:C,$A{row_num},{ledger}!E:E,0),\"\")"
            )
        row.append(logo_by_cohort.get(cohort, ""))
        rows.append(row)

    return rows


async def build_grr_model(
    *,
    spreadsheet_id: Optional[str],
    inputs: Any,
    sheets_service,
    sf_client,
    title: str = "GRR Model",
) -> Dict[str, Any]:
    payload = _inputs_to_dict(inputs)

    if not payload.get("arr_field"):
        raise ValueError("ARR field is required.")
    if not payload.get("start_date_field"):
        raise ValueError("Start date field is required.")
    if not payload.get("extraction_stage_values"):
        raise ValueError("At least one extraction stage value is required.")

    # Single-currency orgs do not expose the Organization field at all, so use describe
    # instead of probing the field with a failing SOQL query.
    if await _is_multi_currency_enabled(sf_client) and not payload.get("multi_currency_strategy"):
        raise ValueError("Multi-currency detected. Select a handling strategy.")

    if payload.get("multi_currency_strategy") == "corporate_converted" and not payload.get("converted_amount_field"):
        raise ValueError("converted_amount_field required when using corporate_converted strategy")

    facts, counters, audit_records = await stream_extract_aggregate(sf_client, payload)

    ledger_rows, meta = build_monthly_ledger(facts, payload)

    if not ledger_rows:
        detail = _describe_empty_grr_result(payload, counters, facts, meta)
        logger.warning("GRR build produced no qualifying rows: %s", detail)
        raise ValueError(detail)

    if len(ledger_rows) > 500000:
        raise ValueError("LEDGER_TOO_LARGE: Dataset exceeds 500k rows.")

    dq = _build_data_quality(ledger_rows, meta, audit_records)

    latest_month = max((row["CalendarMonth"] for row in ledger_rows), default=None)

    summary = {
        "total_cohort_arr": sum(
            row["BaselineARR"] for row in ledger_rows if row["MonthsSinceCohort"] == 0
        ),
        "latest_month": latest_month[:7] if latest_month else "",
        "grr_m3": _grr_at_month(ledger_rows, 3),
        "grr_m6": _grr_at_month(ledger_rows, 6),
        "grr_m12": _grr_at_month(ledger_rows, 12),
        "logo_retention_m12": None,
    }

    logo_retained = meta.get("logo_retained_by_account", {})
    if logo_retained:
        summary["logo_retention_m12"] = sum(1 for v in logo_retained.values() if v) / len(logo_retained)

    validation_errors: List[str] = []
    validation_warnings: List[str] = []
    if dq.get("pct_fallback_term_used", 0) > 0.2:
        validation_warnings.append("Many records missing end dates (fallback term used >20%).")
    if dq.get("pct_cohort_inferred", 0) > 0.1:
        validation_warnings.append("Some accounts lack acquisitions (cohort inferred >10%).")
    if dq.get("pct_gap_smoothed", 0) > 0.25:
        validation_warnings.append("High gap smoothing may mask churn (>25% smoothed).")
    if dq.get("pct_missing_arr", 0) > 0.05:
        validation_warnings.append("ARR missing on more than 5% of records.")
    if dq.get("pct_missing_dates", 0) > 0.1:
        validation_warnings.append("Dates missing on more than 10% of records.")

    validation_status = "errors" if validation_errors else "warnings" if validation_warnings else "ok"

    # Prepare sheet outputs
    created = False
    if spreadsheet_id is None:
        spreadsheet = sheets_service.spreadsheets().create(
            body={"properties": {"title": title}}
        ).execute()
        spreadsheet_id = spreadsheet["spreadsheetId"]
        created = True

    suffix = payload.get("tab_suffix") or ""
    replace_existing = (payload.get("idempotency_action") or "") == "replace"

    config_tab = f"Model_Config{suffix}"
    ledger_tab = f"GRR_Ledger{suffix}"
    customers_tab = f"Customers{suffix}"
    summary_tab = f"GRR_Summary{suffix}"
    methodology_tab = f"Methodology{suffix}"
    source_audit_tab = f"Source_Audit{suffix}"

    _ensure_sheet(sheets_service, spreadsheet_id, config_tab, replace=replace_existing)
    ledger_sheet_id = _ensure_sheet(sheets_service, spreadsheet_id, ledger_tab, replace=replace_existing)
    _ensure_sheet(sheets_service, spreadsheet_id, customers_tab, replace=replace_existing)
    _ensure_sheet(sheets_service, spreadsheet_id, summary_tab, replace=replace_existing)
    _ensure_sheet(sheets_service, spreadsheet_id, methodology_tab, replace=replace_existing)
    _ensure_sheet(sheets_service, spreadsheet_id, source_audit_tab, replace=replace_existing)

    # Write tabs
    _write_rows(
        sheets_service,
        spreadsheet_id,
        config_tab,
        _build_model_config_rows(payload),
        value_input_option="RAW",
    )

    ledger_header = [
        "AccountId",
        "AccountName",
        "CohortMonth",
        "CalendarMonth",
        "MonthsSinceCohort",
        "ActiveARR",
        "BaselineARR",
        "RetainedARR",
        "ReactivationARR",
        "IsChurned",
        "IsGapSmoothed",
        "GapMonths",
        "DateSource",
        "FallbackTermUsed",
        "CohortInferred",
        "MultiProductARR",
    ]
    ledger_rows_values = [ledger_header]
    for row in ledger_rows:
        ledger_rows_values.append([
            row["AccountId"],
            row["AccountName"],
            row["CohortMonth"],
            row["CalendarMonth"],
            row["MonthsSinceCohort"],
            row["ActiveARR"],
            row["BaselineARR"],
            row["RetainedARR"],
            row["ReactivationARR"],
            row["IsChurned"],
            row["IsGapSmoothed"],
            row["GapMonths"],
            row["DateSource"],
            row["FallbackTermUsed"],
            row["CohortInferred"],
            row["MultiProductARR"],
        ])

    _write_rows_batched(
        sheets_service,
        spreadsheet_id,
        ledger_tab,
        ledger_rows_values,
        batch_size=10000,
        value_input_option="RAW",
    )
    _set_sheet_hidden(sheets_service, spreadsheet_id, ledger_sheet_id, True)

    _write_rows(sheets_service, spreadsheet_id, customers_tab, _build_customers_rows(ledger_tab))

    cohort_months = sorted({row["CohortMonth"] for row in ledger_rows})
    logo_by_cohort = {}
    for cohort, values in meta.get("logo_by_cohort", {}).items():
        key = _format_month(cohort)
        logo_by_cohort[key] = sum(1 for v in values if v) / len(values) if values else ""

    summary_rows = _build_summary_rows(
        ledger_tab,
        payload,
        dq,
        summary,
        cohort_months,
        logo_by_cohort,
    )
    _write_rows(sheets_service, spreadsheet_id, summary_tab, summary_rows)

    methodology_rows = _build_methodology_rows(payload, dq)
    _write_rows(
        sheets_service,
        spreadsheet_id,
        methodology_tab,
        methodology_rows,
        value_input_option="RAW",
    )
    _write_rows(
        sheets_service,
        spreadsheet_id,
        source_audit_tab,
        _build_source_audit_rows(audit_records, ledger_rows),
        value_input_option="RAW",
    )

    return {
        "spreadsheet_id": spreadsheet_id,
        "url": f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}",
        "tabs_created": [config_tab, ledger_tab, customers_tab, summary_tab, methodology_tab, source_audit_tab],
        "created": created,
        "validation": {
            "status": validation_status,
            "errors": validation_errors,
            "warnings": validation_warnings,
        },
        "data_quality": dq,
        "summary": summary,
    }
