"""
Formula linting utilities for g-gremlin.
"""

from __future__ import annotations

import csv
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from g_gremlin.graph import parse_formula_references
from g_gremlin.a1 import col_index_to_letter

# Map of issue type -> (severity, fixable)
ERROR_TYPES: dict[str, tuple[str, bool]] = {
    "unquoted_sheet_ref": ("error", True),
    "ref_error": ("error", False),
    "name_error": ("error", False),
    "value_error": ("error", False),
    "div_zero_error": ("error", False),
    "nonexistent_sheet_ref": ("error", False),
    "offset_wrong_args": ("error", False),
    "circular_ref": ("error", False),
    "iferror_missing_fallback": ("warning", False),
    "mixed_reference_style": ("warning", False),
    "hardcoded_in_formula": ("warning", False),
    "very_long_formula": ("warning", False),
}

# Human-friendly rule aliases for CLI
RULE_ALIASES: dict[str, list[str]] = {
    "no-hardcoded": ["hardcoded_in_formula"],
    "no-hardcoded-rows": ["hardcoded_in_formula"],  # alias
    "valid-refs": ["ref_error", "name_error", "nonexistent_sheet_ref"],
    "no-circular": ["circular_ref"],
    "no-errors": ["ref_error", "name_error", "value_error", "div_zero_error"],
    "dead-refs": ["nonexistent_sheet_ref"],  # User-friendly: catch broken sheet references
    "errors": ["ref_error", "name_error", "value_error", "div_zero_error"],  # Alias for no-errors
    "offset-check": ["offset_wrong_args"],
    "quoting": ["unquoted_sheet_ref"],
    "iferror-check": ["iferror_missing_fallback"],
    "mixed-refs": ["mixed_reference_style"],
    "long-formulas": ["very_long_formula"],
    "all": list(ERROR_TYPES.keys()),
}


def expand_rule_aliases(rules: List[str]) -> set[str]:
    """Expand human-friendly rule names to internal error types."""
    expanded: set[str] = set()
    for rule in rules:
        rule_lower = rule.lower().strip()
        if rule_lower in RULE_ALIASES:
            expanded.update(RULE_ALIASES[rule_lower])
        elif rule_lower in ERROR_TYPES:
            expanded.add(rule_lower)
    return expanded


def check_unquoted_sheet_refs(formula: str) -> List[Dict[str, Any]]:
    """
    Find sheet references that need quoting but are not quoted.
    """
    issues: List[Dict[str, Any]] = []
    pattern = r"(?<!')([A-Za-z0-9_][A-Za-z0-9_ \-]*[A-Za-z0-9_])!"
    for match in re.finditer(pattern, formula):
        sheet_name = match.group(1)
        if " " in sheet_name or "-" in sheet_name:
            fixed = formula.replace(f"{sheet_name}!", f"'{sheet_name}'!")
            issues.append(
                {
                    "type": "unquoted_sheet_ref",
                    "sheet_name": sheet_name,
                    "message": f"Sheet name '{sheet_name}' contains spaces/hyphens and must be quoted",
                    "formula": formula,
                    "suggested_fix": fixed,
                }
            )
    return issues


def check_error_values(value: Any) -> Dict[str, Any] | None:
    """
    Return an error issue if value is an error string.
    """
    if not isinstance(value, str):
        return None
    error_map = {
        "#REF!": "ref_error",
        "#NAME?": "name_error",
        "#VALUE!": "value_error",
        "#DIV/0!": "div_zero_error",
    }
    for token, issue_type in error_map.items():
        if token in value:
            return {
                "type": issue_type,
                "message": f"Formula returns {token} error",
                "value": value,
            }
    return None


def check_nonexistent_sheet_refs(formula: str, existing_sheets: List[str]) -> List[Dict[str, Any]]:
    """
    Detect sheet references to tabs that do not exist.
    """
    issues: List[Dict[str, Any]] = []
    existing_lower = {s.lower() for s in existing_sheets}
    # Both quoted and unquoted refs
    quoted = re.findall(r"'([^']+)'!", formula)
    unquoted = re.findall(r"(?<!')([A-Za-z0-9_][A-Za-z0-9_ \-]*[A-Za-z0-9_])!", formula)
    for ref in quoted + unquoted:
        if ref.lower() not in existing_lower:
            issues.append(
                {
                    "type": "nonexistent_sheet_ref",
                    "message": f"References sheet '{ref}' which does not exist",
                    "sheet_name": ref,
                    "formula": formula,
                }
            )
    return issues


def _split_top_level(arg_str: str) -> List[str]:
    args: List[str] = []
    current: List[str] = []
    depth = 0
    for ch in arg_str:
        if ch == "," and depth == 0:
            args.append("".join(current).strip())
            current = []
            continue
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth = max(0, depth - 1)
        current.append(ch)
    if current:
        args.append("".join(current).strip())
    return args


def check_offset_args(formula: str) -> List[Dict[str, Any]]:
    """
    Ensure OFFSET calls have between 3 and 5 args.
    """
    issues: List[Dict[str, Any]] = []
    for match in re.finditer(r"OFFSET\s*\(([^)]*)\)", formula, re.IGNORECASE):
        args = _split_top_level(match.group(1))
        if len(args) < 3 or len(args) > 5:
            issues.append(
                {
                    "type": "offset_wrong_args",
                    "message": f"OFFSET requires 3-5 args, found {len(args)}",
                    "args_found": len(args),
                    "formula": formula,
                }
            )
    return issues


def check_iferror_args(formula: str) -> List[Dict[str, Any]]:
    issues: List[Dict[str, Any]] = []
    for match in re.finditer(r"IFERROR\s*\(([^)]*)\)", formula, re.IGNORECASE):
        args = _split_top_level(match.group(1))
        if len(args) < 2:
            issues.append(
                {
                    "type": "iferror_missing_fallback",
                    "message": "IFERROR has only 1 argument; add a fallback.",
                    "formula": formula,
                }
            )
    return issues


def check_mixed_reference_style(formula: str) -> Dict[str, Any] | None:
    """
    Flag formulas mixing absolute ($A$1) and relative (A1) refs.
    """
    has_abs = bool(re.search(r"\$[A-Za-z]+\$?\d+", formula))
    has_rel = bool(re.search(r"(?<!\$)[A-Za-z]{1,3}\d+", formula))
    if has_abs and has_rel:
        return {
            "type": "mixed_reference_style",
            "message": "Formula mixes absolute and relative references.",
            "formula": formula,
        }
    return None


def check_hardcoded_numbers(formula: str) -> Dict[str, Any] | None:
    """
    Rough check for magic numbers in formulas.
    """
    if re.search(r"[=+\-*/(,]\s*-?\d+(\.\d+)?", formula):
        return {
            "type": "hardcoded_in_formula",
            "message": "Formula contains hardcoded numeric constants.",
            "formula": formula,
        }
    return None


def check_formula_length(formula: str) -> Dict[str, Any] | None:
    if len(formula) > 500:
        return {
            "type": "very_long_formula",
            "message": f"Formula is {len(formula)} characters (maintainability risk).",
            "length": len(formula),
            "formula": formula[:120] + "...",
        }
    return None


def check_circular_ref(cell: str, formula: str) -> Dict[str, Any] | None:
    """
    Direct self-reference detection.
    """
    refs = parse_formula_references(formula)
    cell_core = cell.split("!", 1)[-1]
    for ref in refs:
        ref_core = ref.split("!", 1)[-1]
        if ref_core == cell_core:
            return {
                "type": "circular_ref",
                "message": "Formula references its own cell (direct circular reference).",
                "formula": formula,
            }
    return None


def lint_formula(cell: str, formula: str, value: Any, existing_sheets: List[str]) -> List[Dict[str, Any]]:
    issues: List[Dict[str, Any]] = []

    error_issue = check_error_values(value)
    if error_issue:
        error_issue["cell"] = cell
        sev, fix = ERROR_TYPES.get(error_issue["type"], ("error", False))
        error_issue["severity"] = sev
        error_issue["fixable"] = fix
        issues.append(error_issue)

    if not (isinstance(formula, str) and formula.startswith("=")):
        return issues

    for func in (
        check_unquoted_sheet_refs,
        check_nonexistent_sheet_refs,
        check_offset_args,
        check_iferror_args,
    ):
        for issue in func(formula) if func is check_unquoted_sheet_refs or func is check_offset_args or func is check_iferror_args else func(formula, existing_sheets):  # type: ignore[arg-type]
            issue["cell"] = cell
            sev, fix = ERROR_TYPES.get(issue["type"], ("error", False))
            issue["severity"] = sev
            issue["fixable"] = fix
            issues.append(issue)

    circ = check_circular_ref(cell, formula)
    if circ:
        circ["cell"] = cell
        sev, fix = ERROR_TYPES.get(circ["type"], ("error", False))
        circ["severity"] = sev
        circ["fixable"] = fix
        issues.append(circ)

    mixed = check_mixed_reference_style(formula)
    if mixed:
        mixed["cell"] = cell
        sev, fix = ERROR_TYPES.get(mixed["type"], ("warning", False))
        mixed["severity"] = sev
        mixed["fixable"] = fix
        issues.append(mixed)

    hardcoded = check_hardcoded_numbers(formula)
    if hardcoded:
        hardcoded["cell"] = cell
        sev, fix = ERROR_TYPES.get(hardcoded["type"], ("warning", False))
        hardcoded["severity"] = sev
        hardcoded["fixable"] = fix
        issues.append(hardcoded)

    long_formula = check_formula_length(formula)
    if long_formula:
        long_formula["cell"] = cell
        sev, fix = ERROR_TYPES.get(long_formula["type"], ("warning", False))
        long_formula["severity"] = sev
        long_formula["fixable"] = fix
        issues.append(long_formula)

    return issues


def generate_lint_report(issues: List[Dict[str, Any]]) -> Dict[str, Any]:
    errors = [i for i in issues if i.get("severity") == "error"]
    warnings = [i for i in issues if i.get("severity") == "warning"]
    fixable = [i for i in issues if i.get("fixable")]
    return {
        "lint_results": {
            "summary": {
                "total_issues": len(issues),
                "errors": len(errors),
                "warnings": len(warnings),
                "fixable": len(fixable),
            },
            "issues": issues,
            "fixes_available": [
                {
                    "cell": i.get("cell"),
                    "type": i.get("type"),
                    "original": i.get("formula"),
                    "fixed": i.get("suggested_fix"),
                }
                for i in fixable
                if i.get("suggested_fix")
            ],
        }
    }


def validate_csv_file(csv_path: Path, allowed_refs: Optional[List[str]] = None) -> Dict[str, Any]:
    """
    Pre-flight validate a CSV for formulas: count formulas, cross-sheet refs, and common issues.
    """
    issues: List[Dict[str, Any]] = []
    formula_count = 0
    cross_sheet_refs = 0
    allowed_lower = {r.strip().lower() for r in (allowed_refs or []) if r.strip()}

    with csv_path.open("r", newline="", encoding="utf-8-sig") as handle:
        reader = csv.reader(handle)
        rows = list(reader)

    for r_idx, row in enumerate(rows):
        for c_idx, cell in enumerate(row):
            if not isinstance(cell, str):
                continue
            a1 = f"{col_index_to_letter(c_idx + 1)}{r_idx + 1}"

            err_issue = check_error_values(cell)
            if err_issue:
                err_issue.update({"cell": a1, "severity": "error", "fixable": False})
                issues.append(err_issue)

            if not cell.startswith("="):
                continue
            formula_count += 1

            for issue in check_unquoted_sheet_refs(cell):
                issue.update({"cell": a1, "severity": "error", "fixable": True})
                issues.append(issue)

            refs = parse_formula_references(cell)
            for ref in refs:
                if "!" in ref:
                    cross_sheet_refs += 1
                    sheet_name = ref.split("!", 1)[0].strip("'")
                    if allowed_lower and sheet_name.lower() not in allowed_lower:
                        issues.append(
                            {
                                "cell": a1,
                                "type": "nonexistent_sheet_ref",
                                "severity": "error",
                                "fixable": False,
                                "message": f"References sheet '{sheet_name}' which is not in allowed refs.",
                                "sheet_name": sheet_name,
                                "formula": cell,
                            }
                        )

    errors = [i for i in issues if i.get("severity") == "error"]
    warnings = [i for i in issues if i.get("severity") == "warning"]
    fixable = [i for i in issues if i.get("fixable")]

    return {
        "summary": {
            "total_formulas": formula_count,
            "cross_sheet_refs": cross_sheet_refs,
            "errors": len(errors),
            "warnings": len(warnings),
            "fixable": len(fixable),
        },
        "issues": issues,
    }


def check_bands(csv_path: Path, band_specs: List[Tuple[int, int, str]]) -> Dict[str, Any]:
    """
    Verify that formulas within column bands reference expected sheet names.
    band_specs: list of (start_col_idx, end_col_idx, expected_sheet)
    """
    with csv_path.open("r", newline="", encoding="utf-8-sig") as handle:
        reader = csv.reader(handle)
        rows = list(reader)

    results: List[Dict[str, Any]] = []
    overall_mismatches: List[Dict[str, Any]] = []

    for start_idx, end_idx, expected in band_specs:
        formulas_checked = 0
        mismatches: List[Dict[str, Any]] = []
        for r_idx, row in enumerate(rows):
            for c_idx in range(start_idx, end_idx + 1):
                if c_idx >= len(row):
                    continue
                cell = row[c_idx]
                if not (isinstance(cell, str) and cell.startswith("=")):
                    continue
                formulas_checked += 1
                refs = parse_formula_references(cell)
                for ref in refs:
                    if "!" not in ref:
                        continue
                    sheet_name = ref.split("!", 1)[0].strip("'")
                    if sheet_name != expected:
                        a1 = f"{col_index_to_letter(c_idx + 1)}{r_idx + 1}"
                        mismatch = {
                            "cell": a1,
                            "expected": expected,
                            "found": sheet_name,
                            "formula": cell,
                        }
                        mismatches.append(mismatch)
                        overall_mismatches.append(
                            {"band": f"{col_index_to_letter(start_idx+1)}:{col_index_to_letter(end_idx+1)}", **mismatch}
                        )
        results.append(
            {
                "band": f"{col_index_to_letter(start_idx+1)}:{col_index_to_letter(end_idx+1)}",
                "expected_sheet": expected,
                "formulas_checked": formulas_checked,
                "mismatches": mismatches,
            }
        )

    return {"results": results, "mismatches": overall_mismatches}


def generate_lag_row(source_range: str, lag: int, labels: Optional[List[str]] = None) -> List[str]:
    """
    Generate a single CSV row of IFERROR(OFFSET(...)) formulas across a source range.
    """
    if ":" not in source_range:
        raise ValueError("source range must be like E5:Q5")
    start, end = source_range.split(":", 1)
    start_col = _col_letters(start)
    end_col = _col_letters(end)
    start_row = _row_number(start)
    end_row = _row_number(end)
    if start_row != end_row:
        raise ValueError("Lag generator supports single-row ranges.")
    start_idx = _letters_to_index(start_col) - 1
    end_idx = _letters_to_index(end_col) - 1
    row = labels[:] if labels else []
    for idx in range(start_idx, end_idx + 1):
        col_letter = col_index_to_letter(idx + 1)
        row.append(f"=IFERROR(OFFSET({col_letter}{start_row},0,-{lag}),0)")
    return row


def _letters_to_index(letters: str) -> int:
    value = 0
    for ch in letters.upper():
        value = value * 26 + (ord(ch) - ord("A") + 1)
    return value


def _col_letters(a1: str) -> str:
    return re.match(r"([A-Za-z]+)", a1).group(1) if re.match(r"([A-Za-z]+)", a1) else ""


def _row_number(a1: str) -> int:
    m = re.match(r"[A-Za-z]+(\d+)", a1)
    if not m:
        return 0
    return int(m.group(1))


def lint_formula_filtered(
    cell: str,
    formula: str,
    value: Any,
    existing_sheets: List[str],
    enabled_rules: Optional[set[str]] = None,
) -> List[Dict[str, Any]]:
    """
    Run lint checks but filter to only enabled rule types.
    If enabled_rules is None, run all checks.
    """
    all_issues = lint_formula(cell, formula, value, existing_sheets)
    if enabled_rules is None:
        return all_issues
    return [i for i in all_issues if i.get("type") in enabled_rules]


def detect_transitive_circular_refs(
    cells: Dict[str, Dict[str, Any]],
    default_sheet: Optional[str] = None,
) -> List[Dict[str, Any]]:
    """
    Detect transitive cycles in the dependency graph using DFS with coloring.

    Args:
        cells: Dict mapping cell A1 -> {formula, value, ...}
        default_sheet: Optional sheet name for normalizing refs

    Returns:
        List of cycle issues with cells_in_cycle
    """
    from g_gremlin.graph import build_dependency_graph

    # Build the dependency graph
    graph = build_dependency_graph(cells, default_sheet or "")

    # DFS with coloring: WHITE (0) = unvisited, GRAY (1) = in stack, BLACK (2) = done
    color: Dict[str, int] = {cell: 0 for cell in graph}
    cycles: List[List[str]] = []

    def dfs(cell: str, path: List[str]) -> None:
        if color.get(cell, 0) == 1:  # GRAY - found cycle
            cycle_start = path.index(cell) if cell in path else 0
            cycle = path[cycle_start:] + [cell]
            cycles.append(cycle)
            return
        if color.get(cell, 0) == 2:  # BLACK - already processed
            return

        color[cell] = 1  # GRAY
        path.append(cell)

        for ref in graph.get(cell, {}).get("references", []):
            if ref in graph:
                dfs(ref, path)

        path.pop()
        color[cell] = 2  # BLACK

    for cell in graph:
        if color.get(cell, 0) == 0:
            dfs(cell, [])

    # Convert cycles to issues
    issues: List[Dict[str, Any]] = []
    seen_cycles: set[tuple[str, ...]] = set()

    for cycle in cycles:
        # Normalize cycle to avoid duplicates (start from smallest element)
        if len(cycle) > 1:
            min_idx = cycle.index(min(cycle[:-1]))
            normalized = tuple(cycle[min_idx:-1])
            if normalized not in seen_cycles:
                seen_cycles.add(normalized)
                issues.append({
                    "type": "circular_ref",
                    "severity": "error",
                    "fixable": False,
                    "message": f"Transitive circular reference detected: {' -> '.join(cycle)}",
                    "cells_in_cycle": list(cycle),
                    "cycle_length": len(cycle) - 1,
                })

    return issues
