"""
Google Sheets gremlin API - scan, safe fixes, and styling.
"""

from __future__ import annotations

import asyncio
import json
import logging
import re
import time
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Literal

from fastapi import APIRouter, Depends, Header, HTTPException, Request, status, BackgroundTasks
from googleapiclient.errors import HttpError
from pydantic import BaseModel, Field, root_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi.responses import JSONResponse
from uuid import uuid4

from ...auth_security import require_account
from ... import settings as saas_settings
from ...db import get_session
from ..progress import get_redis
from g_gremlin import google_client
from g_gremlin import graph, lint, sheets_io
from g_gremlin import repair
from g_gremlin.profile import compute_sheet_profile
from ...models import Account, Plan, SubscriptionEvent
from ...services.gremlin_ai import GremlinAI
from ...services.gremlin_profiler import (
    SheetProfile,
    build_sheet_profile,
    compute_content_hash,
    fetch_sheet_values,
)
from ...services.telemetry import emit as emit_telemetry
from ...templates.waterfall_builder import WaterfallInputs, build_waterfall
from ...templates.capacity_builder import CapacityInputs, build_capacity_stress
from ...templates.coverage_builder import CoverageInputs, build_pipeline_coverage
from ...templates.sdr_builder import SDRInputs, build_sdr_diagnostic
from ...templates.market_finder_builder import build_market_finder
from ...templates.partner_overlap_builder import build_partner_overlap
from ...templates.territory_planner_builder import build_territory_planner
from ...templates.grr_model_builder import build_grr_model
from ...services.usage_gateway import UsageGateway
from ...services.salesforce_gateway import (
    SalesforceAuthError,
    SalesforceConnectionError,
    SalesforceFieldAccessError,
    SalesforceNotConfigured,
    SalesforceGateway,
    get_salesforce_gateway,
)
from .auth_helpers import resolve_canonical_account_id

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v2/gremlin", tags=["gremlin"])


def _normalize_idempotency_fields(values: Dict[str, Any]) -> Dict[str, Any]:
    if not isinstance(values, dict):
        return values
    if "idempotency_action" not in values and "_idempotency_action" in values:
        values["idempotency_action"] = values["_idempotency_action"]
    if "tab_suffix" not in values and "_tab_suffix" in values:
        values["tab_suffix"] = values["_tab_suffix"]
    return values


def _derive_first_fy(fy_start_month: Any, as_of_date: Optional[str] = None) -> int:
    """Derive FY label from fiscal start month and current date (or optional as-of date)."""
    try:
        month = int(fy_start_month)
    except Exception:
        month = 1
    if month < 1 or month > 12:
        month = 1

    as_of = date.today()
    if as_of_date:
        try:
            raw = str(as_of_date).strip()
            if raw:
                if "T" in raw:
                    as_of = datetime.fromisoformat(raw.replace("Z", "+00:00")).date()
                else:
                    as_of = date.fromisoformat(raw)
        except Exception:
            pass

    end_year = as_of.year + (1 if (month > 1 and as_of.month >= month) else 0)
    return end_year % 100


class ScanRequest(BaseModel):
    spreadsheet_id: str = Field(..., description="Target spreadsheet ID")
    sheet_name: Optional[str] = Field(
        None, description="Sheet name to scan (ignored when range is provided)"
    )
    range: Optional[str] = Field(
        None,
        description="Optional A1 range (e.g., 'Model!A1:Z200'). Takes precedence over sheet_name.",
    )
    # Client-side data (preferred - avoids Sheets API calls from backend)
    values: Optional[List[List[Any]]] = Field(
        None, description="Cell values grid from SpreadsheetApp.getValues()"
    )
    formulas: Optional[List[List[Any]]] = Field(
        None, description="Cell formulas grid from SpreadsheetApp.getFormulas()"
    )
    sheet_titles: Optional[List[str]] = Field(
        None, description="All sheet names in the spreadsheet"
    )


class ProfileRequest(BaseModel):
    spreadsheet_id: str
    sheet_name: str
    revision: str
    content_hash: Optional[str] = None


class ProfileResponse(BaseModel):
    status: str
    job_id: Optional[str] = None
    profile: Optional[SheetProfile] = None
    profile_key: Optional[str] = None
    error: Optional[str] = None


class IssueModel(BaseModel):
    id: str
    cell: str
    type: str
    severity: str
    message: str
    formula: Optional[str] = None
    fixable: bool = False
    suggested_fix: Optional[str] = None


class PatternAnomaly(BaseModel):
    cell: str
    pattern_group: str
    description: str
    neighbor_cells: List[str] = Field(default_factory=list)


class MissingSheet(BaseModel):
    sheet_name: str
    referenced_by: int
    example_cells: List[str] = Field(default_factory=list)


class HealthModel(BaseModel):
    score: int
    grade: str
    total_issues: int
    errors: int
    warnings: int
    fixable: int


class ScanResponse(BaseModel):
    spreadsheet_id: str
    sheet_name: str
    health: HealthModel
    issues: List[IssueModel]
    pattern_anomalies: List[PatternAnomaly]
    missing_sheets: List[MissingSheet]
    ai_summary: Optional[str] = None


class FixItem(BaseModel):
    cell: str
    fixed: str


class FixRequest(BaseModel):
    spreadsheet_id: str
    fixes: List[FixItem]
    dry_run: bool = False
    categories: Optional[List[str]] = None
    sheet_name: Optional[str] = None


class FixResponse(BaseModel):
    applied: int
    skipped: int
    errors: List[str] = Field(default_factory=list)
    message: Optional[str] = None


class FixPlanRequest(BaseModel):
    spreadsheet_id: str
    sheet_name: Optional[str] = None
    range: Optional[str] = None
    # Client-side data (preferred - avoids Sheets API calls from backend)
    values: Optional[List[List[Any]]] = None
    formulas: Optional[List[List[Any]]] = None
    sheet_titles: Optional[List[str]] = None


class FixPlanSummary(BaseModel):
    total_issues: int
    formula_issues: int
    style_issues: int
    data_quality_issues: int


class FixPlanCategory(BaseModel):
    id: str
    label: str
    issue_count: int
    description: str
    selected_by_default: bool = False


class FixPlanResponse(BaseModel):
    spreadsheet_id: str
    sheet_name: str
    summary: FixPlanSummary
    categories: List[FixPlanCategory]


class StyleRequest(BaseModel):
    spreadsheet_id: str
    sheet_name: str
    style_preset: str = "professional"


class StyleResponse(BaseModel):
    applied: bool
    changes: List[str] = Field(default_factory=list)


class StyleInterpretRequest(BaseModel):
    style_description: str = Field(..., min_length=3, max_length=500)


class StyleInterpretResponse(BaseModel):
    success: bool
    style: Optional[Dict[str, Any]] = None
    error: Optional[str] = None


class ExplainRequest(BaseModel):
    spreadsheet_id: str
    cell: str
    formula: Optional[str] = None


class ExplainResponse(BaseModel):
    cell: str
    formula: Optional[str] = None
    explanation: Optional[str] = None
    complexity: Optional[str] = None
    dependencies: List[str] = Field(default_factory=list)


class WaterfallInputsModel(BaseModel):
    granularity: str = Field("quarterly", description="Granularity for pacing and recognition (quarterly or monthly).")
    annual_target: float = Field(..., description="Annual net-new bookings target.")
    nl_attribution: float = Field(0.925, ge=0, le=1, description="Share of target attributed to new logo.")
    upsell_attribution: float = Field(0.075, ge=0, le=1, description="Share of target attributed to upsell.")
    multiplier: float = Field(1.2, description="Stretch factor applied to bookings target.")
    disco_to_pipe_rate: float = Field(0.4, ge=0, le=1, description="Discovery to pipeline conversion rate.")
    nl_win_rate: float = Field(0.25, ge=0, le=1, description="New logo win rate.")
    nl_asp: float = Field(175000, description="Average selling price for new logo deals.")
    nl_lag: int = Field(2, description="Lag in quarters for new logo recognition.")
    upsell_win_rate: float = Field(0.50, ge=0, le=1, description="Upsell win rate.")
    upsell_asp: float = Field(43750, description="Average selling price for upsell deals.")
    upsell_lag: int = Field(1, description="Lag in quarters for upsell recognition.")
    sdr_productivity: int = Field(10, description="Discovery calls per SDR per month.")
    ae_productivity: float = Field(250000, description="Quota per ramped AE per quarter.")
    ae_to_se_ratio: float = Field(2.5, description="AE to SE coverage ratio.")
    poc_rate: float = Field(0.8, ge=0, le=1, description="Share of opps requiring PoC.")
    quarterly_pacing: Optional[List[float]] = Field(
        None, min_items=4, max_items=4, description="Pacing percentages for Q1-Q4 (fractions)."
    )
    monthly_pacing: Optional[List[float]] = Field(
        None, min_items=12, max_items=12, description="Pacing percentages for months 1-12 (fractions)."
    )
    nl_lag_months: Optional[int] = Field(None, description="Lag in months for new logo recognition.")
    upsell_lag_months: Optional[int] = Field(None, description="Lag in months for upsell recognition.")
    fy27_growth: float = Field(1.15, description="Multiplier applied to second FY target assumptions.")
    fy_start_month: int = Field(1, ge=1, le=12, description="Fiscal year start month (1-12).")
    first_fy: int = Field(26, ge=20, le=99, description="First fiscal year number (auto-derived when omitted).")
    # Idempotency handling
    tab_suffix: str = Field("", description="Suffix to append to tab names (e.g., '_v2').")
    idempotency_action: str = Field("", description="Idempotency action: 'replace' or 'suffix'.")

    @root_validator(pre=True)
    def _normalize_idempotency(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        values = _normalize_idempotency_fields(values)
        if "first_fy" not in values or values.get("first_fy") in (None, ""):
            values["first_fy"] = _derive_first_fy(values.get("fy_start_month", 1))
        return values


class WaterfallRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(None, description="Spreadsheet ID to write into. Omit to create a new sheet.")
    title: Optional[str] = Field("Sales & Marketing Waterfall", description="Spreadsheet title when creating a new file.")
    inputs: WaterfallInputsModel


class WaterfallValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)


class WaterfallResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: WaterfallValidationModel
    explanation: Optional[str] = None


# ---------------------------------------------------------------------------
# Capacity Stress Test Models
# ---------------------------------------------------------------------------


class CapacityInputsModel(BaseModel):
    annual_target: float = Field(..., description="Annual bookings target.")
    pacing_q1: float = Field(0.15, ge=0, le=1, description="Q1 pacing fraction.")
    pacing_q2: float = Field(0.20, ge=0, le=1, description="Q2 pacing fraction.")
    pacing_q3: float = Field(0.30, ge=0, le=1, description="Q3 pacing fraction.")
    pacing_q4: float = Field(0.35, ge=0, le=1, description="Q4 pacing fraction.")
    avg_deal_size: float = Field(100000, description="Average deal size.")
    win_rate: float = Field(0.25, ge=0, le=1, description="Win rate.")
    starting_aes: int = Field(4, ge=0, description="Starting AE count.")
    quarterly_attrition_rate: float = Field(0.0, ge=0, le=1, description="Quarterly attrition rate.")
    # New: dynamic net-hires list (length = planning_years * 4)
    net_hires: Optional[List[int]] = Field(None, description="Net hires per quarter. Length must equal planning_years * 4.")
    # Legacy per-quarter fields (backward compat — ignored when net_hires is provided)
    net_hires_q1: int = Field(0, description="(Legacy) Net hires Q1.")
    net_hires_q2: int = Field(1, description="(Legacy) Net hires Q2.")
    net_hires_q3: int = Field(1, description="(Legacy) Net hires Q3.")
    net_hires_q4: int = Field(1, description="(Legacy) Net hires Q4.")
    net_hires_q5: int = Field(0, description="(Legacy) Net hires FY+1 Q1.")
    net_hires_q6: int = Field(0, description="(Legacy) Net hires FY+1 Q2.")
    net_hires_q7: int = Field(0, description="(Legacy) Net hires FY+1 Q3.")
    net_hires_q8: int = Field(0, description="(Legacy) Net hires FY+1 Q4.")
    hire_month: int = Field(2, ge=1, le=3, description="Hire month in quarter (1=start, 2=mid, 3=end).")
    ramp_months: int = Field(6, ge=1, le=12, description="Ramp period in months.")
    ramp_m1_2: float = Field(0.25, ge=0, le=1.5, description="M1-2 productivity.")
    ramp_m3_4: float = Field(0.50, ge=0, le=1.5, description="M3-4 productivity.")
    ramp_m5_6: float = Field(0.75, ge=0, le=1.5, description="M5-6 productivity.")
    ramp_full: float = Field(1.0, ge=0, le=1.5, description="Fully ramped productivity.")
    ramped_quota: float = Field(250000, description="Ramped AE quota per quarter.")
    seasonality_q1: float = Field(1.0, ge=0.5, le=2.0, description="Q1 seasonality factor.")
    seasonality_q2: float = Field(1.0, ge=0.5, le=2.0, description="Q2 seasonality factor.")
    seasonality_q3: float = Field(1.0, ge=0.5, le=2.0, description="Q3 seasonality factor.")
    seasonality_q4: float = Field(1.0, ge=0.5, le=2.0, description="Q4 seasonality factor.")
    first_fy: int = Field(26, ge=20, le=99, description="First fiscal year number (auto-derived when omitted).")
    fy_start_month: int = Field(1, ge=1, le=12, description="Fiscal year start month.")
    planning_years: int = Field(1, ge=1, le=3, description="Number of fiscal years to plan (1–3).")
    # Legacy boolean (backward compat — ignored when planning_years is provided)
    enable_fy27: bool = Field(False, description="(Legacy) Enable FY27. Use planning_years instead.")
    yoy_growth_pct: float = Field(0.0, ge=-0.5, le=2.0, description="Year-over-year growth percentage.")
    as_of_date: Optional[str] = Field(None, description="As-of date for deterministic testing.")
    # Idempotency handling
    tab_suffix: str = Field("", description="Suffix to append to tab names (e.g., '_v2').")
    idempotency_action: str = Field("", description="Idempotency action: 'replace' or 'suffix'.")

    @root_validator(pre=True)
    def _normalize_planning(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        values = _normalize_idempotency_fields(values)
        if "first_fy" not in values or values.get("first_fy") in (None, ""):
            values["first_fy"] = _derive_first_fy(values.get("fy_start_month", 1), values.get("as_of_date"))
        # Backward compat: if planning_years wasn't explicitly sent, derive from enable_fy27
        if "planning_years" not in values and values.get("enable_fy27"):
            values["planning_years"] = 2
        planning_years = values.get("planning_years", 1)
        # Build net_hires list if not explicitly provided
        if values.get("net_hires") is None:
            legacy = [
                values.get("net_hires_q1", 0), values.get("net_hires_q2", 1),
                values.get("net_hires_q3", 1), values.get("net_hires_q4", 1),
                values.get("net_hires_q5", 0), values.get("net_hires_q6", 0),
                values.get("net_hires_q7", 0), values.get("net_hires_q8", 0),
            ]
            needed = planning_years * 4
            # Pad with zeros or trim to match planning_years
            values["net_hires"] = (legacy + [0] * needed)[:needed]
        return values


class CapacityRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(None, description="Spreadsheet ID to write into. Omit to create new.")
    title: Optional[str] = Field("Sales Capacity Stress Test", description="Spreadsheet title when creating new.")
    inputs: CapacityInputsModel


class CapacityValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)


class CapacityResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: CapacityValidationModel
    explanation: Optional[str] = None


# ---------------------------------------------------------------------------
# Pipeline Coverage & Timing Models
# ---------------------------------------------------------------------------


class CoverageInputsModel(BaseModel):
    annual_target: float = Field(..., description="Annual bookings target.")
    pacing_q1: float = Field(0.15, ge=0, le=1, description="Q1 pacing fraction.")
    pacing_q2: float = Field(0.20, ge=0, le=1, description="Q2 pacing fraction.")
    pacing_q3: float = Field(0.30, ge=0, le=1, description="Q3 pacing fraction.")
    pacing_q4: float = Field(0.35, ge=0, le=1, description="Q4 pacing fraction.")
    avg_deal_size: float = Field(100000, description="Average deal size.")
    coverage_target: float = Field(3.0, ge=1.0, le=10.0, description="Coverage target multiplier.")
    win_rate: float = Field(0.25, ge=0.05, le=0.80, description="Win rate.")
    sales_cycle_days: int = Field(90, ge=14, le=365, description="Average sales cycle in days.")
    late_threshold_days: int = Field(30, ge=7, le=180, description="Late pipeline threshold in days.")
    use_stage_timing: bool = Field(False, description="Use stage timing breakdown.")
    stage_1_name: str = Field("Discovery", description="Stage 1 name.")
    stage_1_days: int = Field(20, ge=1, description="Stage 1 duration in days.")
    stage_2_name: str = Field("Qualification", description="Stage 2 name.")
    stage_2_days: int = Field(25, ge=1, description="Stage 2 duration in days.")
    stage_3_name: str = Field("Proposal", description="Stage 3 name.")
    stage_3_days: int = Field(25, ge=1, description="Stage 3 duration in days.")
    stage_4_name: str = Field("Negotiation", description="Stage 4 name.")
    stage_4_days: int = Field(20, ge=1, description="Stage 4 duration in days.")
    first_fy: int = Field(26, ge=20, le=99, description="First fiscal year number (auto-derived when omitted).")
    fy_start_month: int = Field(1, ge=1, le=12, description="Fiscal year start month.")
    planning_years: int = Field(1, ge=1, le=3, description="Number of fiscal years to plan (1–3).")
    enable_fy27: bool = Field(False, description="(Legacy) Enable FY27. Use planning_years instead.")
    yoy_growth_pct: float = Field(0.0, ge=-0.5, le=2.0, description="Year-over-year growth percentage.")
    as_of_date: Optional[str] = Field(None, description="As-of date for deterministic testing.")
    # Idempotency handling
    tab_suffix: str = Field("", description="Suffix to append to tab names (e.g., '_v2').")
    idempotency_action: str = Field("", description="Idempotency action: 'replace' or 'suffix'.")

    @root_validator(pre=True)
    def _normalize_planning(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        values = _normalize_idempotency_fields(values)
        if "first_fy" not in values or values.get("first_fy") in (None, ""):
            values["first_fy"] = _derive_first_fy(values.get("fy_start_month", 1), values.get("as_of_date"))
        if "planning_years" not in values and values.get("enable_fy27"):
            values["planning_years"] = 2
        return values


class CoverageRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(None, description="Spreadsheet ID to write into. Omit to create new.")
    title: Optional[str] = Field("Pipeline Coverage & Timing", description="Spreadsheet title when creating new.")
    inputs: CoverageInputsModel


class CoverageValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)


class CoverageResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: CoverageValidationModel
    explanation: Optional[str] = None


# ---------------------------------------------------------------------------
# SDR Efficiency Diagnostic Models
# ---------------------------------------------------------------------------


class SDRInputsModel(BaseModel):
    sdr_count: int = Field(5, ge=1, description="Number of SDRs.")
    meetings_per_sdr_month: int = Field(12, ge=1, le=50, description="Meetings per SDR per month.")
    meeting_to_opp_rate: float = Field(0.40, ge=0.05, le=1.0, description="Meeting to opportunity rate.")
    opp_to_win_rate: float = Field(0.25, ge=0.05, le=0.80, description="Opportunity to win rate.")
    avg_deal_size: float = Field(100000, description="Average deal size.")
    sdr_cost_month: float = Field(8000, description="SDR cost per month.")
    sales_cycle_months: int = Field(3, ge=1, le=12, description="Sales cycle in months.")
    ae_capacity_opps_month: Optional[int] = Field(None, ge=1, description="AE capacity in opps per month.")
    sensitivity_range: float = Field(0.20, ge=0.05, le=0.50, description="Sensitivity range.")
    sensitivity_step: float = Field(0.10, ge=0.05, le=0.20, description="Sensitivity step.")
    enable_plus_one_sdr: bool = Field(True, description="Enable +1 SDR analysis.")
    enable_conversion_improvement: bool = Field(True, description="Enable conversion improvement analysis.")
    first_fy: int = Field(26, ge=20, le=99, description="First fiscal year number (auto-derived when omitted).")
    fy_start_month: int = Field(1, ge=1, le=12, description="Fiscal year start month.")
    planning_years: int = Field(1, ge=1, le=3, description="Number of fiscal years to plan (1–3).")
    enable_fy27: bool = Field(False, description="(Legacy) Enable FY27. Use planning_years instead.")
    yoy_growth_pct: float = Field(0.0, ge=-0.5, le=2.0, description="Year-over-year growth percentage.")
    as_of_date: Optional[str] = Field(None, description="As-of date for deterministic testing.")
    # Idempotency handling
    tab_suffix: str = Field("", description="Suffix to append to tab names (e.g., '_v2').")
    idempotency_action: str = Field("", description="Idempotency action: 'replace' or 'suffix'.")

    @root_validator(pre=True)
    def _normalize_planning(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        values = _normalize_idempotency_fields(values)
        if "first_fy" not in values or values.get("first_fy") in (None, ""):
            values["first_fy"] = _derive_first_fy(values.get("fy_start_month", 1), values.get("as_of_date"))
        if "planning_years" not in values and values.get("enable_fy27"):
            values["planning_years"] = 2
        return values


class SDRRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(None, description="Spreadsheet ID to write into. Omit to create new.")
    title: Optional[str] = Field("SDR Efficiency Diagnostic", description="Spreadsheet title when creating new.")
    inputs: SDRInputsModel


class SDRValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)


class SDRResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: SDRValidationModel
    explanation: Optional[str] = None


# ---------------------------------------------------------------------------
# Market Finder Models
# ---------------------------------------------------------------------------


class MarketFinderInputsModel(BaseModel):
    customer_filter_field: str
    customer_filter_values: List[str]
    customer_filter_field_type: str = Field("picklist", description="picklist, boolean, text")
    prospect_filter_field: Optional[str] = None
    prospect_filter_values: Optional[List[str]] = None
    prospect_filter_field_type: str = Field("picklist", description="picklist, boolean, text")
    prospect_exclude_customers: bool = True
    geo_key_type: str = Field("city_state", description="city_state, zip, state, country")
    city_field: str = "BillingCity"
    state_field: str = "BillingState"
    zip_field: str = "BillingPostalCode"
    country_field: str = "BillingCountry"
    weight_customer: float = Field(0.40, ge=0, le=1)
    weight_prospect: float = Field(0.40, ge=0, le=1)
    weight_overlap: float = Field(0.20, ge=0, le=1)
    arr_field: Optional[str] = None
    pipeline_field: Optional[str] = None
    contact_email_field: Optional[str] = "Email"
    contact_phone_field: Optional[str] = "Phone"
    contact_title_field: Optional[str] = "Title"
    max_contacts_per_market: int = Field(10, ge=1)
    win_rate_pct: float = Field(25, ge=0)
    event_cost_per_attendee: float = Field(150, ge=0)
    event_expected_attendance: int = Field(30, ge=1)
    event_meeting_conversion: float = Field(0.40, ge=0)
    event_opportunity_conversion: float = Field(0.25, ge=0)
    top_n_markets: int = Field(25, ge=1)
    enable_drilldown: bool = True
    min_accounts: int = Field(3, ge=1)
    # Idempotency handling
    tab_suffix: str = Field("", description="Suffix to append to tab names (e.g., '_v2').")
    idempotency_action: str = Field("", description="Idempotency action: 'replace' or 'suffix'.")

    @root_validator(pre=True)
    def _normalize_idempotency(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        return _normalize_idempotency_fields(values)

    @root_validator(skip_on_failure=True)
    def _validate_weights(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        total = (
            float(values.get("weight_customer") or 0)
            + float(values.get("weight_prospect") or 0)
            + float(values.get("weight_overlap") or 0)
        )
        if abs(total - 1.0) > 0.01:
            raise ValueError("Weights must sum to 1.0.")
        geo_key = (values.get("geo_key_type") or "city_state").strip().lower()
        if geo_key not in {"city_state", "zip", "state", "country"}:
            raise ValueError("geo_key_type must be one of city_state, zip, state, country.")
        return values


class MarketFinderRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(None, description="Spreadsheet ID to write into. Omit to create new.")
    title: Optional[str] = Field("Field Marketing Market Finder", description="Spreadsheet title when creating new.")
    inputs: MarketFinderInputsModel


class MarketFinderValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)


class MarketFinderResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: MarketFinderValidationModel
    summary: Dict[str, Any] = Field(default_factory=dict)


# ---------------------------------------------------------------------------
# Partner Overlap Models
# ---------------------------------------------------------------------------


class PartnerOverlapInputsModel(BaseModel):
    partner_spreadsheet_id: str
    partner_sheet_tab: str
    partner_range_a1: str
    partner_header_row: int = Field(1, ge=1)
    partner_company_col: str
    partner_domain_col: Optional[str] = None
    partner_segment_col: Optional[str] = None
    our_customer_field: str
    our_customer_values: List[str]
    our_customer_field_type: str = Field("picklist", description="picklist, boolean, text")
    our_prospect_field: Optional[str] = None
    our_prospect_values: Optional[List[str]] = None
    our_prospect_field_type: str = Field("picklist", description="picklist, boolean, text")
    match_domain_first: bool = True
    match_fuzzy_name: bool = True
    fuzzy_threshold: float = Field(0.80, ge=0.5, le=1.0)
    crm_domain_field: str = "Website"
    enable_unmatched_tab: bool = True
    crm_fields: List[str] = Field(default_factory=lambda: ["Id", "Name", "Website", "Industry"])
    # Idempotency handling
    tab_suffix: str = Field("", description="Suffix to append to tab names (e.g., '_v2').")
    idempotency_action: str = Field("", description="Idempotency action: 'replace' or 'suffix'.")

    @root_validator(pre=True)
    def _normalize_idempotency(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        return _normalize_idempotency_fields(values)


class PartnerOverlapRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(None, description="Spreadsheet ID to write into. Omit to create new.")
    title: Optional[str] = Field("Ecosystem Partner Overlap Finder", description="Spreadsheet title when creating new.")
    inputs: PartnerOverlapInputsModel


class PartnerOverlapValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)


class PartnerOverlapResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: PartnerOverlapValidationModel
    summary: Dict[str, Any] = Field(default_factory=dict)


# ---------------------------------------------------------------------------
# Territory Planner Models
# ---------------------------------------------------------------------------


class TerritoryPlannerInputsModel(BaseModel):
    rep_spreadsheet_id: str
    rep_sheet_tab: str
    rep_range_a1: str
    rep_header_row: int = Field(1, ge=1)
    rep_name_col: str
    rep_id_col: Optional[str] = None
    rep_location_col: Optional[str] = None
    prospect_filter_field: str
    prospect_filter_values: List[str]
    prospect_filter_field_type: str = Field("picklist", description="picklist, boolean, text")
    segment_field: Optional[str] = None
    segment_values: Optional[List[str]] = None
    segment_field_type: str = Field("picklist", description="picklist, boolean, text")
    icp_filter_field: Optional[str] = Field(None, description="Field that identifies ICP accounts")
    icp_filter_values: Optional[List[str]] = Field(None, description="Values to filter for ICP")
    icp_filter_field_type: str = Field("boolean", description="boolean, picklist, text")
    ultimate_parent_only: bool = Field(False, description="Only include ultimate parent accounts")
    parent_account_field: Optional[str] = Field(None, description="Field containing parent account ID")
    use_geo_proximity: bool = Field(True, description="Enable distance-weighted assignment")
    geo_weight: float = Field(0.7, ge=0.0, le=1.0)
    geo_key_type: str = Field("auto", description="auto, state, city_state, zip")
    city_field: str = "BillingCity"
    state_field: str = "BillingState"
    zip_field: str = "BillingPostalCode"
    assignment_mode: str = Field("territory", description="territory, named_account")
    balance_by: str = Field("count", description="count, arr, pipeline")
    arr_field: Optional[str] = None
    pipeline_field: Optional[str] = None
    # Idempotency handling
    tab_suffix: str = Field("", description="Suffix to append to tab names (e.g., '_v2').")
    idempotency_action: str = Field("", description="Idempotency action: 'replace' or 'suffix'.")

    @root_validator(pre=True)
    def _normalize_idempotency(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        return _normalize_idempotency_fields(values)

    @root_validator(skip_on_failure=True)
    def _validate_inputs(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        assignment_mode = str(values.get("assignment_mode") or "territory").strip().lower()
        if assignment_mode not in {"territory", "named_account"}:
            raise ValueError("assignment_mode must be territory or named_account.")
        balance_by = str(values.get("balance_by") or "count").strip().lower()
        if balance_by not in {"count", "arr", "pipeline"}:
            raise ValueError("balance_by must be count, arr, or pipeline.")
        geo_key_type = str(values.get("geo_key_type") or "auto").strip().lower()
        if geo_key_type not in {"auto", "state", "city_state", "zip"}:
            raise ValueError("geo_key_type must be auto, state, city_state, or zip.")
        prospect_field = str(values.get("prospect_filter_field") or "").strip()
        if not prospect_field:
            raise ValueError("Prospect filter field is required.")
        prospect_values = values.get("prospect_filter_values") or []
        if not prospect_values:
            raise ValueError("Prospect filter values are required.")
        segment_field = str(values.get("segment_field") or "").strip()
        segment_values = values.get("segment_values") or []
        if segment_field and not segment_values:
            raise ValueError("Segment values are required when segment field is provided.")
        icp_field = str(values.get("icp_filter_field") or "").strip()
        icp_values = values.get("icp_filter_values") or []
        if icp_field and not icp_values:
            raise ValueError("ICP filter values required when ICP filter field is provided.")
        if values.get("ultimate_parent_only"):
            parent_field = str(values.get("parent_account_field") or "").strip()
            if not parent_field:
                raise ValueError("parent_account_field required when ultimate_parent_only is enabled.")
        return values


class TerritoryPlannerRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(None, description="Spreadsheet ID to write into. Omit to create new.")
    title: Optional[str] = Field("Territory Planner", description="Spreadsheet title when creating new.")
    inputs: TerritoryPlannerInputsModel


class TerritoryPlannerValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)


class TerritoryPlannerResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: TerritoryPlannerValidationModel
    summary: Dict[str, Any] = Field(default_factory=dict)


# ---------------------------------------------------------------------------
# GRR Model Builder Models
# ---------------------------------------------------------------------------


class GRRModelInputsModel(BaseModel):
    # ARR Source
    arr_source: Literal["opportunity", "opportunity_line_item"]
    arr_field: str

    # Multi-currency (optional)
    multi_currency_strategy: Optional[
        Literal["corporate_converted", "normalized_field", "single_currency_filter"]
    ] = None
    converted_amount_field: Optional[str] = None
    currency_filter_value: Optional[str] = None

    # Service Terms
    date_source_mode: Literal["opportunity", "line_item", "hybrid"] = "hybrid"
    start_date_field: str = "CloseDate"
    end_date_field: Optional[str] = None
    fallback_term_mode: Literal["field", "constant"] = "constant"
    fallback_term_field: Optional[str] = None
    fallback_term_months: int = Field(12, ge=1, le=120)

    # Churn Policy
    continuity_policy: Literal["strict", "operational", "hybrid"] = "hybrid"
    gap_tolerance_days: int = Field(30, ge=0, le=365)

    # Extraction & Acquisition
    extraction_stage_values: List[str] = Field(default_factory=lambda: ["Closed Won"])
    acquisition_type_field: Optional[str] = "Type"
    acquisition_type_values: List[str] = Field(default_factory=lambda: ["New Business"])
    enable_fallback_cohort: bool = True
    exclusion_field: Optional[str] = None
    exclusion_values: List[str] = Field(default_factory=list)

    # Idempotency
    tab_suffix: str = ""
    idempotency_action: Literal["", "replace", "suffix"] = ""

    @root_validator(pre=True)
    def _normalize_idempotency(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        return _normalize_idempotency_fields(values)

    @root_validator(skip_on_failure=True)
    def _validate_multi_currency(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        strategy = values.get("multi_currency_strategy")
        if strategy == "corporate_converted" and not values.get("converted_amount_field"):
            raise ValueError(
                "converted_amount_field required when using corporate_converted strategy"
            )
        if not values.get("extraction_stage_values"):
            raise ValueError("extraction_stage_values cannot be empty")
        return values


class GRRModelRequest(BaseModel):
    spreadsheet_id: Optional[str] = Field(
        None, description="Spreadsheet ID to write into. Omit to create new."
    )
    title: Optional[str] = Field("GRR Model", description="Spreadsheet title when creating new.")
    inputs: GRRModelInputsModel


class GRRDataQualityModel(BaseModel):
    total_accounts: int
    total_records: int
    pct_fallback_term_used: float
    pct_cohort_inferred: float
    pct_gap_smoothed: float
    pct_multi_product: float
    pct_missing_arr: float
    pct_missing_dates: float
    invalid_date_range_count: int
    zero_baseline_arr_count: int
    reactivation_count: int


class GRRValidationModel(BaseModel):
    status: str
    errors: List[str] = Field(default_factory=list)
    warnings: List[str] = Field(default_factory=list)


class GRRSummaryModel(BaseModel):
    total_cohort_arr: float
    latest_month: str
    grr_m3: Optional[float] = None
    grr_m6: Optional[float] = None
    grr_m12: Optional[float] = None
    logo_retention_m12: Optional[float] = None


class GRRModelResponse(BaseModel):
    spreadsheet_id: str
    url: str
    tabs_created: List[str]
    created: bool = False
    validation: GRRValidationModel
    data_quality: GRRDataQualityModel
    summary: GRRSummaryModel


STYLE_PRESETS: Dict[str, Dict[str, Any]] = {
    "professional": {
        "header_rows": 1,
        "header_bold": True,
        "header_fill": "#0f172a",
        "header_font_color": "#ffffff",
        "stripe": True,
        "stripe_color": "#f5f7fb",
        "font_size": 11,
        "font_color": None,
        "row_limit": 200,
        "column_limit": 26,
    }
}

PROFILE_CACHE_TTL_SECONDS = 3600
PROFILE_JOB_TTL_SECONDS = 3600


async def get_sheets_service_from_token(
    x_sheets_token: Optional[str] = Header(None, alias="X-Sheets-Token"),
):
    """
    Build an ephemeral Sheets client from the delegated OAuth token header.
    Returns None if no token provided (allows client-side data mode).
    """
    token = (x_sheets_token or "").strip()
    if not token:
        return None  # Client-side data mode - no Sheets API needed
    try:
        return google_client.build_sheets_service_from_token(token)
    except Exception as exc:  # pragma: no cover - defensive
        logger.warning("Failed to build Sheets client: %s", exc)
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid X-Sheets-Token") from exc


def _profile_cache_key(
    spreadsheet_id: str,
    sheet_name: str,
    content_hash: Optional[str],
    revision: str,
) -> str:
    safe_sheet = sheet_name.replace(" ", "_")
    if content_hash:
        suffix = f"h:{content_hash}"
    else:
        suffix = f"r:{revision}"
    return f"gremlin:profile:{spreadsheet_id}:{safe_sheet}:{suffix}"


def _profile_job_key(job_id: str) -> str:
    return f"gremlin:profile:job:{job_id}"


# Server-side payload limits (defense in depth - client also clamps)
MAX_PAYLOAD_ROWS = 2500  # Slightly above client limit to allow wiggle room
MAX_PAYLOAD_COLS = 100


def _has_client_data(payload: ScanRequest) -> bool:
    """Check if payload contains client-side sheet data."""
    return payload.values is not None and len(payload.values) > 0


def _validate_payload_size(values: List[List[Any]]) -> None:
    """Reject payloads that exceed size limits."""
    if len(values) > MAX_PAYLOAD_ROWS:
        raise HTTPException(
            status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
            detail=f"Sheet payload too large: {len(values)} rows exceeds limit of {MAX_PAYLOAD_ROWS}"
        )
    if values and len(values[0]) > MAX_PAYLOAD_COLS:
        raise HTTPException(
            status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
            detail=f"Sheet payload too wide: {len(values[0])} columns exceeds limit of {MAX_PAYLOAD_COLS}"
        )


def _normalize_client_data(payload: ScanRequest) -> tuple[List[List[Any]], List[List[Any]], List[str], str]:
    """
    Normalize client-side data to ensure downstream code has consistent inputs.
    Returns (values_grid, formulas_grid, sheet_titles, sheet_name).
    """
    values = payload.values or []
    if not values:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No sheet data provided in values field"
        )

    # Server-side size validation (defense in depth)
    _validate_payload_size(values)

    # Formulas: if missing, create empty grid matching values dimensions
    formulas = payload.formulas
    if formulas is None:
        formulas = [[""] * len(row) for row in values]

    # Sheet titles: if missing, use sheet_name or default
    sheet_name = payload.sheet_name or "Sheet1"
    sheet_titles = payload.sheet_titles or [sheet_name]

    # Ensure sheet_name is in sheet_titles for cross-ref validation
    if sheet_name not in sheet_titles:
        sheet_titles = [sheet_name] + sheet_titles

    return values, formulas, sheet_titles, sheet_name


@router.post("/scan", response_model=ScanResponse)
async def scan_spreadsheet(
    payload: ScanRequest,
    request: Request,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Scan a sheet range for lint issues, repair signals, and pattern anomalies.

    Supports two modes:
    1. Client-side data: values/formulas/sheet_titles sent in payload (preferred)
    2. Sheets API: X-Sheets-Token header used to fetch data from Google
    """
    start_time = time.perf_counter()
    row_count: Optional[int] = None
    sheet_name: Optional[str] = None
    error_code: Optional[str] = None
    try:
        # Determine data source: client-side or Sheets API
        use_client_data = _has_client_data(payload)

        if use_client_data:
            # Client-side data mode - normalize and use payload directly
            values_grid, formulas_grid, sheet_titles, sheet_name = _normalize_client_data(payload)
            logger.info("[SCAN] Using client-side data mode (values=%d rows, formulas=%d rows)",
                        len(values_grid), len(formulas_grid))
            resolved_range = f"{sheet_name}!A1:Z{len(values_grid)}"
        else:
            # Sheets API mode - requires X-Sheets-Token
            if service is None:
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail="Either X-Sheets-Token header or values/formulas payload is required"
                )
            logger.info("[SCAN] Using Sheets API mode")
            sheet_titles = _list_sheet_titles(service, payload.spreadsheet_id)
            sheet_name, resolved_range = _resolve_range(payload, sheet_titles)
            values_grid, formulas_grid = _fetch_sheet_grids(
                service, payload.spreadsheet_id, resolved_range
            )

        row_count = len(values_grid or [])
        profile = compute_sheet_profile(
            service,
            payload.spreadsheet_id,
            resolved_range,
            values_grid=values_grid,
            formulas_grid=formulas_grid,
        )

        lint_issues = _collect_lint(profile, sheet_titles)
        lint_report = lint.generate_lint_report(lint_issues)

        cells = repair.cells_from_grids(resolved_range, values_grid, formulas_grid)
        plan = repair.analyze_sheet(cells, sheet_titles)

        issues: List[IssueModel] = []
        issues.extend(_issues_from_repair(plan.issues))
        issues.extend(_issues_from_lint(lint_issues))

        # Recalculate health score including ALL issues (repair + lint)
        error_count = sum(1 for i in issues if i.severity == "error")
        warning_count = sum(1 for i in issues if i.severity == "warning")
        adjusted_score = _calculate_combined_health_score(
            base_score=plan.health_score,
            repair_errors=len([i for i in plan.issues if i.severity == "error"]),
            total_errors=error_count,
            total_warnings=warning_count,
        )

        health = HealthModel(
            score=adjusted_score,
            grade=repair.health_grade(adjusted_score),
            total_issues=len(issues),
            errors=error_count,
            warnings=warning_count,
            fixable=sum(1 for i in issues if i.fixable),
        )

        pattern_anomalies = _pattern_anomalies(plan.pattern_anomalies)
        missing_sheets = [_missing_sheet(ms) for ms in plan.missing_sheets]

        # Keep lint summary handy for future augmentation/debugging
        if lint_report.get("lint_results", {}).get("summary"):
            logger.debug("Gremlin lint summary: %s", lint_report["lint_results"]["summary"])

        ai_summary: Optional[str] = None
        tier = await get_gremlin_tier(account_id, db)
        if tier != "free":
            try:
                gremlin_ai = GremlinAI(tier)
                structure_for_ai = _structure_context_for_ai(
                    health,
                    issues,
                    profile,
                    values_grid,
                    formulas_grid,
                )
                lint_results = lint_report.get("lint_results") or {}
                ai_summary = await gremlin_ai.summarize_scan(
                    structure_for_ai,
                    lint_results,
                    account_id,
                    db,
                )
            except Exception:
                logger.exception("Gremlin AI summary failed")
                ai_summary = None

        return ScanResponse(
            spreadsheet_id=payload.spreadsheet_id,
            sheet_name=sheet_name,
            health=health,
            issues=issues,
            pattern_anomalies=pattern_anomalies,
            missing_sheets=missing_sheets,
            ai_summary=ai_summary,
        )
    except HttpError as err:
        error_code = f"HTTP_{getattr(getattr(err, 'resp', None), 'status', 500) or 500}"
        raise _http_error(err, "Unable to scan sheet") from err
    except HTTPException as exc:
        error_code = _extract_error_code(exc.detail) or f"HTTP_{exc.status_code}"
        raise
    except Exception:
        error_code = "INTERNAL_ERROR"
        logger.exception("Gremlin scan failed for account %s", account_id)
        return _internal_error_response(request)
    finally:
        duration_ms = int((time.perf_counter() - start_time) * 1000)
        _log_sheets_event(
            feature="scan",
            account_id=account_id,
            spreadsheet_id=payload.spreadsheet_id,
            sheet_name=sheet_name or payload.sheet_name,
            row_count=row_count,
            duration_ms=duration_ms,
            status="success" if error_code is None else "error",
            error=error_code,
            request_id=_request_id(request),
        )


def _has_fix_plan_client_data(payload: FixPlanRequest) -> bool:
    """Check if fix plan request contains client-side sheet data."""
    return payload.values is not None and len(payload.values) > 0


def _normalize_fix_plan_data(payload: FixPlanRequest) -> tuple[List[List[Any]], List[List[Any]], List[str], str]:
    """Normalize client-side data for fix plan. Returns (values, formulas, sheet_titles, sheet_name)."""
    values = payload.values or []
    if not values:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No sheet data provided in values field"
        )
    _validate_payload_size(values)

    formulas = payload.formulas
    if formulas is None:
        formulas = [[""] * len(row) for row in values]

    sheet_name = payload.sheet_name or "Sheet1"
    sheet_titles = payload.sheet_titles or [sheet_name]
    if sheet_name not in sheet_titles:
        sheet_titles = [sheet_name] + sheet_titles

    return values, formulas, sheet_titles, sheet_name


@router.post("/fix/plan", response_model=FixPlanResponse)
async def plan_fixes(
    payload: FixPlanRequest,
    request: Request,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Generate a fix plan so the user can choose categories before applying fixes.

    Supports two modes:
    1. Client-side data: values/formulas/sheet_titles sent in payload (preferred)
    2. Sheets API: X-Sheets-Token header used to fetch data from Google
    """
    start_time = time.perf_counter()
    error_code: Optional[str] = None
    row_count: Optional[int] = None
    sheet_name: Optional[str] = None
    try:
        tier = await get_gremlin_tier(account_id, db)
        if tier == "free":
            upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
            raise HTTPException(
                status_code=status.HTTP_402_PAYMENT_REQUIRED,
                detail={
                    "error": "UPGRADE_REQUIRED",
                    "message": "Gremlin auto-fix requires a paid tier.",
                    "upgrade_url": upgrade_url,
                },
            )

        use_client_data = _has_fix_plan_client_data(payload)

        if use_client_data:
            # Client-side data mode
            values_grid, formulas_grid, sheet_titles, sheet_name = _normalize_fix_plan_data(payload)
            logger.info("[FIX_PLAN] Using client-side data mode (values=%d rows)", len(values_grid))
            resolved_range = f"{sheet_name}!A1:Z{len(values_grid)}"
        else:
            # Sheets API mode - requires X-Sheets-Token
            if service is None:
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail="Either X-Sheets-Token header or values/formulas payload is required"
                )
            logger.info("[FIX_PLAN] Using Sheets API mode")
            sheet_titles = _list_sheet_titles(service, payload.spreadsheet_id)
            scan_payload = ScanRequest(
                spreadsheet_id=payload.spreadsheet_id,
                sheet_name=payload.sheet_name,
                range=payload.range,
            )
            sheet_name, resolved_range = _resolve_range(scan_payload, sheet_titles)
            values_grid, formulas_grid = _fetch_sheet_grids(
                service, payload.spreadsheet_id, resolved_range
            )

        row_count = len(values_grid or [])
        profile = compute_sheet_profile(
            service,
            payload.spreadsheet_id,
            resolved_range,
            values_grid=values_grid,
            formulas_grid=formulas_grid,
        )

        lint_issues = _collect_lint(profile, sheet_titles)
        lint_report = lint.generate_lint_report(lint_issues)
        cells = repair.cells_from_grids(resolved_range, values_grid, formulas_grid)
        plan = repair.analyze_sheet(cells, sheet_titles)

        issues: List[IssueModel] = []
        issues.extend(_issues_from_repair(plan.issues))
        issues.extend(_issues_from_lint(lint_issues))
        missing_sheets = [_missing_sheet(ms) for ms in plan.missing_sheets]

        counts = _categorize_fix_plan(issues, missing_sheets)
        total_issues = counts["formula_issues"] + counts["style_issues"] + counts["data_quality_issues"]
        summary = FixPlanSummary(
            total_issues=total_issues,
            formula_issues=counts["formula_issues"],
            style_issues=counts["style_issues"],
            data_quality_issues=counts["data_quality_issues"],
        )
        categories = _build_fix_plan_categories(counts)

        if lint_report.get("lint_results", {}).get("summary"):
            logger.debug("Gremlin lint summary: %s", lint_report["lint_results"]["summary"])

        return FixPlanResponse(
            spreadsheet_id=payload.spreadsheet_id,
            sheet_name=sheet_name,
            summary=summary,
            categories=categories,
        )
    except HttpError as err:
        error_code = f"HTTP_{getattr(getattr(err, 'resp', None), 'status', 500) or 500}"
        raise _http_error(err, "Unable to plan fixes") from err
    except HTTPException as exc:
        error_code = _extract_error_code(exc.detail) or f"HTTP_{exc.status_code}"
        raise
    except Exception:
        error_code = "INTERNAL_ERROR"
        logger.exception("Gremlin fix plan failed for account %s", account_id)
        return _internal_error_response(request)
    finally:
        duration_ms = int((time.perf_counter() - start_time) * 1000)
        _log_sheets_event(
            feature="fix_plan",
            account_id=account_id,
            spreadsheet_id=payload.spreadsheet_id,
            sheet_name=sheet_name or payload.sheet_name,
            row_count=row_count,
            duration_ms=duration_ms,
            status="success" if error_code is None else "error",
            error=error_code,
            request_id=_request_id(request),
        )


@router.post("/fix", response_model=FixResponse)
async def apply_fixes(
    payload: FixRequest,
    request: Request,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Apply safe lint fixes (currently unquoted sheet references).
    """
    start_time = time.perf_counter()
    error_code: Optional[str] = None
    row_count = len(payload.fixes or [])
    try:
        tier = await get_gremlin_tier(account_id, db)
        if tier == "free":
            upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
            raise HTTPException(
                status_code=status.HTTP_402_PAYMENT_REQUIRED,
                detail={
                    "error": "UPGRADE_REQUIRED",
                    "message": "Gremlin auto-fix requires a paid tier.",
                    "upgrade_url": upgrade_url,
                },
            )

        if not payload.fixes:
            return FixResponse(applied=0, skipped=0, errors=[], message="No fixes provided.")

        categories = [c for c in (payload.categories or ["formula_issues"]) if c]
        if not categories:
            categories = ["formula_issues"]
        selected = set(categories)
        if "formula_issues" not in selected:
            return FixResponse(
                applied=0,
                skipped=len(payload.fixes),
                errors=["No supported categories selected."],
            )

        allowed, skipped_reasons = _filter_safe_fixes(service, payload.spreadsheet_id, payload.fixes)
        skipped_count = len(skipped_reasons) + len(payload.fixes) - len(allowed)
        if payload.dry_run:
            return FixResponse(applied=0, skipped=skipped_count, errors=skipped_reasons)

        if not allowed:
            return FixResponse(
                applied=0,
                skipped=0,
                errors=[],
                message="No fixable issues found in the selected categories.",
            )

        try:
            applied = sheets_io.apply_lint_fixes(
                payload.spreadsheet_id,
                [{"cell": f.cell, "fixed": f.fixed} for f in allowed],
                service=service,
            )
        except HttpError as err:
            raise _http_error(err, "Failed to apply fixes") from err

        skipped = len(payload.fixes) - applied
        return FixResponse(applied=applied, skipped=skipped, errors=skipped_reasons)
    except HttpError as err:
        error_code = f"HTTP_{getattr(getattr(err, 'resp', None), 'status', 500) or 500}"
        raise _http_error(err, "Failed to apply fixes") from err
    except HTTPException as exc:
        error_code = _extract_error_code(exc.detail) or f"HTTP_{exc.status_code}"
        raise
    except Exception:
        error_code = "INTERNAL_ERROR"
        logger.exception("Gremlin apply fixes failed for account %s", account_id)
        return _internal_error_response(request)
    finally:
        duration_ms = int((time.perf_counter() - start_time) * 1000)
        _log_sheets_event(
            feature="fix_apply",
            account_id=account_id,
            spreadsheet_id=payload.spreadsheet_id,
            sheet_name=payload.sheet_name,
            row_count=row_count,
            duration_ms=duration_ms,
            status="success" if error_code is None else "error",
            error=error_code,
            request_id=_request_id(request),
        )


@router.post("/style", response_model=StyleResponse)
async def apply_style(
    payload: StyleRequest,
    service=Depends(get_sheets_service_from_token),
    account_id: str = Depends(require_account),
):
    """
    Apply a simple style preset to a sheet range.
    """
    del account_id

    preset = STYLE_PRESETS.get(payload.style_preset)
    if not preset:
        raise HTTPException(status_code=400, detail=f"Unknown style preset '{payload.style_preset}'")

    try:
        changes = _apply_style_preset(
            service=service,
            spreadsheet_id=payload.spreadsheet_id,
            sheet_name=payload.sheet_name,
            preset=preset,
        )
    except HttpError as err:
        raise _http_error(err, "Failed to apply style") from err

    return StyleResponse(applied=bool(changes), changes=changes)


@router.post("/style/interpret", response_model=StyleInterpretResponse)
async def interpret_style(
    payload: StyleInterpretRequest,
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Interpret a natural language style description into structured style parameters.
    Requires a paid tier (Pro/Scale/Unleashed).
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "AI style interpretation requires a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    try:
        gremlin_ai = GremlinAI(tier)
        style_params = await gremlin_ai.interpret_style(
            style_description=payload.style_description,
            account_id=account_id,
            db=db,
        )
    except Exception:
        logger.exception("AI style interpretation failed for account %s", account_id)
        return StyleInterpretResponse(
            success=False,
            error="AI interpretation failed. Please try again.",
        )

    if not style_params:
        return StyleInterpretResponse(
            success=False,
            error="Could not interpret style description. Try being more specific.",
        )

    # Merge with professional defaults for any missing fields
    base_preset = dict(STYLE_PRESETS["professional"])
    base_preset.update(style_params)

    return StyleInterpretResponse(
        success=True,
        style=base_preset,
    )


@router.post("/explain", response_model=ExplainResponse)
async def explain_formula_issue(
    payload: ExplainRequest,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Explain a formula for paid tiers using Vertex AI.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "Formula explanations require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    # Use formula from payload (client-side), or fetch via Sheets API as fallback
    formula = payload.formula
    if not formula and service is not None:
        try:
            formula = _fetch_formula(service, payload.spreadsheet_id, payload.cell)
        except HttpError as err:
            raise _http_error(err, "Unable to fetch formula") from err
        except Exception as exc:  # pragma: no cover - defensive
            logger.warning("Failed to fetch formula for %s: %s", payload.cell, exc)
            formula = None

    if not (formula and isinstance(formula, str)):
        return ExplainResponse(
            cell=payload.cell,
            formula=formula if isinstance(formula, str) else None,
            explanation=None,
            complexity=None,
            dependencies=[],
        )

    snippet = _structure_snippet_for_ai(payload.cell, formula)
    gremlin_ai = GremlinAI(tier)
    ai_result: Optional[Dict[str, Any]] = None
    try:
        ai_result = await gremlin_ai.explain_formula(
            formula=formula,
            cell=payload.cell,
            structure_snippet=snippet,
            account_id=account_id,
            db=db,
        )
    except Exception:
        logger.exception("Gremlin AI explain failed")
        ai_result = None

    if not ai_result:
        return ExplainResponse(
            cell=payload.cell,
            formula=formula,
            explanation=None,
            complexity=None,
            dependencies=[],
        )

    return ExplainResponse(
        cell=payload.cell,
        formula=formula,
        explanation=ai_result.get("explanation"),
        complexity=ai_result.get("complexity"),
        dependencies=ai_result.get("dependencies") or [],
    )


@router.post("/templates/waterfall", response_model=WaterfallResponse)
async def create_waterfall_template(
    payload: WaterfallRequest,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the B2B SaaS sales + marketing waterfall model.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "Waterfall templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    try:
        inputs = WaterfallInputs(**payload.inputs.dict())
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Invalid waterfall inputs: {exc}") from exc

    # Waterfall creation requires Sheets API access
    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Waterfall template creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = build_waterfall(
            inputs=inputs,
            sheets_service=service,
            spreadsheet_id=payload.spreadsheet_id,
            title=payload.title or "Sales & Marketing Waterfall",
        )
    except HttpError as err:
        raise _http_error(err, "Failed to build waterfall template") from err
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to build waterfall template: {exc}") from exc

    validation = result.get("validation") or {}
    status_val = validation.get("status") or "ok"
    explanation = _waterfall_explanation(inputs, result) if tier in ("pro", "scale", "unleashed") else f"Waterfall built. Validation: {status_val}"
    return WaterfallResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=WaterfallValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
        ),
        explanation=explanation,
    )


@router.post("/templates/capacity", response_model=CapacityResponse)
async def create_capacity_template(
    payload: CapacityRequest,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the Sales Capacity Stress Test model.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "Capacity templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    try:
        inputs = CapacityInputs(
            annual_target=payload.inputs.annual_target,
            pacing_q1=payload.inputs.pacing_q1,
            pacing_q2=payload.inputs.pacing_q2,
            pacing_q3=payload.inputs.pacing_q3,
            pacing_q4=payload.inputs.pacing_q4,
            avg_deal_size=payload.inputs.avg_deal_size,
            win_rate=payload.inputs.win_rate,
            starting_aes=payload.inputs.starting_aes,
            quarterly_attrition_rate=payload.inputs.quarterly_attrition_rate,
            net_hires_list=payload.inputs.net_hires,
            hire_month=payload.inputs.hire_month,
            ramp_months=payload.inputs.ramp_months,
            ramp_m1_2=payload.inputs.ramp_m1_2,
            ramp_m3_4=payload.inputs.ramp_m3_4,
            ramp_m5_6=payload.inputs.ramp_m5_6,
            ramp_full=payload.inputs.ramp_full,
            ramped_quota=payload.inputs.ramped_quota,
            seasonality_q1=payload.inputs.seasonality_q1,
            seasonality_q2=payload.inputs.seasonality_q2,
            seasonality_q3=payload.inputs.seasonality_q3,
            seasonality_q4=payload.inputs.seasonality_q4,
            first_fy=payload.inputs.first_fy,
            fy_start_month=payload.inputs.fy_start_month,
            planning_years=payload.inputs.planning_years,
            yoy_growth_pct=payload.inputs.yoy_growth_pct,
            as_of_date=payload.inputs.as_of_date or "",
            tab_suffix=payload.inputs.tab_suffix or "",
            idempotency_action=payload.inputs.idempotency_action or "",
        )
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Invalid capacity inputs: {exc}") from exc

    # Capacity creation requires Sheets API access
    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Capacity template creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = build_capacity_stress(
            inputs=inputs,
            sheets_service=service,
            spreadsheet_id=payload.spreadsheet_id,
            title=payload.title or "Sales Capacity Stress Test",
        )
    except HttpError as err:
        raise _http_error(err, "Failed to build capacity template") from err
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to build capacity template: {exc}") from exc

    validation = result.get("validation") or {}
    status_val = validation.get("status") or "ok"
    explanation = _capacity_explanation(inputs, result) if tier in ("pro", "scale", "unleashed") else f"Capacity model built. Validation: {status_val}"
    return CapacityResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=CapacityValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
        ),
        explanation=explanation,
    )


@router.post("/templates/coverage", response_model=CoverageResponse)
async def create_coverage_template(
    payload: CoverageRequest,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the Pipeline Coverage & Timing model.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "Coverage templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    try:
        inputs = CoverageInputs(
            annual_target=payload.inputs.annual_target,
            pacing_q1=payload.inputs.pacing_q1,
            pacing_q2=payload.inputs.pacing_q2,
            pacing_q3=payload.inputs.pacing_q3,
            pacing_q4=payload.inputs.pacing_q4,
            avg_deal_size=payload.inputs.avg_deal_size,
            coverage_target=payload.inputs.coverage_target,
            win_rate=payload.inputs.win_rate,
            sales_cycle_days=payload.inputs.sales_cycle_days,
            late_threshold_days=payload.inputs.late_threshold_days,
            use_stage_timing=payload.inputs.use_stage_timing,
            stage_1_name=payload.inputs.stage_1_name,
            stage_1_days=payload.inputs.stage_1_days,
            stage_2_name=payload.inputs.stage_2_name,
            stage_2_days=payload.inputs.stage_2_days,
            stage_3_name=payload.inputs.stage_3_name,
            stage_3_days=payload.inputs.stage_3_days,
            stage_4_name=payload.inputs.stage_4_name,
            stage_4_days=payload.inputs.stage_4_days,
            first_fy=payload.inputs.first_fy,
            fy_start_month=payload.inputs.fy_start_month,
            planning_years=payload.inputs.planning_years,
            yoy_growth_pct=payload.inputs.yoy_growth_pct,
            as_of_date=payload.inputs.as_of_date or "",
            tab_suffix=payload.inputs.tab_suffix or "",
            idempotency_action=payload.inputs.idempotency_action or "",
        )
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Invalid coverage inputs: {exc}") from exc

    # Coverage creation requires Sheets API access
    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Coverage template creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = build_pipeline_coverage(
            inputs=inputs,
            sheets_service=service,
            spreadsheet_id=payload.spreadsheet_id,
            title=payload.title or "Pipeline Coverage & Timing",
        )
    except HttpError as err:
        raise _http_error(err, "Failed to build coverage template") from err
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to build coverage template: {exc}") from exc

    validation = result.get("validation") or {}
    status_val = validation.get("status") or "ok"
    explanation = _coverage_explanation(inputs, result) if tier in ("pro", "scale", "unleashed") else f"Coverage model built. Validation: {status_val}"
    return CoverageResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=CoverageValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
        ),
        explanation=explanation,
    )


@router.post("/templates/sdr", response_model=SDRResponse)
async def create_sdr_template(
    payload: SDRRequest,
    service=Depends(get_sheets_service_from_token),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the SDR Efficiency Diagnostic model.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "SDR templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    try:
        inputs = SDRInputs(
            sdr_count=payload.inputs.sdr_count,
            meetings_per_sdr_month=payload.inputs.meetings_per_sdr_month,
            meeting_to_opp_rate=payload.inputs.meeting_to_opp_rate,
            opp_to_win_rate=payload.inputs.opp_to_win_rate,
            avg_deal_size=payload.inputs.avg_deal_size,
            sdr_cost_month=payload.inputs.sdr_cost_month,
            sales_cycle_months=payload.inputs.sales_cycle_months,
            ae_capacity_opps_month=payload.inputs.ae_capacity_opps_month,
            sensitivity_range=payload.inputs.sensitivity_range,
            sensitivity_step=payload.inputs.sensitivity_step,
            enable_plus_one_sdr=payload.inputs.enable_plus_one_sdr,
            enable_conversion_improvement=payload.inputs.enable_conversion_improvement,
            first_fy=payload.inputs.first_fy,
            fy_start_month=payload.inputs.fy_start_month,
            planning_years=payload.inputs.planning_years,
            yoy_growth_pct=payload.inputs.yoy_growth_pct,
            as_of_date=payload.inputs.as_of_date or "",
            tab_suffix=payload.inputs.tab_suffix or "",
            idempotency_action=payload.inputs.idempotency_action or "",
        )
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Invalid SDR inputs: {exc}") from exc

    # SDR creation requires Sheets API access
    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="SDR template creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = build_sdr_diagnostic(
            inputs=inputs,
            sheets_service=service,
            spreadsheet_id=payload.spreadsheet_id,
            title=payload.title or "SDR Efficiency Diagnostic",
        )
    except HttpError as err:
        raise _http_error(err, "Failed to build SDR template") from err
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to build SDR template: {exc}") from exc

    validation = result.get("validation") or {}
    status_val = validation.get("status") or "ok"
    explanation = _sdr_explanation(inputs, result) if tier in ("pro", "scale", "unleashed") else f"SDR diagnostic built. Validation: {status_val}"
    return SDRResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=SDRValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
        ),
        explanation=explanation,
    )


@router.post("/templates/market-finder", response_model=MarketFinderResponse)
async def create_market_finder_template(
    payload: MarketFinderRequest,
    service=Depends(get_sheets_service_from_token),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the Field Marketing Market Finder model.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "Market Finder templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Market Finder template creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = await build_market_finder(
            spreadsheet_id=payload.spreadsheet_id,
            inputs=payload.inputs,
            sheets_service=service,
            sf_client=sf,
            title=payload.title or "Field Marketing Market Finder",
        )
    except (SalesforceNotConfigured, SalesforceAuthError) as exc:
        raise HTTPException(status_code=400, detail="Please connect Salesforce via Settings.") from exc
    except SalesforceFieldAccessError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except SalesforceConnectionError as exc:
        raise HTTPException(status_code=503, detail="Salesforce connection unavailable.") from exc
    except HttpError as err:
        raise _http_error(err, "Failed to build market finder template") from err
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid market finder inputs: {exc}") from exc
    except Exception as exc:
        raise HTTPException(
            status_code=500, detail=f"Failed to build market finder template: {exc}"
        ) from exc

    validation = result.get("validation") or {}
    return MarketFinderResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=MarketFinderValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
        ),
        summary=result.get("summary") or {},
    )


@router.post("/templates/partner-overlap", response_model=PartnerOverlapResponse)
async def create_partner_overlap_template(
    payload: PartnerOverlapRequest,
    service=Depends(get_sheets_service_from_token),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the Ecosystem Partner Overlap Finder model.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "Partner Overlap templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Partner Overlap template creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = await build_partner_overlap(
            spreadsheet_id=payload.spreadsheet_id,
            inputs=payload.inputs,
            sheets_service=service,
            sf_client=sf,
            title=payload.title or "Ecosystem Partner Overlap Finder",
        )
    except (SalesforceNotConfigured, SalesforceAuthError) as exc:
        raise HTTPException(status_code=400, detail="Please connect Salesforce via Settings.") from exc
    except SalesforceFieldAccessError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except SalesforceConnectionError as exc:
        raise HTTPException(status_code=503, detail="Salesforce connection unavailable.") from exc
    except HttpError as err:
        raise _http_error(err, "Failed to build partner overlap template") from err
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid partner overlap inputs: {exc}") from exc
    except Exception as exc:
        raise HTTPException(
            status_code=500, detail=f"Failed to build partner overlap template: {exc}"
        ) from exc

    validation = result.get("validation") or {}
    return PartnerOverlapResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=PartnerOverlapValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
        ),
        summary=result.get("summary") or {},
    )


@router.post("/templates/territory-planner", response_model=TerritoryPlannerResponse)
async def create_territory_planner_template(
    payload: TerritoryPlannerRequest,
    service=Depends(get_sheets_service_from_token),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the Territory Planner model.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "Territory Planner templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Territory Planner template creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = await build_territory_planner(
            spreadsheet_id=payload.spreadsheet_id,
            inputs=payload.inputs,
            sheets_service=service,
            sf_client=sf,
            title=payload.title or "Territory Planner",
        )
    except (SalesforceNotConfigured, SalesforceAuthError) as exc:
        raise HTTPException(status_code=400, detail="Please connect Salesforce via Settings.") from exc
    except SalesforceFieldAccessError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except SalesforceConnectionError as exc:
        raise HTTPException(status_code=503, detail="Salesforce connection unavailable.") from exc
    except HttpError as err:
        raise _http_error(err, "Failed to build territory planner template") from err
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid territory planner inputs: {exc}") from exc
    except Exception as exc:
        raise HTTPException(
            status_code=500, detail=f"Failed to build territory planner template: {exc}"
        ) from exc

    validation = result.get("validation") or {}
    return TerritoryPlannerResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=TerritoryPlannerValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
        ),
        summary=result.get("summary") or {},
    )


@router.post("/templates/grr-model", response_model=GRRModelResponse)
async def create_grr_model_template(
    payload: GRRModelRequest,
    service=Depends(get_sheets_service_from_token),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),
):
    """
    Build the GRR Model workbook.
    """
    tier = await get_gremlin_tier(account_id, db)
    if tier == "free":
        upgrade_url = saas_settings.APP_URL or "https://foundrymatch.com/pricing"
        raise HTTPException(
            status_code=status.HTTP_402_PAYMENT_REQUIRED,
            detail={
                "error": "UPGRADE_REQUIRED",
                "message": "GRR Model templates require a paid tier.",
                "upgrade_url": upgrade_url,
            },
        )

    if service is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="GRR model creation requires X-Sheets-Token header for Sheets API access.",
        )

    try:
        result = await build_grr_model(
            spreadsheet_id=payload.spreadsheet_id,
            inputs=payload.inputs,
            sheets_service=service,
            sf_client=sf,
            title=payload.title or "GRR Model",
        )
    except (SalesforceNotConfigured, SalesforceAuthError) as exc:
        raise HTTPException(status_code=400, detail="Please connect Salesforce via Settings.") from exc
    except SalesforceFieldAccessError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except SalesforceConnectionError as exc:
        raise HTTPException(status_code=503, detail="Salesforce connection unavailable.") from exc
    except HttpError as err:
        raise _http_error(err, "Failed to build GRR model") from err
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=f"Invalid GRR inputs: {exc}") from exc
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Failed to build GRR model: {exc}") from exc

    validation = result.get("validation") or {}
    dq = result.get("data_quality") or {}
    summary = result.get("summary") or {}

    return GRRModelResponse(
        spreadsheet_id=result.get("spreadsheet_id") or "",
        url=result.get("url") or "",
        tabs_created=result.get("tabs_created") or [],
        created=bool(result.get("created")),
        validation=GRRValidationModel(
            status=str(validation.get("status") or "unknown"),
            errors=validation.get("errors") or [],
            warnings=validation.get("warnings") or [],
        ),
        data_quality=GRRDataQualityModel(
            total_accounts=int(dq.get("total_accounts") or 0),
            total_records=int(dq.get("total_records") or 0),
            pct_fallback_term_used=float(dq.get("pct_fallback_term_used") or 0),
            pct_cohort_inferred=float(dq.get("pct_cohort_inferred") or 0),
            pct_gap_smoothed=float(dq.get("pct_gap_smoothed") or 0),
            pct_multi_product=float(dq.get("pct_multi_product") or 0),
            pct_missing_arr=float(dq.get("pct_missing_arr") or 0),
            pct_missing_dates=float(dq.get("pct_missing_dates") or 0),
            invalid_date_range_count=int(dq.get("invalid_date_range_count") or 0),
            zero_baseline_arr_count=int(dq.get("zero_baseline_arr_count") or 0),
            reactivation_count=int(dq.get("reactivation_count") or 0),
        ),
        summary=GRRSummaryModel(
            total_cohort_arr=float(summary.get("total_cohort_arr") or 0),
            latest_month=str(summary.get("latest_month") or ""),
            grr_m3=summary.get("grr_m3"),
            grr_m6=summary.get("grr_m6"),
            grr_m12=summary.get("grr_m12"),
            logo_retention_m12=summary.get("logo_retention_m12"),
        ),
    )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _calculate_combined_health_score(
    base_score: int,
    repair_errors: int,
    total_errors: int,
    total_warnings: int,
) -> int:
    """
    Recalculate health score to include lint issues that weren't in the repair analysis.
    Uses diminishing returns so 1,500 errors doesn't look the same as 10 errors.

    Penalty curve (sqrt-based, capped at 80):
    - 1 error: -10 points → 90
    - 4 errors: -20 points → 80
    - 16 errors: -40 points → 60
    - 64 errors: -80 points → 20
    - 1500 errors: -80 points → 20 (capped)
    """
    import math

    # Lint errors not already counted in repair
    lint_errors = total_errors - repair_errors

    if lint_errors <= 0:
        return base_score

    # Diminishing returns: 10 * sqrt(errors), capped at 80
    lint_penalty = min(80, int(10 * math.sqrt(lint_errors)))

    score = base_score - lint_penalty
    return max(0, min(100, score))


def _request_id(request: Request | None) -> Optional[str]:
    try:
        return getattr(getattr(request, "state", None), "request_id", None)
    except Exception:  # pragma: no cover - defensive
        return None


def _internal_error_response(
    request: Request | None,
    message: str = "Gremlin hit an unexpected error. Please try again or contact support.",
) -> JSONResponse:
    content: Dict[str, Any] = {
        "error": "INTERNAL_ERROR",
        "message": message,
    }
    req_id = _request_id(request)
    if req_id:
        content["request_id"] = req_id
    return JSONResponse(status_code=500, content=content)


def _extract_error_code(detail: Any) -> Optional[str]:
    if isinstance(detail, dict):
        if isinstance(detail.get("error"), str):
            return str(detail["error"])
        if isinstance(detail.get("detail"), str):
            return str(detail["detail"])
    if isinstance(detail, str):
        return detail
    return None


def _log_sheets_event(
    *,
    feature: str,
    account_id: str,
    spreadsheet_id: Optional[str],
    sheet_name: Optional[str],
    row_count: Optional[int],
    duration_ms: int,
    status: str,
    error: Optional[str],
    request_id: Optional[str],
) -> None:
    payload: Dict[str, Any] = {
        "source": "sheets-addon",
        "feature": feature,
        "account_id": account_id,
        "spreadsheet_id": spreadsheet_id,
        "sheet_name": sheet_name,
        "duration_ms": duration_ms,
        "status": status,
    }
    if row_count is not None:
        payload["row_count"] = row_count
    if error:
        payload["error"] = error
    if request_id:
        payload["request_id"] = request_id
    try:
        emit_telemetry(f"gsheets.gremlin.{feature}", **payload)
    except Exception:  # pragma: no cover - telemetry is best-effort
        logger.debug("Telemetry emit failed for %s", feature, exc_info=True)
    try:
        logger.info("Sheets gremlin %s %s", feature, payload)
    except Exception:  # pragma: no cover - defensive
        logger.debug("Sheets gremlin log failed for %s", feature, exc_info=True)


def _structure_context_for_ai(
    health: HealthModel,
    issues: List[IssueModel],
    profile,
    values_grid: List[List[Any]],
    formulas_grid: List[List[Any]],
) -> Dict[str, Any]:
    counts = {
        "drivers": 0,
        "formulas": len(getattr(profile, "formula_cells", []) or []),
        "cross_sheet_refs": 0,
    }
    try:
        counts["cross_sheet_refs"] = _count_cross_sheet_refs(profile.formula_cells)
    except Exception:
        counts["cross_sheet_refs"] = 0
    try:
        structural = graph.compute_structural_profile(
            spreadsheet_id=getattr(profile, "spreadsheet_id", ""),
            a1_range=getattr(profile, "a1_range", ""),
            values_grid=values_grid,
            formulas_grid=formulas_grid,
        )
        summary = structural.get("summary") or {}
        counts["drivers"] = int(summary.get("drivers") or 0)
    except Exception:
        counts["drivers"] = 0

    return {
        "health": {
            "score": health.score,
            "errors": health.errors,
            "warnings": health.warnings,
            "fixable": health.fixable,
        },
        "counts": counts,
        "issues_summary": _issues_summary_for_ai(issues),
    }


def _issues_summary_for_ai(issues: List[IssueModel]) -> str:
    if not issues:
        return "No issues reported."
    severity_rank = {"error": 0, "warning": 1}
    trimmed = sorted(issues, key=lambda i: severity_rank.get(i.severity, 2))[:20]
    parts: List[str] = []
    for idx, issue in enumerate(trimmed, 1):
        message = (issue.message or issue.type or "issue").replace("\n", " ").strip()
        type_suffix = f" [{issue.type}]" if issue.type else ""
        parts.append(f"{idx}) {issue.cell} {message}{type_suffix}")
    return " ".join(parts)


def _count_cross_sheet_refs(formula_cells: List[Any]) -> int:
    total = 0
    for cell in formula_cells or []:
        formula_text = getattr(cell, "formula", None)
        if not (isinstance(formula_text, str) and formula_text.startswith("=")):
            continue
        refs = graph.parse_formula_references(formula_text)
        total += sum(1 for ref in refs if "!" in ref)
    return total


def _structure_snippet_for_ai(cell: str, formula: str) -> Dict[str, Any]:
    sheet_name, addr = _split_cell_reference(cell)
    try:
        classification = graph.classify_formula(formula or "")
    except Exception:
        classification = {}
    try:
        refs = graph.parse_formula_references(formula or "")
    except Exception:
        refs = []
    snippet: Dict[str, Any] = {
        "cell": cell,
        "sheet": sheet_name,
        "address": addr,
        "classification": classification,
        "cross_sheet_references": [ref for ref in refs if "!" in ref],
        "has_cross_sheet_references": any("!" in ref for ref in refs),
    }
    match = re.match(r"\$?([A-Za-z]+)\$?(\d+)", addr or cell)
    if match:
        snippet["column"] = match.group(1)
        snippet["row"] = int(match.group(2))
    if classification.get("offset_details"):
        snippet["offset_details"] = classification.get("offset_details")
    return snippet


def _split_cell_reference(cell: str) -> tuple[Optional[str], str]:
    if "!" in cell:
        sheet, addr = cell.split("!", 1)
        return sheet, addr
    return None, cell


def _fetch_formula(service, spreadsheet_id: str, cell: str) -> Optional[str]:
    resp = (
        service.spreadsheets()
        .values()
        .get(
            spreadsheetId=spreadsheet_id,
            range=cell,
            valueRenderOption="FORMULA",
            majorDimension="ROWS",
        )
        .execute()
    )
    values = resp.get("values", []) or []
    if values and values[0]:
        formula = values[0][0]
        return formula if isinstance(formula, str) else None
    return None


def _waterfall_explanation(inputs: WaterfallInputs, result: Dict[str, Any]) -> str:
    pacing_vals = inputs.monthly_pacing if inputs.granularity == "monthly" else inputs.quarterly_pacing or []
    pacing_text = ", ".join(f"{p * 100:.0f}%" for p in pacing_vals) if pacing_vals else "n/a"
    pacing_label = "monthly" if inputs.granularity == "monthly" else "quarterly"
    validation = result.get("validation") or {}
    status = validation.get("status") or "ok"
    first_fy = inputs.first_fy or 26
    second_fy = first_fy + 1
    return (
        f"Built target-driven waterfall for FY{first_fy}/FY{second_fy} with ${inputs.annual_target:,.0f} in net-new bookings "
        f"(NL {inputs.nl_attribution * 100:.1f}% / Upsell {inputs.upsell_attribution * 100:.1f}%, "
        f"{pacing_label} pacing [{pacing_text}], fiscal start month {inputs.fy_start_month}). Validation status: {status}."
    )


def _fy_range_label(first_fy: int, planning_years: int) -> str:
    """Build a human-readable FY range string, e.g. 'FY26', 'FY26/FY27', 'FY26/FY27/FY28'."""
    return "/".join(f"FY{first_fy + i}" for i in range(planning_years))


def _capacity_explanation(inputs: CapacityInputs, result: Dict[str, Any]) -> str:
    pacing_text = f"{inputs.pacing_q1*100:.0f}/{inputs.pacing_q2*100:.0f}/{inputs.pacing_q3*100:.0f}/{inputs.pacing_q4*100:.0f}%"
    validation = result.get("validation") or {}
    status = validation.get("status") or "ok"
    fy_range = _fy_range_label(inputs.first_fy, inputs.planning_years)
    total_hires = sum(inputs.net_hires)
    attrition_text = f"{inputs.quarterly_attrition_rate*100:.1f}% qtr attrition"
    return (
        f"Built capacity stress test for {fy_range} with ${inputs.annual_target:,.0f} annual target "
        f"(pacing [{pacing_text}], {inputs.starting_aes} starting AEs, {total_hires} net hires, {attrition_text}, "
        f"{inputs.ramp_months}-month ramp, ${inputs.ramped_quota:,.0f}/qtr quota). Validation: {status}."
    )


def _coverage_explanation(inputs: CoverageInputs, result: Dict[str, Any]) -> str:
    pacing_text = f"{inputs.pacing_q1*100:.0f}/{inputs.pacing_q2*100:.0f}/{inputs.pacing_q3*100:.0f}/{inputs.pacing_q4*100:.0f}%"
    validation = result.get("validation") or {}
    status = validation.get("status") or "ok"
    fy_range = _fy_range_label(inputs.first_fy, inputs.planning_years)
    return (
        f"Built coverage model for {fy_range} with ${inputs.annual_target:,.0f} annual target "
        f"(pacing [{pacing_text}], {inputs.coverage_target:.1f}x coverage, {inputs.win_rate*100:.0f}% win rate, "
        f"{inputs.sales_cycle_days}d cycle, {inputs.late_threshold_days}d late threshold). Validation: {status}."
    )


def _sdr_explanation(inputs: SDRInputs, result: Dict[str, Any]) -> str:
    validation = result.get("validation") or {}
    status = validation.get("status") or "ok"
    fy_range = _fy_range_label(inputs.first_fy, inputs.planning_years)
    total_meetings = inputs.sdr_count * inputs.meetings_per_sdr_month
    opps = total_meetings * inputs.meeting_to_opp_rate
    return (
        f"Built SDR diagnostic for {fy_range} with {inputs.sdr_count} SDRs "
        f"({inputs.meetings_per_sdr_month} meetings/SDR/mo = {total_meetings} meetings/mo, "
        f"{inputs.meeting_to_opp_rate*100:.0f}% mt→opp = {opps:.0f} opps/mo, "
        f"${inputs.sdr_cost_month:,.0f}/mo cost). Validation: {status}."
    )


def _resolve_range(payload: ScanRequest, sheet_titles: List[str]) -> tuple[str, str]:
    """
    Determine sheet name and A1 range based on precedence rules.
    """
    if payload.range:
        raw = payload.range.strip()
        if "!" in raw:
            sheet, rng = raw.split("!", 1)
            resolved_sheet = sheet or payload.sheet_name or (sheet_titles[0] if sheet_titles else "")
            if not resolved_sheet:
                raise HTTPException(status_code=400, detail="Sheet name could not be resolved")
            return resolved_sheet, f"{resolved_sheet}!{rng}"
        target_sheet = payload.sheet_name or (sheet_titles[0] if sheet_titles else "")
        return target_sheet, f"{target_sheet}!{raw}"

    target_sheet = payload.sheet_name or (sheet_titles[0] if sheet_titles else "")
    if not target_sheet:
        raise HTTPException(status_code=400, detail="Sheet name could not be resolved")
    return target_sheet, f"{target_sheet}!A1:Z200"


def _list_sheet_titles(service, spreadsheet_id: str) -> List[str]:
    resp = (
        service.spreadsheets()
        .get(spreadsheetId=spreadsheet_id, fields="sheets.properties.title")
        .execute()
    )
    return [s.get("properties", {}).get("title", "") for s in resp.get("sheets", []) or []]


def _fetch_sheet_grids(service, spreadsheet_id: str, a1_range: str) -> tuple[list[list[Any]], list[list[Any]]]:
    values_resp = (
        service.spreadsheets()
        .values()
        .get(
            spreadsheetId=spreadsheet_id,
            range=a1_range,
            valueRenderOption="UNFORMATTED_VALUE",
            majorDimension="ROWS",
        )
        .execute()
    )
    formulas_resp = (
        service.spreadsheets()
        .values()
        .get(
            spreadsheetId=spreadsheet_id,
            range=a1_range,
            valueRenderOption="FORMULA",
            majorDimension="ROWS",
        )
        .execute()
    )
    return values_resp.get("values", []) or [], formulas_resp.get("values", []) or []


def _collect_lint(profile, sheet_names: List[str]) -> List[Dict[str, Any]]:
    issues: List[Dict[str, Any]] = []
    for fc in profile.formula_cells:
        cell_issues = lint.lint_formula(
            cell=fc.a1,
            formula=fc.formula,
            value=fc.value,
            existing_sheets=sheet_names,
        )
        issues.extend(cell_issues)
    return issues


def _issues_from_repair(items: List[repair.Issue]) -> List[IssueModel]:
    out: List[IssueModel] = []
    for issue in items:
        suggestion = issue.suggestion or {}
        suggested = suggestion.get("suggested_formula") or suggestion.get("fixed")
        out.append(
            IssueModel(
                id=issue.id or f"{issue.type}_{issue.cell}",
                cell=issue.cell,
                type=issue.type,
                severity=issue.severity,
                message=issue.diagnosis or issue.type,
                formula=issue.current_formula or None,
                fixable=bool(issue.auto_fixable),
                suggested_fix=suggested,
            )
        )
    return out


def _issues_from_lint(items: List[Dict[str, Any]]) -> List[IssueModel]:
    out: List[IssueModel] = []
    for issue in items:
        issue_id = issue.get("id") or f"lint_{issue.get('cell','')}_{issue.get('type','')}"
        out.append(
            IssueModel(
                id=issue_id,
                cell=str(issue.get("cell", "")),
                type=str(issue.get("type", "")),
                severity=str(issue.get("severity", "warning")),
                message=str(issue.get("message", issue.get("type", ""))),
                formula=issue.get("formula"),
                fixable=bool(issue.get("fixable")),
                suggested_fix=issue.get("suggested_fix"),
            )
        )
    return out


def _categorize_fix_plan(issues: List[IssueModel], missing_sheets: List[MissingSheet]) -> Dict[str, int]:
    formula_issues = sum(1 for issue in issues if _is_formula_issue(issue))
    formula_issues += len(missing_sheets)
    return {
        "formula_issues": formula_issues,
        "style_issues": 0,
        "data_quality_issues": 0,
    }


def _is_formula_issue(issue: IssueModel) -> bool:
    issue_type = (issue.type or "").lower()
    return issue.fixable or any(key in issue_type for key in ("ref", "formula", "sheet"))


def _build_fix_plan_categories(counts: Dict[str, int]) -> List[FixPlanCategory]:
    return [
        FixPlanCategory(
            id="formula_issues",
            label="Formula issues",
            issue_count=counts.get("formula_issues", 0),
            description="Broken references, unquoted sheet names, missing sheets.",
            selected_by_default=True,
        ),
        FixPlanCategory(
            id="style_issues",
            label="Style inconsistencies",
            issue_count=counts.get("style_issues", 0),
            description="Inconsistent fonts and formats.",
            selected_by_default=False,
        ),
        FixPlanCategory(
            id="data_quality_issues",
            label="Data quality issues",
            issue_count=counts.get("data_quality_issues", 0),
            description="Duplicates, missing values.",
            selected_by_default=False,
        ),
    ]


def _pattern_anomalies(pattern_summaries: List[Dict[str, Any]]) -> List[PatternAnomaly]:
    anomalies: List[PatternAnomaly] = []
    for summary in pattern_summaries or []:
        scope = str(summary.get("scope") or "pattern")
        pattern = summary.get("pattern")
        for cell, actual in (summary.get("anomalies") or {}).items():
            neighbors = [c for c in (summary.get("anomalies") or {}).keys() if c != cell]
            description = f"Expected pattern '{pattern}', saw '{actual}'"
            anomalies.append(
                PatternAnomaly(
                    cell=cell,
                    pattern_group=scope,
                    description=description,
                    neighbor_cells=neighbors,
                )
            )
    return anomalies


def _missing_sheet(entry: Dict[str, Any]) -> MissingSheet:
    return MissingSheet(
        sheet_name=str(entry.get("sheet_name") or entry.get("sheet") or ""),
        referenced_by=int(entry.get("referenced_by") or 0),
        example_cells=[str(c) for c in entry.get("example_cells", [])],
    )


def _filter_safe_fixes(service, spreadsheet_id: str, fixes: List[FixItem]) -> tuple[List[FixItem], List[str]]:
    allowed: List[FixItem] = []
    skipped: List[str] = []
    for fix in fixes:
        cell = fix.cell
        try:
            resp = (
                service.spreadsheets()
                .values()
                .get(
                    spreadsheetId=spreadsheet_id,
                    range=cell,
                    valueRenderOption="FORMULA",
                    majorDimension="ROWS",
                )
                .execute()
            )
            formula = None
            values = resp.get("values", []) or []
            if values and values[0]:
                formula = values[0][0]
        except HttpError as err:
            skipped.append(f"{cell}: failed to fetch formula ({err.resp.status})")
            continue

        if not (isinstance(formula, str) and formula.startswith("=")):
            skipped.append(f"{cell}: no formula to fix")
            continue
        lint_issues = lint.check_unquoted_sheet_refs(formula)
        if not lint_issues:
            skipped.append(f"{cell}: no safe lint fix available")
            continue
        expected_fix = lint_issues[0].get("suggested_fix")
        if not expected_fix:
            skipped.append(f"{cell}: missing suggested fix")
            continue
        if expected_fix.strip() != fix.fixed.strip():
            skipped.append(f"{cell}: provided fix does not match suggested fix")
            continue
        allowed.append(fix)
    return allowed, skipped


def _apply_style_preset(service, spreadsheet_id: str, sheet_name: str, preset: Dict[str, Any]) -> List[str]:
    sheet_id, row_count, col_count = _get_sheet_id_and_size(service, spreadsheet_id, sheet_name)
    row_limit = int(preset.get("row_limit") or row_count)
    col_limit = int(preset.get("column_limit") or col_count)
    grid_range = {
        "sheetId": sheet_id,
        "startRowIndex": 0,
        "endRowIndex": max(1, min(row_count, row_limit)),
        "startColumnIndex": 0,
        "endColumnIndex": max(1, min(col_count, col_limit)),
    }

    requests: List[Dict[str, Any]] = []
    changes: List[str] = []

    header_rows = int(preset.get("header_rows") or 0)
    if header_rows > 0:
        header_range = dict(grid_range)
        header_range["endRowIndex"] = min(header_rows, header_range["endRowIndex"])
        header_format: Dict[str, Any] = {
            "backgroundColor": _hex_to_color(preset.get("header_fill", "#0f172a")),
            "textFormat": {
                "bold": bool(preset.get("header_bold", True)),
                "foregroundColor": _hex_to_color(preset.get("header_font_color", "#ffffff")),
            },
        }
        requests.append(
            {
                "repeatCell": {
                    "range": header_range,
                    "cell": {"userEnteredFormat": header_format},
                    "fields": "userEnteredFormat(backgroundColor,textFormat)",
                }
            }
        )
        changes.append(f"Styled header ({header_rows} row{'s' if header_rows > 1 else ''})")

    font_size = preset.get("font_size")
    font_color = preset.get("font_color")
    if font_size or font_color:
        body_range = dict(grid_range)
        if header_rows > 0:
            body_range["startRowIndex"] = min(header_rows, body_range["endRowIndex"])
        text_format: Dict[str, Any] = {}
        if font_size:
            text_format["fontSize"] = int(font_size)
        if font_color:
            text_format["foregroundColor"] = _hex_to_color(font_color)
        if text_format:
            requests.append(
                {
                    "repeatCell": {
                        "range": body_range,
                        "cell": {"userEnteredFormat": {"textFormat": text_format}},
                        "fields": "userEnteredFormat.textFormat",
                    }
                }
            )
            changes.append("Updated body font styling")

    if preset.get("stripe", False):
        body_range = dict(grid_range)
        if header_rows > 0:
            body_range["startRowIndex"] = min(header_rows, body_range["endRowIndex"])
        requests.append(
            {
                "addConditionalFormatRule": {
                    "rule": {
                        "ranges": [body_range],
                        "booleanRule": {
                            "condition": {"type": "CUSTOM_FORMULA", "values": [{"userEnteredValue": "=ISEVEN(ROW())"}]},
                            "format": {"backgroundColor": _hex_to_color(preset.get("stripe_color", "#f5f7fb"))},
                        },
                    },
                    "index": 0,
                }
            }
        )
        changes.append("Applied zebra striping")

    if not requests:
        return []

    service.spreadsheets().batchUpdate(
        spreadsheetId=spreadsheet_id,
        body={"requests": requests},
    ).execute()
    return changes


def _hex_to_color(hex_str: Optional[str]) -> Dict[str, float]:
    value = (hex_str or "").strip().lstrip("#")
    if len(value) == 3:
        value = "".join(ch * 2 for ch in value)
    if len(value) != 6:
        return {"red": 1, "green": 1, "blue": 1}
    r = int(value[0:2], 16) / 255.0
    g = int(value[2:4], 16) / 255.0
    b = int(value[4:6], 16) / 255.0
    return {"red": r, "green": g, "blue": b}




def _get_sheet_id_and_size(service, spreadsheet_id: str, sheet_name: str) -> tuple[int, int, int]:
    resp = (
        service.spreadsheets()
        .get(spreadsheetId=spreadsheet_id, fields="sheets.properties.sheetId,sheets.properties.title,sheets.properties.gridProperties")
        .execute()
    )
    for sheet in resp.get("sheets", []) or []:
        props = sheet.get("properties", {}) or {}
        if props.get("title") == sheet_name:
            grid = props.get("gridProperties", {}) or {}
            return (
                int(props.get("sheetId")),
                int(grid.get("rowCount", 1000)),
                int(grid.get("columnCount", 26)),
            )
    raise HTTPException(status_code=404, detail=f"Sheet '{sheet_name}' not found")


def _http_error(err: HttpError, message: str) -> HTTPException:
    status_code = getattr(getattr(err, "resp", None), "status", 500) or 500
    detail = f"{message}: {getattr(err, 'error_details', '') or err}"
    return HTTPException(status_code=status_code, detail=detail)


async def _generate_sheet_profile(
    job_id: str,
    token: str,
    spreadsheet_id: str,
    sheet_name: str,
    revision: str,
    cache_key: str,
) -> None:
    redis = await get_redis()
    job_key = _profile_job_key(job_id)
    try:
        service = google_client.build_sheets_service_from_token(token)
        values, total_rows, total_cols, truncated = await asyncio.to_thread(
            fetch_sheet_values,
            service,
            spreadsheet_id,
            sheet_name,
        )
        headers = values[0] if values else []
        data_rows = values[1:] if len(values) > 1 else []
        content_hash = compute_content_hash(headers, data_rows)
        profile = build_sheet_profile(
            values=values,
            spreadsheet_id=spreadsheet_id,
            sheet_name=sheet_name,
            revision=revision,
            total_rows=total_rows,
            total_cols=total_cols,
            content_hash=content_hash,
            profile_truncated=truncated,
        )
        profile_json = profile.json()
        if redis:
            await redis.setex(cache_key, PROFILE_CACHE_TTL_SECONDS, profile_json)
            await redis.setex(
                job_key,
                PROFILE_JOB_TTL_SECONDS,
                json.dumps(
                    {
                        "status": "complete",
                        "profile_key": cache_key,
                        "profile": json.loads(profile_json),
                    }
                ),
            )
    except Exception as exc:
        logger.exception("Gremlin profile job failed: %s", exc)
        if redis:
            await redis.setex(
                job_key,
                PROFILE_JOB_TTL_SECONDS,
                json.dumps({"status": "failed", "error": str(exc)}),
            )


@router.post("/profile", response_model=ProfileResponse)
async def profile_sheet(
    request: ProfileRequest,
    background_tasks: BackgroundTasks,
    account_id: str = Depends(require_account),
    x_sheets_token: Optional[str] = Header(None, alias="X-Sheets-Token"),
):
    del account_id  # reserved for future access checks
    token = (x_sheets_token or "").strip()
    if not token:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="X-Sheets-Token header required for profiling",
        )
    redis = await get_redis()
    if not redis:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Profile service temporarily unavailable",
        )
    cache_key = _profile_cache_key(
        request.spreadsheet_id,
        request.sheet_name,
        request.content_hash,
        request.revision,
    )
    cached = await redis.get(cache_key)
    if cached:
        profile_data = json.loads(cached)
        return ProfileResponse(
            status="complete", profile=profile_data, profile_key=cache_key
        )

    job_id = str(uuid4())
    job_key = _profile_job_key(job_id)
    await redis.setex(
        job_key,
        PROFILE_JOB_TTL_SECONDS,
        json.dumps({"status": "processing", "profile_key": cache_key}),
    )
    background_tasks.add_task(
        _generate_sheet_profile,
        job_id,
        token,
        request.spreadsheet_id,
        request.sheet_name,
        request.revision,
        cache_key,
    )
    return ProfileResponse(status="processing", job_id=job_id, profile_key=cache_key)


@router.get("/profile/{job_id}", response_model=ProfileResponse)
async def profile_status(
    job_id: str,
    account_id: str = Depends(require_account),
):
    del account_id  # reserved for future access checks
    redis = await get_redis()
    if not redis:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Profile service temporarily unavailable",
        )
    job_key = _profile_job_key(job_id)
    raw = await redis.get(job_key)
    if not raw:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Profile job not found",
        )
    payload = json.loads(raw)
    status_value = payload.get("status", "processing")
    profile = payload.get("profile")
    profile_key = payload.get("profile_key")
    error = payload.get("error")
    return ProfileResponse(
        status=status_value,
        job_id=job_id,
        profile=profile,
        profile_key=profile_key,
        error=error,
    )


async def _legacy_get_gremlin_tier(account_id: str, db: AsyncSession) -> str:
    """Legacy Gremlin tier lookup via subscription events."""
    from ...services.tier import infer_tier

    account = await db.get(Account, account_id)
    if not account:
        logger.debug("[GREMLIN TIER] account_id=%s not found -> free", account_id)
        return "free"

    plan_name = await _resolve_plan_name(account, db)
    if not plan_name:
        logger.debug("[GREMLIN TIER] account_id=%s no plan_name -> free", account_id)
        return "free"

    tier = infer_tier(plan_name)
    logger.debug("[GREMLIN TIER] account_id=%s plan=%s -> %s", account_id, plan_name, tier.value)
    return tier.value


async def get_gremlin_tier(account_id: str, db: AsyncSession) -> str:
    """
    Map subscription plan to gremlin tier (free|pro|scale|unleashed).

    Standard path is UsageGateway on the canonical account id. We retain
    subscription-event lookup as a fallback for accounts that have not been
    backfilled into entitlements yet.
    """
    canonical_account_id = await resolve_canonical_account_id(account_id, db)
    lookup_account_id = canonical_account_id or str(account_id)

    try:
        capabilities = await UsageGateway(db).get_capabilities(lookup_account_id)
        tier = str((capabilities or {}).get("tier") or "").strip().lower()
        if tier:
            logger.debug(
                "[GREMLIN TIER] account_id=%s canonical_account_id=%s gateway -> %s",
                account_id,
                lookup_account_id,
                tier,
            )
            return tier
    except Exception:
        logger.warning(
            "[GREMLIN TIER] UsageGateway lookup failed for account_id=%s canonical_account_id=%s",
            account_id,
            lookup_account_id,
            exc_info=True,
        )

    return await _legacy_get_gremlin_tier(lookup_account_id, db)


async def has_gremlin_paid_access(db: AsyncSession, account_id: str) -> bool:
    return (await get_gremlin_tier(account_id, db)) != "free"


async def _resolve_plan_name(account: Account, db: AsyncSession) -> Optional[str]:
    """
    Best-effort: use the latest subscription event plan to derive plan name.
    """
    latest_plan_id = await _latest_plan_id(account.id, db)
    if not latest_plan_id:
        return None
    plan = await db.get(Plan, latest_plan_id)
    return plan.name if plan else None


async def _latest_plan_id(account_id: str, db: AsyncSession) -> Optional[str]:
    stmt = (
        select(SubscriptionEvent.plan_id)
        .where(SubscriptionEvent.account_id == account_id, SubscriptionEvent.plan_id.is_not(None))
        .order_by(SubscriptionEvent.occurred_at.desc())
        .limit(1)
    )
    result = await db.execute(stmt)
    return result.scalar_one_or_none()


# Include triage sub-router
try:
    from .gremlin_triage import router as triage_router

    router.include_router(triage_router)
except Exception as exc:  # pragma: no cover - defensive
    logger.warning("Failed to include gremlin triage router: %s", exc)

# Include chat sub-router
try:
    from .gremlin_chat import router as chat_router

    router.include_router(chat_router)
except Exception as exc:  # pragma: no cover - defensive
    logger.warning("Failed to include gremlin chat router: %s", exc)
