"""
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 _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]:
    facts: Dict[Tuple[str, date], AccountMonthFact] = {}
    counters = ExtractionCounters()
    soql = build_extraction_soql(config)
    arr_source = config.get("arr_source")

    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")
            else:
                account_id = _get_record_field(record, "AccountId")
                account_name = _get_record_field(record, "Account.Name")

            if not account_id:
                counters.skipped_no_account += 1
                continue

            arr_amount = resolve_arr(record, config)
            if arr_amount is None or arr_amount == 0:
                counters.skipped_no_arr += 1
                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
                continue
            if end_date < start_date:
                counters.invalid_date_range += 1
                continue

            acquisition = is_acquisition_event(record, config)
            acquisition_month = _month_start(start_date)

            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


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}-01"


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]]) -> 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="USER_ENTERED",
        body={"values": padded_rows},
    ).execute()


def _write_rows_batched(
    service,
    spreadsheet_id: str,
    sheet_name: str,
    rows: List[List[Any]],
    batch_size: int = 10000,
) -> 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="USER_ENTERED",
            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
    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:
            continue

        cohort_fact = next((f for f in account_facts if f.month == cohort_month), None)
        if cohort_fact is None:
            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 = {
        "cohort_inferred_accounts": cohort_inferred_accounts,
        "reactivated_accounts": reactivated_accounts,
        "gap_smoothed_rows": gap_smoothed_rows,
        "multi_product_rows": multi_product_rows,
        "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(f"- {dq.get('reactivation_count', 0)} accounts reactivated after churn")

    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"SUMIFS({ledger}!J:J,{ledger}!A:A,acct,{ledger}!D:D,MAXIFS({ledger}!D:D,{ledger}!A:A,acct))>0)))"
        ),
        f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\",SUMIFS({ledger}!O:O,{ledger}!A:A,acct)>0)))",
        (
            f"=BYROW(A2:A,LAMBDA(acct,IF(acct=\"\",\"\",IF((SUMIFS({ledger}!O:O,{ledger}!A:A,acct)>0)+"
            f"(SUMIFS({ledger}!N:N,{ledger}!A:A,acct)>0)+"
            f"(SUMIFS({ledger}!K:K,{ledger}!A:A,acct)>0),\"Review\",\"OK\"))))"
        ),
    ])
    return rows


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", dq.get("pct_missing_arr", 0), "=IF(B8>0.05,\"Warning\",\"OK\")"])
    rows.append(["% Missing Dates", 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] = [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.")

    # Multi-currency validation (defensive)
    # Skip FLS guard since IsMultiCurrencyEnabled doesn't exist in non-multi-currency orgs
    try:
        org_info = await sf_client.soql(
            "SELECT Id, IsMultiCurrencyEnabled FROM Organization LIMIT 1",
            fls_guard=False,
        )
        is_multi = False
        if isinstance(org_info, dict):
            records = org_info.get("records") or []
            if records:
                is_multi = bool(records[0].get("IsMultiCurrencyEnabled"))
        if is_multi and not payload.get("multi_currency_strategy"):
            raise ValueError("Multi-currency detected. Select a handling strategy.")
    except Exception:
        # If org check fails (e.g., field doesn't exist), assume single-currency and continue
        pass

    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 = await stream_extract_aggregate(sf_client, payload)

    ledger_rows, meta = build_monthly_ledger(facts, payload)

    if not ledger_rows:
        raise ValueError("No qualifying records returned for GRR model.")

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

    total_accounts = len({row["AccountId"] for row in ledger_rows})
    total_records = len(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": counters.invalid_date_range,
        "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

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

    if 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 counters.total_processed:
        dq["pct_missing_arr"] = counters.skipped_no_arr / counters.total_processed
        dq["pct_missing_dates"] = counters.missing_dates / counters.total_processed

    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}"

    config_sheet_id = _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)

    # Write tabs
    _write_rows(sheets_service, spreadsheet_id, config_tab, _build_model_config_rows(payload))

    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)
    _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)

    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],
        "created": created,
        "validation": {
            "status": validation_status,
            "errors": validation_errors,
            "warnings": validation_warnings,
        },
        "data_quality": dq,
        "summary": summary,
    }
