"""
Sheet inspection and profiling.
"""

from __future__ import annotations

import re
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple

from g_gremlin.a1 import cell_a1_from_offsets, parse_a1_range
from g_gremlin.models import (
    ColumnProfile,
    FormulaCell,
    NamedRangeProfile,
    SheetProfile,
    TabClassification,
)


def compute_sheet_profile(
    service,
    spreadsheet_id: str,
    a1_range: str,
    values_grid: Optional[List[List[Any]]] = None,
    formulas_grid: Optional[List[List[Any]]] = None,
) -> SheetProfile:
    """
    Compute a structural profile of a sheet range.
    """
    parsed = parse_a1_range(a1_range)
    if values_grid is None or formulas_grid is None:
        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()
        )
        values_grid = values_resp.get("values", [])
        formulas_grid = formulas_resp.get("values", [])

    header_row, data_rows = _split_rows(values_grid)
    num_cols = _max_columns(header_row, data_rows)
    header = _normalize_header(header_row, num_cols)

    column_profiles = _build_column_profiles(header, data_rows, formulas_grid, num_cols)
    formula_cells = _collect_formula_cells(
        parsed, values_grid, formulas_grid, num_cols
    )
    named_ranges = _fetch_named_ranges(service, spreadsheet_id, parsed.sheet_name)
    classification, detected_patterns = _classify_tab(header, len(data_rows), column_profiles)

    return SheetProfile(
        spreadsheet_id=spreadsheet_id,
        sheet_name=parsed.sheet_name,
        a1_range=a1_range,
        header=header,
        row_count=len(data_rows),
        column_profiles=column_profiles,
        formula_cells=formula_cells,
        named_ranges=named_ranges,
        classification=classification,
        detected_sfdc_patterns=detected_patterns,
    )


def _split_rows(values_grid: List[List[Any]]) -> Tuple[List[Any], List[List[Any]]]:
    if not values_grid:
        return [], []
    header = values_grid[0]
    data = values_grid[1:] if len(values_grid) > 1 else []
    return header, data


def _max_columns(header: List[Any], data_rows: List[List[Any]]) -> int:
    data_max = max((len(r) for r in data_rows), default=0)
    return max(len(header), data_max)


def _normalize_header(header_row: List[Any], num_cols: int) -> List[str]:
    names: List[str] = []
    for idx in range(num_cols):
        if idx < len(header_row) and str(header_row[idx]).strip():
            names.append(str(header_row[idx]).strip())
        else:
            names.append(f"Column{idx+1}")
    return names


def _build_column_profiles(
    header: List[str],
    data_rows: List[List[Any]],
    formulas_grid: List[List[Any]],
    num_cols: int,
) -> List[ColumnProfile]:
    profiles: List[ColumnProfile] = []
    formula_cols = _formula_columns(formulas_grid, num_cols)
    for col_idx in range(num_cols):
        col_values_with_index = [
            (idx, row[col_idx] if col_idx < len(row) else None)
            for idx, row in enumerate(data_rows)
        ]
        col_values = [value for _, value in col_values_with_index]
        inferred_type, non_null, distinct, samples = _infer_type_and_stats(col_values)
        has_formula = _column_has_formula(formulas_grid, col_idx)
        role = _infer_role(
            col_idx,
            inferred_type=inferred_type,
            non_null_count=non_null,
            distinct_count=distinct,
            has_formula=has_formula,
            formula_cols=formula_cols,
        )
        semantic_type, validation = _infer_semantic_type(
            header[col_idx],
            col_values_with_index,
            inferred_type=inferred_type,
            non_null_count=non_null,
            distinct_count=distinct,
        )
        profiles.append(
            ColumnProfile(
                name=header[col_idx],
                index=col_idx,
                inferred_type=inferred_type,
                non_null_count=non_null,
                distinct_count=distinct,
                sample_values=samples,
                has_formula=has_formula,
                role=role,
                semantic_type=semantic_type,
                validation=validation,
            )
        )
    return profiles


def _infer_type_and_stats(col_values: List[Any]) -> Tuple[str, int, int, List[str]]:
    non_null_values = [
        v for v in col_values if v is not None and str(v).strip() != ""
    ]
    non_null_count = len(non_null_values)
    if non_null_count == 0:
        return "empty", 0, 0, []

    num_count = 0
    bool_count = 0
    date_count = 0
    distinct_set = set()
    sample_values: List[str] = []
    for v in non_null_values:
        if isinstance(v, bool):
            bool_count += 1
        elif isinstance(v, (int, float)) and not isinstance(v, bool):
            num_count += 1
        elif isinstance(v, str) and _looks_like_date(v):
            date_count += 1
        distinct_set.add(str(v))
        if len(sample_values) < 5 and str(v) not in sample_values:
            sample_values.append(str(v))

    distinct_count = len(distinct_set)
    inferred_type = _pick_type(
        non_null_count=non_null_count,
        num_count=num_count,
        bool_count=bool_count,
        date_count=date_count,
    )
    return inferred_type, non_null_count, distinct_count, sample_values


def _pick_type(
    non_null_count: int, num_count: int, bool_count: int, date_count: int
) -> str:
    if num_count == non_null_count:
        return "number"
    if bool_count == non_null_count:
        return "boolean"
    if date_count == non_null_count:
        return "date"
    if non_null_count == 0:
        return "empty"
    # if a single kind of string that is not numeric/bool/date
    if num_count == 0 and bool_count == 0 and date_count == 0:
        return "text"
    return "mixed"


def _looks_like_date(value: str) -> bool:
    if not isinstance(value, str):
        return False
    # Simple YYYY-MM-DD or YYYY/MM/DD
    return bool(re.match(r"^\\d{4}[-/]\\d{2}[-/]\\d{2}$", value.strip()))


def _column_has_formula(formulas_grid: List[List[Any]], col_idx: int) -> bool:
    for row in formulas_grid:
        if col_idx < len(row):
            cell = row[col_idx]
            if isinstance(cell, str) and cell.startswith("="):
                return True
    return False


def _formula_columns(formulas_grid: List[List[Any]], num_cols: int) -> set[int]:
    cols: set[int] = set()
    for row in formulas_grid:
        for c in range(min(num_cols, len(row))):
            cell = row[c]
            if isinstance(cell, str) and cell.startswith("="):
                cols.add(c)
    return cols


def _collect_formula_cells(
    parsed_range,
    values_grid: List[List[Any]],
    formulas_grid: List[List[Any]],
    num_cols: int,
) -> List[FormulaCell]:
    cells: List[FormulaCell] = []
    for row_idx, formula_row in enumerate(formulas_grid):
        if row_idx == 0:
            continue  # skip header row
        data_row_index = row_idx - 1
        for col_idx in range(num_cols):
            if col_idx >= len(formula_row):
                continue
            formula_val = formula_row[col_idx]
            if not (isinstance(formula_val, str) and formula_val.startswith("=")):
                continue
            value = None
            if row_idx < len(values_grid) and col_idx < len(values_grid[row_idx]):
                value = values_grid[row_idx][col_idx]
            is_error = isinstance(value, str) and value.startswith("#")
            a1 = cell_a1_from_offsets(parsed_range, data_row_index, col_idx)
            cells.append(
                FormulaCell(
                    a1=a1,
                    row_index=data_row_index,
                    column_index=col_idx,
                    formula=formula_val,
                    value=value if value is None else str(value),
                    is_error=is_error,
                )
            )
    return cells


def _infer_role(
    col_idx: int,
    inferred_type: str,
    non_null_count: int,
    distinct_count: int,
    has_formula: bool,
    formula_cols: set[int],
) -> str:
    if has_formula and col_idx in formula_cols:
        return "derived"
    if inferred_type == "number" and non_null_count > 0 and distinct_count > 10:
        return "measure"
    if inferred_type in ("text", "boolean") and distinct_count <= 20:
        return "dimension"
    return "unknown"


def _infer_semantic_type(
    name: str,
    values_with_index: List[Tuple[int, Any]],
    inferred_type: str,
    non_null_count: int,
    distinct_count: int,
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
    semantic_type: Optional[str] = None
    validation: Optional[Dict[str, Any]] = None
    lower_name = name.lower()

    account_hint = "account" in lower_name and "id" in lower_name
    opp_hint = ("opp" in lower_name or "opportunity" in lower_name) and "id" in lower_name

    account_ratio, account_validation = _validate_sfdc_ids(values_with_index, prefixes=("001",))
    opp_ratio, opp_validation = _validate_sfdc_ids(values_with_index, prefixes=("006",))

    if account_hint or account_ratio >= 0.6:
        semantic_type = "sfdc_account_id"
        validation = account_validation
        return semantic_type, validation
    if opp_hint or opp_ratio >= 0.6:
        semantic_type = "sfdc_opportunity_id"
        validation = opp_validation
        return semantic_type, validation

    excel_ratio, excel_validation = _excel_serial_ratio(values_with_index)
    if excel_ratio >= 0.6 and inferred_type in {"number", "mixed"}:
        return "date_excel_serial", excel_validation

    iso_ratio, iso_validation = _iso_date_ratio(values_with_index)
    if iso_ratio >= 0.6:
        return "date_iso", iso_validation

    currency_ratio, currency_validation = _currency_ratio(name, values_with_index)
    if currency_ratio >= 0.6:
        return "currency", currency_validation

    picklist_candidate = _is_picklist_candidate(lower_name, non_null_count, distinct_count, values_with_index)
    if picklist_candidate:
        validation = {"distinct_values_sample": list(set(v for _, v in values_with_index if v is not None))[:10]}
        return "picklist", validation

    return semantic_type, validation


def _validate_sfdc_ids(
    values_with_index: List[Tuple[int, Any]], prefixes: Tuple[str, ...]
) -> Tuple[float, Optional[Dict[str, Any]]]:
    pattern = rf"^({'|'.join(prefixes)})[A-Za-z0-9]{{12}}(?:[A-Za-z0-9]{{3}})?$"
    total = 0
    matches = 0
    violations = []
    for row_idx, raw in values_with_index:
        if raw is None:
            continue
        value = str(raw).strip()
        if not value:
            continue
        total += 1
        if _is_sfdc_id(value, prefixes):
            matches += 1
        elif len(violations) < 10:
            violations.append({"row_index": row_idx, "value": value, "issue": "does_not_match_pattern"})
    if total == 0:
        return 0.0, None
    ratio = matches / total
    validation = {"pattern": pattern, "violations": violations}
    return ratio, validation


def _is_sfdc_id(value: str, prefixes: Tuple[str, ...]) -> bool:
    if len(value) not in (15, 18):
        return False
    if value[:3] not in prefixes:
        return False
    return bool(re.match(r"^[A-Za-z0-9]+$", value))


def _excel_serial_ratio(values_with_index: List[Tuple[int, Any]]) -> Tuple[float, Optional[Dict[str, Any]]]:
    numeric_values: List[float] = []
    excel_like: List[float] = []
    for _, raw in values_with_index:
        if raw is None or isinstance(raw, bool):
            continue
        number: Optional[float] = None
        if isinstance(raw, (int, float)):
            number = float(raw)
        else:
            try:
                number = float(str(raw))
            except (TypeError, ValueError):
                number = None
        if number is None:
            continue
        numeric_values.append(number)
        if 40000 <= number <= 50000:
            excel_like.append(number)
    if not numeric_values:
        return 0.0, None
    ratio = len(excel_like) / len(numeric_values)
    validation = None
    if excel_like:
        sample_date = _excel_serial_to_iso(excel_like[0])
        validation = {
            "excel_serial_range": [40000, 50000],
            "sample_converted_date": sample_date,
        }
    return ratio, validation


def _iso_date_ratio(values_with_index: List[Tuple[int, Any]]) -> Tuple[float, Optional[Dict[str, Any]]]:
    total = 0
    parsed = 0
    sample: Optional[str] = None
    for _, raw in values_with_index:
        if raw is None:
            continue
        val = str(raw).strip()
        if not val:
            continue
        total += 1
        if _parse_iso_datetime(val):
            parsed += 1
            if sample is None:
                sample = val
    if total == 0:
        return 0.0, None
    ratio = parsed / total
    validation = None
    if sample:
        validation = {"sample_value": sample}
    return ratio, validation


def _parse_iso_datetime(value: str) -> Optional[datetime]:
    cleaned = value.replace("Z", "+00:00")
    try:
        return datetime.fromisoformat(cleaned)
    except ValueError:
        return None


def _currency_ratio(name: str, values_with_index: List[Tuple[int, Any]]) -> Tuple[float, Optional[Dict[str, Any]]]:
    total = 0
    currency_like = 0
    samples: List[str] = []
    name_hint = any(hint in name.lower() for hint in ["amount", "arr", "revenue", "price", "acv"])
    for _, raw in values_with_index:
        if raw is None or isinstance(raw, bool):
            continue
        text = str(raw).strip()
        if not text:
            continue
        total += 1
        if _looks_like_currency(text):
            currency_like += 1
            if len(samples) < 3 and text not in samples:
                samples.append(text)
        elif name_hint and _coercible_number(text):
            currency_like += 1
            if len(samples) < 3 and text not in samples:
                samples.append(text)
    if total == 0:
        return 0.0, None
    ratio = currency_like / total
    validation = None
    if samples:
        validation = {"samples": samples}
    return ratio, validation


def _looks_like_currency(value: str) -> bool:
    return bool(re.match(r"^\$?\s*-?\d{1,3}(,\d{3})*(\.\d+)?$", value)) or bool(
        re.match(r"^\$?\s*-?\d+(\.\d+)?$", value)
    )


def _coercible_number(value: str) -> bool:
    try:
        float(value.replace(",", "").replace("$", ""))
        return True
    except Exception:
        return False


def _excel_serial_to_iso(serial: float) -> str:
    base = datetime(1899, 12, 30)  # Excel serial date base
    date_value = base + timedelta(days=serial)
    return date_value.date().isoformat()


def _is_picklist_candidate(
    lower_name: str, non_null_count: int, distinct_count: int, values_with_index: List[Tuple[int, Any]]
) -> bool:
    if non_null_count == 0:
        return False
    if distinct_count <= 25 and distinct_count <= non_null_count:
        return True
    sample_strings = [_normalize_text_value(v) for _, v in values_with_index if v is not None][:10]
    stage_keywords = ["closed won", "closed lost", "pipeline", "commit", "stage"]
    if any(val and any(kw in val for kw in stage_keywords) for val in sample_strings):
        return True
    if "stage" in lower_name or "status" in lower_name:
        return distinct_count <= 50
    return False


def _normalize_text_value(value: Any) -> str:
    return str(value).strip().lower()


def _classify_tab(
    header: List[str], row_count: int, column_profiles: List[ColumnProfile]
) -> Tuple[Optional[TabClassification], Optional[Dict[str, Any]]]:
    classification: Optional[TabClassification] = None
    evidence: List[str] = []

    account_id_col = _first_semantic(column_profiles, "sfdc_account_id")
    opp_id_col = _first_semantic(column_profiles, "sfdc_opportunity_id")
    account_name_col = _find_column_by_name(column_profiles, ["account name", "accountname"])
    stage_col = _find_column_by_name(column_profiles, ["stage"])
    amount_col = _find_column_by_name(column_profiles, ["amount", "amt"])
    close_date_col = _find_column_by_name(column_profiles, ["close date", "closedate", "close_dt", "close_dt"])
    status_col = _find_column_by_name(column_profiles, ["status"])
    source_col = _find_column_by_name(column_profiles, ["source"])

    detected_patterns: Dict[str, Any] = {}

    def is_unique(col: ColumnProfile) -> bool:
        return col.non_null_count > 0 and col.distinct_count >= col.non_null_count

    if opp_id_col:
        evidence.append(f"Has Opportunity ID column '{opp_id_col.name}'.")
        if is_unique(opp_id_col):
            evidence.append("Opportunity ID appears unique.")
        if stage_col:
            evidence.append(f"Stage column detected ('{stage_col.name}').")
        if amount_col:
            evidence.append(f"Amount column detected ('{amount_col.name}').")
        if close_date_col:
            evidence.append(f"Close Date column detected ('{close_date_col.name}').")
        confidence = 0.55 + 0.1 * sum(bool(x) for x in [stage_col, amount_col, close_date_col])
        confidence = min(confidence, 0.95)
        classification = TabClassification(
            type="opportunity_fact_table",
            confidence=confidence,
            evidence=evidence,
            suggested_grain="one row per Opportunity",
            suggested_primary_key=opp_id_col.name,
        )

    elif account_id_col or account_name_col:
        if account_id_col:
            evidence.append(f"Has Account ID column '{account_id_col.name}'.")
            if is_unique(account_id_col):
                evidence.append("Account ID appears unique.")
        if account_name_col:
            evidence.append(f"Account Name column detected ('{account_name_col.name}').")
        if status_col:
            evidence.append(f"Status column detected ('{status_col.name}').")
        if source_col:
            evidence.append(f"Source column detected ('{source_col.name}').")
        confidence = 0.5
        if (account_id_col and is_unique(account_id_col)) or (account_name_col and row_count > 0):
            confidence += 0.15
        if status_col or source_col:
            confidence += 0.15
        confidence = min(confidence, 0.9)
        primary_key = account_id_col.name if account_id_col else (account_name_col.name if account_name_col else None)
        classification = TabClassification(
            type="account_master_list",
            confidence=confidence,
            evidence=evidence,
            suggested_grain="one row per Account",
            suggested_primary_key=primary_key,
        )
    else:
        classification = TabClassification(
            type="generic_data",
            confidence=0.3,
            evidence=["No strong SFDC patterns detected."],
        )

    if account_id_col or account_name_col:
        detected_patterns["Account"] = {
            "id_column": account_id_col.name if account_id_col else None,
            "name_column": account_name_col.name if account_name_col else None,
            "confidence": 0.8 if account_id_col else 0.6,
        }
    if opp_id_col:
        detected_patterns["Opportunity"] = {
            "id_column": opp_id_col.name,
            "stage_column": stage_col.name if stage_col else None,
            "amount_column": amount_col.name if amount_col else None,
            "close_date_column": close_date_col.name if close_date_col else None,
            "confidence": 0.7 + 0.05 * sum(bool(x) for x in [stage_col, amount_col, close_date_col]),
        }

    return classification, detected_patterns or None


def _first_semantic(columns: List[ColumnProfile], semantic: str) -> Optional[ColumnProfile]:
    for col in columns:
        if col.semantic_type == semantic:
            return col
    return None


def _find_column_by_name(columns: List[ColumnProfile], keywords: List[str]) -> Optional[ColumnProfile]:
    lowered = [(col, col.name.lower()) for col in columns]
    for col, lower in lowered:
        for kw in keywords:
            if kw in lower:
                return col
    return None


def _fetch_named_ranges(service, spreadsheet_id: str, sheet_name: str) -> List[NamedRangeProfile]:
    # Client-side data mode: no service available
    if service is None:
        return []
    try:
        meta = (
            service.spreadsheets()
            .get(spreadsheetId=spreadsheet_id, fields="namedRanges")
            .execute()
        )
    except Exception:
        return []

    named_ranges_raw = meta.get("namedRanges", []) or []
    profiles: List[NamedRangeProfile] = []
    for nr in named_ranges_raw:
        name = nr.get("name") or ""
        ranges = nr.get("ranges") or []
        for rng in ranges:
            a1 = rng.get("a1Range") or ""
            # Include if sheet matches or unknown; keep simple.
            if sheet_name and "!" in a1:
                target_sheet = a1.split("!", 1)[0]
                if target_sheet != sheet_name:
                    continue
            profiles.append(NamedRangeProfile(name=name, range_a1=a1))
    return profiles
