"""
Formula Fixer service: scan sparse formula cells and group issues.
"""

from __future__ import annotations

import json
import re
from typing import Any, Dict, Iterable, List, Optional, Tuple

from g_gremlin import graph as gremlin_graph
from g_gremlin import lint, repair

MAX_FORMULA_CELLS = 5000
SCAN_CACHE_TTL_SECONDS = 600

SCAN_CACHE_KEY = "gremlin:formula_fixer:scan:{scan_id}"
LAST_SCAN_KEY = "gremlin:formula_fixer:last_scan:{spreadsheet_id}"

BAND_RANK = {
    "CERTAIN": 3,
    "LIKELY": 2,
    "REVIEW": 1,
    "EXPLAIN_ONLY": 0,
}

DIAGNOSTIC_ACTIONS = {
    "circular_ref": ["show_dependency_path", "highlight_cells"],
    "ref_error": ["show_dependency_path", "create_diagnostic_tab"],
    "name_error": ["show_dependency_path"],
    "value_error": ["show_dependency_path"],
    "div_zero_error": ["show_dependency_path"],
    "nonexistent_sheet_ref": ["show_dependency_path", "highlight_cells"],
    "pattern_anomaly": ["show_dependency_path", "create_diagnostic_tab"],
    "mixed_reference_style": ["highlight_cells"],
    "hardcoded_in_formula": ["highlight_cells"],
    "very_long_formula": ["highlight_cells"],
    "offset_wrong_args": ["show_dependency_path"],
    "iferror_missing_fallback": ["show_dependency_path"],
    "unquoted_sheet_ref": [],
}

# Human-readable titles for issue types
ISSUE_TITLES = {
    "circular_ref": "Circular Reference",
    "ref_error": "Reference Error (#REF!)",
    "name_error": "Name Error (#NAME?)",
    "value_error": "Value Error (#VALUE!)",
    "div_zero_error": "Division by Zero (#DIV/0!)",
    "nonexistent_sheet_ref": "Missing Sheet Reference",
    "pattern_anomaly": "Pattern Anomaly",
    "mixed_reference_style": "Mixed Reference Style",
    "hardcoded_in_formula": "Hardcoded Value in Formula",
    "very_long_formula": "Very Long Formula",
    "offset_wrong_args": "OFFSET Function Error",
    "iferror_missing_fallback": "IFERROR Missing Fallback",
    "unquoted_sheet_ref": "Unquoted Sheet Name",
}

# Base explanations for issue types (context-specific details added dynamically)
ISSUE_EXPLANATIONS = {
    "circular_ref": "These cells reference each other in a loop, creating a circular dependency that cannot be calculated.",
    "ref_error": "The formula references a cell or range that no longer exists (deleted row/column or invalid reference).",
    "name_error": "The formula contains an unrecognized name—possibly a misspelled function, missing named range, or unquoted text.",
    "value_error": "The formula is trying to perform an operation on incompatible data types (e.g., adding text to a number).",
    "div_zero_error": "The formula is dividing by zero or by an empty cell.",
    "nonexistent_sheet_ref": "The formula references a sheet that doesn't exist in this workbook.",
    "pattern_anomaly": "These cells use a DIFFERENT formula pattern than the majority in the same column. This is a heuristic—the minority pattern may actually be correct. Review carefully before applying.",
    "mixed_reference_style": "The formula mixes absolute ($A$1) and relative (A1) references inconsistently.",
    "hardcoded_in_formula": "The formula contains hardcoded numbers that should probably be cell references.",
    "very_long_formula": "This formula is unusually long and may be difficult to maintain. Consider breaking it into helper cells.",
    "offset_wrong_args": "The OFFSET function has incorrect or missing arguments.",
    "iferror_missing_fallback": "IFERROR is used but the fallback value may hide real errors.",
    "unquoted_sheet_ref": "Sheet names with spaces must be quoted. This can be auto-fixed.",
}


def _build_explanation(
    issue_type: str,
    cells: List[str],
    sample_original: Optional[str],
    sample_fixed: Optional[str],
    cells_map: Optional[Dict[str, Dict[str, Any]]] = None,
    working_cells: Optional[List[str]] = None,
    all_working: bool = False,
) -> str:
    """Build a context-specific explanation for an issue group."""
    base = ISSUE_EXPLANATIONS.get(issue_type, "")

    if not cells:
        return base

    # Add cell-specific context
    cell_list = ", ".join(cells[:5])
    if len(cells) > 5:
        cell_list += f" (+{len(cells) - 5} more)"

    context_parts = [base]

    if issue_type == "pattern_anomaly":
        # Check if all flagged cells are working correctly
        if all_working:
            # All cells work - this is informational only
            context_parts = [
                "These cells use a different formula pattern than the majority, "
                "but they produce VALID results. No fix needed."
            ]
            context_parts.append(
                f"\n\nMajority pattern: {sample_fixed or '(unknown)'}\n"
                f"These cells use: {sample_original or '(unknown)'}\n"
                f"Working cells: {cell_list}\n\n"
                f"✓ These formulas are correct - the majority pattern may actually be wrong."
            )
        elif sample_original and sample_fixed:
            context_parts.append(
                f"\n\nMajority pattern: {sample_fixed}\n"
                f"These cells use: {sample_original}\n"
                f"Cells with errors: {cell_list}"
            )
            if working_cells:
                working_list = ", ".join(working_cells[:3])
                if len(working_cells) > 3:
                    working_list += f" (+{len(working_cells) - 3} more)"
                context_parts.append(f"\n\n✓ Note: {working_list} use this pattern and work correctly (not included in fix).")
        else:
            context_parts.append(f"\n\nCells: {cell_list}")
    elif issue_type == "value_error" and cells_map:
        # Try to identify what's causing the value error
        sample_cell = cells[0] if cells else None
        if sample_cell and sample_cell in cells_map:
            formula = cells_map[sample_cell].get("formula", "")
            if formula:
                context_parts.append(f"\n\nFormula: {formula}\nCells: {cell_list}")
    elif issue_type == "circular_ref":
        context_parts.append(f"\n\nCells in cycle: {cell_list}")
    elif issue_type in ("ref_error", "name_error", "div_zero_error"):
        context_parts.append(f"\n\nAffected cells: {cell_list}")
    elif issue_type == "nonexistent_sheet_ref" and sample_original:
        # Extract the sheet name from the formula
        match = re.search(r"['\"]?([^'\"!]+)['\"]?!", sample_original)
        if match:
            missing_sheet = match.group(1)
            context_parts.append(f"\n\nMissing sheet: '{missing_sheet}'\nCells: {cell_list}")
        else:
            context_parts.append(f"\n\nCells: {cell_list}")
    else:
        context_parts.append(f"\n\nCells: {cell_list}")

    return "".join(context_parts)


def clamp_cells(
    cells: List[Dict[str, Any]],
    max_cells: int = MAX_FORMULA_CELLS,
) -> Tuple[List[Dict[str, Any]], bool, Optional[str]]:
    if len(cells) <= max_cells:
        return cells, False, None
    truncated_cells = cells[:max_cells]
    message = (
        f"Scanned {max_cells} of {len(cells)} formula cells. "
        "Select specific sheets for a full scan."
    )
    return truncated_cells, True, message


def build_cells_map(cells: Iterable[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
    normalized: Dict[str, Dict[str, Any]] = {}
    for cell in cells:
        a1 = str(cell.get("a1") or "").strip()
        sheet = str(cell.get("sheet") or "").strip()
        if not a1:
            continue
        full = f"{sheet}!{a1}" if sheet else a1
        normalized[full] = {
            "formula": cell.get("formula"),
            "value": cell.get("display_value"),
        }
    return normalized


def _is_numeric(value: Any) -> bool:
    if value is None:
        return False
    if isinstance(value, (int, float)):
        return True
    if isinstance(value, bool):
        return True
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return False
        try:
            float(text.replace(",", ""))
            return True
        except Exception:
            return False
    return False


def find_suspect_cells(
    cell_key: str,
    cells_map: Dict[str, Dict[str, Any]],
    cell_graph: Dict[str, Dict[str, List[str]]],
) -> List[Dict[str, Any]]:
    suspects: List[Dict[str, Any]] = []
    sheet_prefix = ""
    if cell_key and "!" in cell_key:
        sheet_prefix = cell_key.split("!", 1)[0]
    refs = cell_graph.get(cell_key, {}).get("references", []) if cell_key else []
    for ref in refs:
        normalized_ref = ref
        if "!" not in ref and sheet_prefix:
            normalized_ref = f"{sheet_prefix}!{ref}"
        val = (cells_map.get(normalized_ref, {}) or {}).get("value")
        if val is None and normalized_ref != ref:
            val = (cells_map.get(ref, {}) or {}).get("value")
        if val is None:
            continue
        if _is_numeric(val):
            continue
        if isinstance(val, str) and not val.strip():
            continue
        suspects.append({"cell": normalized_ref, "value": val})
    return suspects


def collect_issues(
    cells_map: Dict[str, Dict[str, Any]],
    existing_sheets: List[str],
) -> Tuple[List[Dict[str, Any]], repair.RepairPlan]:
    issues: List[Dict[str, Any]] = []

    for cell, meta in cells_map.items():
        formula = meta.get("formula")
        value = meta.get("value")
        lint_issues = lint.lint_formula_filtered(
            cell, formula, value, existing_sheets, enabled_rules=None
        )
        issues.extend(lint_issues)

    repair_cells = [
        {
            "a1": cell,
            "value": meta.get("value"),
            "formula": meta.get("formula") or "",
        }
        for cell, meta in cells_map.items()
    ]
    plan = repair.analyze_sheet(repair_cells, existing_sheets)
    for issue in plan.issues:
        suggestion = issue.suggestion or {}
        issues.append(
            {
                "type": issue.type,
                "cell": issue.cell,
                "severity": issue.severity,
                "message": issue.diagnosis,
                "formula": issue.current_formula,
                "suggested_fix": suggestion.get("suggested_formula"),
                "suggestion": suggestion,
            }
        )

    cycle_issues = lint.detect_transitive_circular_refs(cells_map, default_sheet=None)
    for issue in cycle_issues:
        issues.append(
            {
                "type": issue.get("type"),
                "cell": (issue.get("cells_in_cycle") or [""])[0],
                "severity": issue.get("severity"),
                "message": issue.get("message"),
                "cells_in_cycle": issue.get("cells_in_cycle") or [],
            }
        )

    return issues, plan


def _is_error_value(value: Any) -> bool:
    """Check if a display value is a spreadsheet error."""
    if value is None:
        return False
    text = str(value).upper().strip()
    return any(err in text for err in (
        "#REF!", "#NAME?", "#VALUE!", "#DIV/0!", "#NULL!", "#N/A", "#NUM!", "CIRCULAR"
    ))


def _confidence_for_issue(issue: Dict[str, Any]) -> str:
    issue_type = issue.get("type")
    if issue_type == "unquoted_sheet_ref":
        return "CERTAIN"
    if issue_type == "pattern_anomaly":
        return "REVIEW"
    if issue.get("suggested_fix"):
        return "LIKELY"
    return "EXPLAIN_ONLY"


def _recommended_action(confidence_band: str) -> str:
    if confidence_band in ("CERTAIN", "LIKELY"):
        return "preview_apply"
    if confidence_band == "REVIEW":
        return "review"
    return "diagnose"


def build_issue_groups(
    issues: List[Dict[str, Any]],
    scan_id: Optional[str] = None,
    cells_map: Optional[Dict[str, Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
    groups: Dict[str, Dict[str, Any]] = {}

    for issue in issues:
        issue_type = issue.get("type") or "unknown"
        group = groups.setdefault(
            issue_type,
            {
                "type": issue_type,
                "title": ISSUE_TITLES.get(issue_type, issue_type.replace("_", " ").title()),
                "cells": [],
                "sample_original": None,
                "sample_fixed": None,
                "fix_available": False,
                "fix_confidence_band": "EXPLAIN_ONLY",
                "recommended_action": "diagnose",
                "diagnostic_actions": DIAGNOSTIC_ACTIONS.get(issue_type, []),
                "fixes": [],
                "_bands": [],
            },
        )

        cells = issue.get("cells_in_cycle") or [issue.get("cell")]
        for cell in cells:
            if cell and cell not in group["cells"]:
                group["cells"].append(cell)

        if not group["sample_original"] and issue.get("formula"):
            group["sample_original"] = issue.get("formula")
        if not group["sample_fixed"] and issue.get("suggested_fix"):
            group["sample_fixed"] = issue.get("suggested_fix")

        if issue.get("suggested_fix") and issue.get("cell"):
            group["fix_available"] = True
            group["fixes"].append(
                {
                    "cell": issue.get("cell"),
                    "expected_original": issue.get("formula") or "",
                    "proposed": issue.get("suggested_fix"),
                }
            )

        band = _confidence_for_issue(issue)
        group["_bands"].append(band)

    for group in groups.values():
        bands = group.pop("_bands") if group.get("_bands") else []
        if bands:
            group["fix_confidence_band"] = min(
                bands, key=lambda b: BAND_RANK.get(b, 0)
            )
        group["recommended_action"] = _recommended_action(group["fix_confidence_band"])
        if not group["fix_available"]:
            group["fixes"] = []
        if scan_id:
            group["issue_id"] = f"{scan_id}:{group['type']}"

        # For pattern_anomaly: filter out fixes for cells that produce valid results
        # Don't suggest changing working formulas to match a potentially broken pattern
        if group["type"] == "pattern_anomaly" and cells_map:
            cells_with_errors = []
            cells_working = []

            # Check each cell in the group
            for cell in group["cells"]:
                cell_meta = cells_map.get(cell, {})
                cell_value = cell_meta.get("value")

                if _is_error_value(cell_value):
                    cells_with_errors.append(cell)
                else:
                    cells_working.append(cell)

            # Update the group based on what we found
            if cells_working and not cells_with_errors:
                # ALL flagged cells work correctly - don't suggest any fixes
                group["fixes"] = []
                group["fix_available"] = False
                group["fix_confidence_band"] = "EXPLAIN_ONLY"
                group["recommended_action"] = "diagnose"
                group["_all_working"] = True
            elif cells_with_errors and group["fixes"]:
                # Some cells have errors - only include fixes for those
                valid_fixes = [
                    f for f in group["fixes"]
                    if f.get("cell") in cells_with_errors
                ]
                group["fixes"] = valid_fixes
                group["_working_cells"] = cells_working if cells_working else None

            # Store working cells for explanation
            if cells_working:
                group["_working_cells"] = cells_working

        # Add human-readable explanation
        group["explanation"] = _build_explanation(
            group["type"],
            group["cells"],
            group["sample_original"],
            group["sample_fixed"],
            cells_map,
            working_cells=group.get("_working_cells"),
            all_working=group.get("_all_working", False),
        )
        # Clean up internal fields
        group.pop("_working_cells", None)
        group.pop("_all_working", None)

    # Cross-reference: if value_error cells could use a working pattern from pattern_anomaly
    _cross_reference_errors_with_patterns(groups, cells_map)

    return sorted(groups.values(), key=lambda g: g.get("type", ""))


def _extract_column(cell: str) -> Optional[str]:
    """Extract sheet!column from a cell reference like 'Sheet1!A5' -> 'Sheet1!A'."""
    if "!" in cell:
        sheet, a1 = cell.split("!", 1)
        col = "".join(c for c in a1 if c.isalpha())
        return f"{sheet}!{col}" if col else None
    col = "".join(c for c in cell if c.isalpha())
    return col if col else None


def _generate_pattern_fix(
    error_cell: str,
    working_formula: str,
    working_cell: str,
) -> Optional[str]:
    """
    Generate a fix for error_cell by adapting the working_formula pattern.
    E.g., if working_cell='E7' has '=D7+C7', and error_cell='E2',
    generate '=D2+C2'.
    """
    if not working_formula or not working_formula.startswith("="):
        return None

    # Extract row numbers
    working_row_match = re.search(r"(\d+)$", working_cell.split("!")[-1] if "!" in working_cell else working_cell)
    error_row_match = re.search(r"(\d+)$", error_cell.split("!")[-1] if "!" in error_cell else error_cell)

    if not working_row_match or not error_row_match:
        return None

    working_row = working_row_match.group(1)
    error_row = error_row_match.group(1)

    # Replace row numbers in the formula
    # This is a simple approach - replace the working row with the error row
    new_formula = re.sub(
        r"(\$?)(" + working_row + r")(?!\d)",
        r"\g<1>" + error_row,
        working_formula,
    )

    return new_formula if new_formula != working_formula else None


def _cross_reference_errors_with_patterns(
    groups: Dict[str, Dict[str, Any]],
    cells_map: Dict[str, Dict[str, Any]],
) -> None:
    """
    If we have value_error cells in the same column as a working pattern_anomaly,
    suggest fixing the error cells to use the working pattern.
    """
    pattern_group = groups.get("pattern_anomaly")
    value_error_group = groups.get("value_error")

    if not pattern_group or not value_error_group:
        return

    # Check if pattern_anomaly cells are all working
    pattern_cells = pattern_group.get("cells", [])
    pattern_has_errors = any(
        _is_error_value(cells_map.get(c, {}).get("value"))
        for c in pattern_cells
    )

    if pattern_has_errors or not pattern_cells:
        return  # Pattern cells have errors too, can't use them as template

    # Get a working cell and its formula as template
    working_cell = pattern_cells[0]
    working_meta = cells_map.get(working_cell, {})
    working_formula = working_meta.get("formula", "")

    if not working_formula:
        return

    # Get the column of the working pattern
    working_col = _extract_column(working_cell)
    if not working_col:
        return

    # Check which error cells are in the same column
    error_cells = value_error_group.get("cells", [])
    same_col_errors = [c for c in error_cells if _extract_column(c) == working_col]

    if not same_col_errors:
        return

    # Generate fixes for error cells using the working pattern
    fixes = []
    for error_cell in same_col_errors:
        new_formula = _generate_pattern_fix(error_cell, working_formula, working_cell)
        if new_formula:
            error_meta = cells_map.get(error_cell, {})
            fixes.append({
                "cell": error_cell,
                "expected_original": error_meta.get("formula", ""),
                "proposed": new_formula,
            })

    if fixes:
        # Update the value_error group with the suggested fixes
        value_error_group["fixes"] = fixes
        value_error_group["fix_available"] = True
        value_error_group["fix_confidence_band"] = "LIKELY"
        value_error_group["recommended_action"] = "preview_apply"
        value_error_group["sample_fixed"] = fixes[0]["proposed"] if fixes else None

        # Update the explanation
        working_list = ", ".join(pattern_cells[:3])
        if len(pattern_cells) > 3:
            working_list += f" (+{len(pattern_cells) - 3} more)"

        value_error_group["explanation"] = (
            "These formulas fail because they reference text values.\n\n"
            f"Current pattern: {value_error_group.get('sample_original', '(unknown)')}\n"
            f"Working pattern (from {working_list}): {working_formula}\n\n"
            f"💡 Fix available: Change these {len(fixes)} cells to use the working pattern."
        )


def compute_health_score(issues: List[Dict[str, Any]], formula_count: int) -> int:
    errors = [i for i in issues if i.get("severity") == "error"]
    warnings = [i for i in issues if i.get("severity") == "warning"]
    score = 100 - (len(errors) * 5) - (len(warnings) * 2)
    if formula_count and (len(errors) / max(formula_count, 1)) < 0.01:
        score += 10
    return max(0, min(100, score))


def build_scan_payload(
    cells: List[Dict[str, Any]],
    existing_sheets: List[str],
    scan_id: str,
) -> Dict[str, Any]:
    cells_map = build_cells_map(cells)
    issues, plan = collect_issues(cells_map, existing_sheets)
    issue_groups = build_issue_groups(issues, scan_id=scan_id, cells_map=cells_map)

    formula_count = sum(
        1
        for meta in cells_map.values()
        if isinstance(meta.get("formula"), str) and str(meta.get("formula")).startswith("=")
    )
    health_score = compute_health_score(issues, formula_count)
    health = {"score": health_score, "grade": repair.health_grade(health_score)}
    cell_graph = gremlin_graph.build_dependency_graph(cells_map, default_sheet=None)

    return {
        "health": health,
        "issue_groups": issue_groups,
        "cells_map": cells_map,
        "cell_graph": cell_graph,
    }


def build_scan_cache_payload(payload: Dict[str, Any]) -> str:
    cache_payload = {
        "cells": payload.get("cells_map", {}),
        "graph": payload.get("cell_graph", {}),
        "issue_groups": {
            group.get("type"): {
                "cells": group.get("cells", []),
                "issue_id": group.get("issue_id"),
            }
            for group in payload.get("issue_groups", [])
        },
    }
    return json.dumps(cache_payload, ensure_ascii=True)


def resolve_issue_group(
    cached: Dict[str, Any],
    issue_type: str,
) -> Optional[Dict[str, Any]]:
    groups = cached.get("issue_groups") or {}
    group = groups.get(issue_type)
    if group:
        return group
    return None
