from __future__ import annotations

"""Openpyxl-backed semantic extractor for formulas and cell data."""

import os
import re
import zipfile
from concurrent.futures import ProcessPoolExecutor, as_completed
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Iterator
from xml.etree import ElementTree as ET

from excelminer.backends.base import AnalysisContext, BackendReport
from excelminer.backends.formula_utils import (
    a1_to_rowcol,
    cell_in_ranges,
    parse_a1_range,
    pivot_ranges_by_sheet,
)
from excelminer.backends.ooxml_zip import OOXMLZipContext
from excelminer.model.entities import FormulaCell, Sheet
from excelminer.model.graph import WorkbookGraph

_A1_REF_RE = re.compile(r"^\$?[A-Z]{1,3}\$?\d+$", re.IGNORECASE)
_CELL_RANGE_RE = re.compile(
    r"(?:(?P<sheet>'[^']+'|[A-Za-z0-9_ ]+|'\[[^]]+\][^']+'|\[[^]]+\][A-Za-z0-9_ ]+)!)?"
    r"(?P<cell>\$?[A-Z]{1,3}\$?\d+)"
    r"(?::(?P<cell2>\$?[A-Z]{1,3}\$?\d+))?",
)
_STRUCTURED_REF_RE = re.compile(
    r"(?<![A-Za-z0-9_])(?P<structured>[A-Za-z_][A-Za-z0-9_ ]*\[[^\]]+\])",
)
_NAME_TOKEN_RE = re.compile(
    r"(?<![A-Za-z0-9_.])(?P<name>[A-Za-z_][A-Za-z0-9_.]*)\b",
)


def _localname(tag: str) -> str:
    if "}" in tag:
        return tag.split("}", 1)[1]
    return tag


@dataclass(slots=True)
class _SheetInfo:
    index: int
    name: str
    key: str
    part: str | None


def _normalize_sheet_name(s: str) -> str:
    """Return a normalized sheet name without surrounding quotes."""
    s = (s or "").strip()
    if s.startswith("'") and s.endswith("'") and len(s) >= 2:
        s = s[1:-1]
    return s


def _sheet_key(name: str) -> str:
    """Build a stable key for sheet entity deduplication."""
    return re.sub(r"\s+", " ", (name or "").strip())


def _chunk_bounds(limit: int, chunk_size: int) -> Iterator[tuple[int, int]]:
    """Yield inclusive start/end bounds for a chunked range."""
    for start in range(1, limit + 1, chunk_size):
        end = min(limit, start + chunk_size - 1)
        yield start, end


def _sheet_info_from_ooxml(
    zip_ctx: OOXMLZipContext, zf: zipfile.ZipFile | None = None
) -> list[_SheetInfo]:
    """Return workbook sheet order + part paths from workbook.xml."""
    wb_xml = zip_ctx.workbook_xml(zf)
    if not wb_xml:
        return []

    try:
        wb_root = ET.fromstring(wb_xml)
    except ET.ParseError:
        return []

    rid_to_target: dict[str, str] = {}
    wb_rels = zip_ctx.workbook_rels_xml(zf)
    if wb_rels:
        try:
            rels_root = ET.fromstring(wb_rels)
            for rel in rels_root.iter():
                if _localname(rel.tag) != "Relationship":
                    continue
                rid = rel.attrib.get("Id") or ""
                target = rel.attrib.get("Target") or ""
                rel_type = rel.attrib.get("Type") or ""
                if not rid or not target:
                    continue
                target_norm = target.replace("\\", "/")
                if "worksheet" not in rel_type and "worksheets/" not in target_norm:
                    continue
                target_norm = target_norm.lstrip("/")
                if target_norm.startswith("xl/"):
                    part = target_norm
                else:
                    part = os.path.normpath(os.path.join("xl", target_norm)).replace(
                        "\\", "/"
                    )
                rid_to_target[rid] = part
        except ET.ParseError:
            pass

    infos: list[_SheetInfo] = []
    idx = 0
    for el in wb_root.iter():
        if _localname(el.tag) != "sheet":
            continue
        name = (el.attrib.get("name") or "").strip()
        if not name:
            continue
        idx += 1
        rid = ""
        for k, v in el.attrib.items():
            if k.endswith("}id") or k == "r:id":
                rid = v
                break
        part = rid_to_target.get(rid)
        infos.append(_SheetInfo(index=idx, name=name, key=_sheet_key(name), part=part))
    return infos


def _read_sheet_dimension_xml(sheet_xml: bytes) -> tuple[int, int] | None:
    """Return (max_row, max_col) from a worksheet XML payload."""
    if not sheet_xml:
        return None
    try:
        root = ET.fromstring(sheet_xml)
    except ET.ParseError:
        return None

    for el in root.iter():
        if _localname(el.tag) != "dimension":
            continue
        ref = (el.attrib.get("ref") or "").strip()
        if not ref:
            continue
        rng = parse_a1_range(ref)
        if rng is None:
            continue
        _, _, max_row, max_col = rng
        return max_row, max_col

    max_row = 0
    max_col = 0
    for el in root.iter():
        if _localname(el.tag) != "c":
            continue
        ref = (el.attrib.get("r") or "").strip()
        rc = a1_to_rowcol(ref)
        if rc is None:
            continue
        r, c = rc
        if r > max_row:
            max_row = r
        if c > max_col:
            max_col = c
    if max_row and max_col:
        return max_row, max_col
    return None


def _iter_formula_cells_xml(sheet_xml: bytes) -> Iterator[tuple[str, str]]:
    """Yield (address, formula) from worksheet XML."""
    if not sheet_xml:
        return
    try:
        root = ET.fromstring(sheet_xml)
    except ET.ParseError:
        return

    shared: dict[str, str] = {}
    for cell in root.iter():
        if _localname(cell.tag) != "c":
            continue
        addr = (cell.attrib.get("r") or "").strip()
        if not addr:
            continue

        formula_text = ""
        for child in list(cell):
            if _localname(child.tag) != "f":
                continue
            text = (child.text or "").strip()
            f_type = (child.attrib.get("t") or "").strip()
            if f_type == "shared":
                si = (child.attrib.get("si") or "").strip()
                if text:
                    shared[si] = text
                else:
                    text = shared.get(si, "")
            if text:
                formula_text = text
            break

        if not formula_text:
            continue
        if not formula_text.startswith("="):
            formula_text = f"={formula_text}"
        yield addr, formula_text


@contextmanager
def _formula_executor(max_workers: int | None) -> Iterator[ProcessPoolExecutor | None]:
    """Context manager that yields a process pool executor or None."""
    if max_workers is not None and max_workers <= 1:
        yield None
        return

    try:
        executor = ProcessPoolExecutor(max_workers=max_workers)
    except Exception:  # noqa: BLE001
        yield None
        return

    try:
        yield executor
    finally:
        executor.shutdown(wait=True, cancel_futures=True)


def _resolve_formula_deps(
    formula_batch: list[tuple[str, str, str]],
    *,
    executor: ProcessPoolExecutor | None,
    report: BackendReport,
) -> list[tuple[str, str, str, dict[str, object]]]:
    """Resolve formula dependencies with optional parallelism."""
    if not formula_batch:
        return []

    results: list[tuple[str, str, str, dict[str, object]] | None] = [None] * len(
        formula_batch
    )

    if executor is None:
        for idx, (sheet_name, address, formula) in enumerate(formula_batch):
            try:
                deps = _extract_deps(formula)
            except Exception as exc:  # noqa: BLE001
                report.add_issue(
                    "dependency extraction failed",
                    kind="runtime",
                    detail=f"{sheet_name}!{address}: {exc}",
                )
                deps = {}
            results[idx] = (sheet_name, address, formula, deps)
        return [r for r in results if r is not None]

    try:
        futures = {
            executor.submit(_extract_deps, formula): (idx, sheet_name, address, formula)
            for idx, (sheet_name, address, formula) in enumerate(formula_batch)
        }
    except Exception as exc:  # noqa: BLE001
        report.add_issue(
            "dependency extraction failed",
            kind="runtime",
            detail=str(exc),
        )
        return _resolve_formula_deps(formula_batch, executor=None, report=report)

    for future in as_completed(futures):
        idx, sheet_name, address, formula = futures[future]
        try:
            deps = future.result()
        except Exception as exc:  # noqa: BLE001
            report.add_issue(
                "dependency extraction failed",
                kind="runtime",
                detail=f"{sheet_name}!{address}: {exc}",
            )
            deps = {}
        results[idx] = (sheet_name, address, formula, deps)

    return [r for r in results if r is not None]


def _extract_deps(formula: str) -> dict[str, object]:
    """Extract simple cell references from a formula string."""
    if not formula:
        return {}

    refs: list[dict[str, str]] = []
    seen: set[tuple[str, ...]] = set()
    spans: list[tuple[int, int]] = []

    def overlaps(span: tuple[int, int]) -> bool:
        for start, end in spans:
            if span[0] < end and span[1] > start:
                return True
        return False

    def add_ref(ref: dict[str, str], span: tuple[int, int]) -> None:
        key = (
            ref.get("kind", "cell"),
            ref.get("workbook", ""),
            ref.get("sheet", ""),
            ref.get("cell", ""),
            ref.get("range", ""),
            ref.get("structured", ""),
            ref.get("name", ""),
        )
        if key in seen:
            return
        seen.add(key)
        refs.append(ref)
        spans.append(span)

    for m in _STRUCTURED_REF_RE.finditer(formula):
        structured = (m.group("structured") or "").strip()
        if structured:
            add_ref({"kind": "structured", "structured": structured}, m.span())

    for m in _CELL_RANGE_RE.finditer(formula):
        sheet_token = _normalize_sheet_name(m.group("sheet") or "")
        cell = (m.group("cell") or "").replace("$", "")
        cell2 = (m.group("cell2") or "").replace("$", "")

        workbook = ""
        sheet = sheet_token
        if sheet_token.startswith("[") and "]" in sheet_token:
            workbook, sheet = sheet_token[1:].split("]", 1)

        if workbook:
            if cell2:
                add_ref(
                    {
                        "kind": "external",
                        "workbook": workbook,
                        "sheet": sheet,
                        "range": f"{cell}:{cell2}",
                    },
                    m.span(),
                )
            else:
                add_ref(
                    {
                        "kind": "external",
                        "workbook": workbook,
                        "sheet": sheet,
                        "cell": cell,
                    },
                    m.span(),
                )
            continue

        if cell2:
            add_ref(
                {
                    "kind": "range",
                    "sheet": sheet,
                    "range": f"{cell}:{cell2}",
                },
                m.span(),
            )
        else:
            add_ref({"sheet": sheet, "cell": cell}, m.span())

    for m in _NAME_TOKEN_RE.finditer(formula):
        name = (m.group("name") or "").strip()
        if not name or overlaps(m.span()):
            continue
        if _A1_REF_RE.match(name):
            continue

        rest = formula[m.end() :].lstrip()
        if rest[:1] in ("(", "!", "[", ":"):
            continue

        add_ref({"kind": "name", "name": name}, m.span())

    return {"refs": refs} if refs else {}


@dataclass(slots=True)
class OpenpyxlBackend:
    """Semantic backend: scans worksheets for formulas and basic cell stats.

    Notes:
    - openpyxl does not evaluate formulas; it reads the formula text.
    - This backend is best used after OOXMLZipBackend has registered sheets.
    """

    name: str = "openpyxl"

    def can_handle(self, ctx: AnalysisContext) -> bool:
        """Return True when openpyxl can parse the workbook and options require it."""
        p = ctx.path
        if not p.exists() or not p.is_file():
            return False
        if not (ctx.options.include_formulas or ctx.options.include_cells):
            return False
        return p.suffix.lower() in (".xlsx", ".xlsm", ".xltx", ".xltm")

    def extract(self, ctx: AnalysisContext, graph: WorkbookGraph) -> BackendReport:
        """Scan worksheets for formulas and register them in the graph."""
        report = BackendReport(backend=self.name)

        try:
            from openpyxl import load_workbook
            from openpyxl.utils import get_column_letter
        except Exception as e:  # noqa: BLE001
            report.add_issue("openpyxl import failed", kind="dependency", detail=str(e))
            return report

        try:
            wb = load_workbook(
                filename=str(ctx.path),
                read_only=True,
                data_only=False,
                keep_links=False,
            )
        except Exception as e:  # noqa: BLE001
            report.add_issue(
                "failed to load workbook", kind="parse_error", detail=str(e)
            )
            return report

        formula_count = 0
        sheets_scanned = 0
        formula_sheets_scanned = 0

        options = ctx.options
        allowed_sheet_keys = {
            _sheet_key(n) for n in (options.formula_sheet_names or []) if str(n).strip()
        } or None
        allowed_sheet_indexes = set(options.formula_sheet_indexes or []) or None
        pivot_ranges = (
            pivot_ranges_by_sheet(graph) if options.formula_skip_pivot_cells else {}
        )

        zip_ctx = OOXMLZipContext.from_path(ctx.path)
        zf: zipfile.ZipFile | None = None
        sheet_infos: list[_SheetInfo] = []
        info_by_key: dict[str, _SheetInfo] = {}
        try:
            zf = zipfile.ZipFile(ctx.path)
            sheet_infos = _sheet_info_from_ooxml(zip_ctx, zf)
            info_by_key = {info.key: info for info in sheet_infos}
        except zipfile.BadZipFile:
            zf = None

        def emit_formula_batch(
            formula_batch: list[tuple[str, str, str]], sheet_node: Sheet
        ) -> None:
            nonlocal formula_count
            for (
                sheet_name,
                address,
                formula,
                deps,
            ) in _resolve_formula_deps(formula_batch, executor=executor, report=report):
                key = f"{sheet_name}!{address}"
                node = graph.upsert(
                    FormulaCell.make(
                        key=key,
                        id=f"formula:{key}",
                        sheet_name=sheet_name,
                        address=address,
                        formula=formula,
                        deps=deps,
                    )
                )
                graph.add_edge(sheet_node.id, node.id, "contains")
                formula_count += 1

        try:
            worker_limit = options.formula_decomposition_workers
            with _formula_executor(worker_limit) as executor:
                for idx, ws in enumerate(wb.worksheets, start=1):
                    sheets_scanned += 1
                    if (
                        options.max_sheets is not None
                        and allowed_sheet_keys is None
                        and allowed_sheet_indexes is None
                        and sheets_scanned > options.max_sheets
                    ):
                        break

                    sheet_key = _sheet_key(ws.title)
                    sheet_id = f"sheet:{sheet_key}"
                    sheet_node = graph.get_by_key("sheet", sheet_key)
                    if not sheet_node:
                        sheet_node = graph.upsert(
                            Sheet.make(key=sheet_key, id=sheet_id, name=ws.title)
                        )

                    if not options.include_formulas:
                        continue

                    sheet_info = info_by_key.get(sheet_key)
                    sheet_index = sheet_info.index if sheet_info else idx
                    sheet_part = sheet_info.part if sheet_info else None

                    if (
                        allowed_sheet_keys is not None
                        and sheet_key not in allowed_sheet_keys
                    ):
                        continue
                    if (
                        allowed_sheet_indexes is not None
                        and sheet_index not in allowed_sheet_indexes
                    ):
                        continue

                    if (
                        options.max_sheets is not None
                        and formula_sheets_scanned >= options.max_sheets
                    ):
                        continue
                    formula_sheets_scanned += 1

                    actual_max_row = ws.max_row or 0
                    actual_max_col = ws.max_column or 0
                    if actual_max_row == 0 or actual_max_col == 0:
                        continue

                    scan_mode = options.formula_scan_mode
                    use_xml = scan_mode == "xml"
                    if scan_mode == "auto":
                        threshold = options.formula_large_sheet_cells
                        if (
                            threshold is not None
                            and actual_max_row * actual_max_col > threshold
                        ):
                            use_xml = True

                    sheet_ranges = pivot_ranges.get(sheet_key, [])
                    sheet_xml: bytes | None = None
                    if use_xml:
                        if zf is None or sheet_part is None:
                            if scan_mode == "xml":
                                report.add_issue(
                                    f"{ws.title}: XML scan unavailable; falling back to openpyxl",
                                    kind="runtime",
                                )
                            use_xml = False
                        else:
                            sheet_xml = zip_ctx.read_member(
                                sheet_part, zf=zf, cache=True
                            )
                            if not sheet_xml:
                                if scan_mode == "xml":
                                    report.add_issue(
                                        f"{ws.title}: worksheet XML missing; falling back to openpyxl",
                                        kind="parse_error",
                                    )
                                use_xml = False

                    if use_xml and sheet_xml:
                        max_rows = options.max_rows_per_sheet
                        max_cols = options.max_cols_per_sheet
                        max_cells = options.max_cells_per_sheet
                        formula_batch: list[tuple[str, str, str]] = []
                        batch_size = 500
                        formula_cells = 0

                        for addr, formula in _iter_formula_cells_xml(sheet_xml):
                            rc = a1_to_rowcol(addr)
                            if rc is None:
                                continue
                            row_idx, col_idx = rc
                            if max_rows is not None and row_idx > max_rows:
                                continue
                            if max_cols is not None and col_idx > max_cols:
                                continue
                            if sheet_ranges and cell_in_ranges(
                                row_idx, col_idx, sheet_ranges
                            ):
                                continue
                            formula_cells += 1
                            if max_cells is not None and formula_cells > max_cells:
                                break
                            formula_batch.append((ws.title, addr, formula))
                            if len(formula_batch) >= batch_size:
                                emit_formula_batch(formula_batch, sheet_node)
                                formula_batch = []

                        if formula_batch:
                            emit_formula_batch(formula_batch, sheet_node)
                        continue

                    scanned_cells = 0
                    max_row = actual_max_row
                    max_col = actual_max_col
                    if options.max_rows_per_sheet is not None:
                        max_row = min(max_row, options.max_rows_per_sheet)
                    if options.max_cols_per_sheet is not None:
                        max_col = min(max_col, options.max_cols_per_sheet)
                    if max_row == 0 or max_col == 0:
                        continue

                    row_chunk = max(1, options.row_chunk_size)
                    col_chunk = max(1, options.column_chunk_size)
                    hit_cell_limit = False

                    for row_start, row_end in _chunk_bounds(max_row, row_chunk):
                        if hit_cell_limit:
                            break
                        for col_start, col_end in _chunk_bounds(max_col, col_chunk):
                            if hit_cell_limit:
                                break
                            formula_batch = []
                            col_letters = [
                                get_column_letter(c)
                                for c in range(col_start, col_end + 1)
                            ]
                            for row_offset, row in enumerate(
                                ws.iter_rows(
                                    min_row=row_start,
                                    max_row=row_end,
                                    min_col=col_start,
                                    max_col=col_end,
                                    values_only=True,
                                ),
                                start=0,
                            ):
                                row_idx = row_start + row_offset
                                for col_offset, v in enumerate(row, start=0):
                                    scanned_cells += 1
                                    if (
                                        options.max_cells_per_sheet is not None
                                        and scanned_cells > options.max_cells_per_sheet
                                    ):
                                        hit_cell_limit = True
                                        break

                                    if not isinstance(v, str) or not v.startswith("="):
                                        continue

                                    col_idx = col_start + col_offset
                                    if sheet_ranges and cell_in_ranges(
                                        row_idx, col_idx, sheet_ranges
                                    ):
                                        continue

                                    addr = f"{col_letters[col_offset]}{row_idx}"
                                    formula_batch.append((ws.title, addr, v))
                                if hit_cell_limit:
                                    break

                            if formula_batch:
                                emit_formula_batch(formula_batch, sheet_node)
        finally:
            if zf is not None:
                try:
                    zf.close()
                except Exception:
                    pass

        report.stats = {
            "sheets_scanned": sheets_scanned,
            "formula_cells": formula_count,
            **graph.stats(),
        }
        return report
