from __future__ import annotations

"""OOXML VBA project detection backend."""

import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from excelminer.backends.base import AnalysisContext, BackendReport
from excelminer.backends.ooxml_zip import OOXMLZipContext, _is_ooxml_excel_names
from excelminer.model.entities import Entity
from excelminer.model.graph import WorkbookGraph


@dataclass(slots=True)
class VbaZipBackend:
    """Detect VBA projects embedded in OOXML workbooks.

    For macro-enabled OOXML formats (like `.xlsm`), VBA is typically stored in
    `xl/vbaProject.bin`.

    This backend extracts VBA module text when the optional `oletools` dependency
    is available. If it is missing, it still emits a `vba_project` node with basic
    metadata so downstream systems can detect the presence of macros.
    """

    name: str = "vba_zip"

    def can_handle(self, ctx: AnalysisContext) -> bool:
        """Return True when VBA extraction is enabled and workbook is OOXML macro."""
        p = ctx.path
        if not ctx.options.include_vba:
            return False
        if not p.exists() or not p.is_file():
            return False
        if p.suffix.lower() not in (".xlsm", ".xltm", ".xlam"):
            return False
        try:
            with zipfile.ZipFile(p):
                return True
        except zipfile.BadZipFile:
            return False

    def extract(
        self,
        ctx: AnalysisContext,
        graph: WorkbookGraph,
        zip_ctx: OOXMLZipContext | None = None,
    ) -> BackendReport:
        """Detect embedded VBA projects and emit a vba_project node."""
        report = BackendReport(backend=self.name)
        p: Path = ctx.path
        zip_ctx = zip_ctx or OOXMLZipContext.from_path(p)

        try:
            with zipfile.ZipFile(p) as zf:
                if not _is_ooxml_excel_names(zip_ctx.names_set(zf)):
                    report.add_issue("not an OOXML Excel workbook", kind="unsupported")
                    report.stats = graph.stats()
                    return report

                vba_parts: list[dict[str, Any]] = []
                for name in zip_ctx.namelist(zf):
                    if name.lower().endswith("vbaproject.bin"):
                        try:
                            info = zf.getinfo(name)
                            vba_parts.append(
                                {
                                    "part": name,
                                    "size": int(getattr(info, "file_size", 0) or 0),
                                }
                            )
                        except KeyError:
                            continue

                if not vba_parts:
                    report.stats = {"vba_projects": 0, **graph.stats()}
                    return report

                vba_parts = sorted(vba_parts, key=lambda d: str(d.get("part") or ""))

                vba_modules: list[dict[str, Any]] = []
                for part in vba_parts:
                    part_name = str(part.get("part") or "")
                    if not part_name:
                        continue
                    try:
                        part_bytes = zf.read(part_name)
                    except KeyError:
                        report.add_issue(
                            f"missing VBA part {part_name}", kind="parse_error"
                        )
                        continue
                    vba_modules.extend(
                        _extract_vba_modules(part_name, part_bytes, report)
                    )

                key = str(ctx.path.name)
                node = Entity(
                    kind="vba_project",
                    id=f"vba:{key}",
                    key=key,
                    attrs={
                        "file": str(ctx.path.name),
                        "parts": vba_parts,
                        "has_vba": True,
                        "modules": vba_modules,
                        "module_count": len(vba_modules),
                    },
                )
                graph.upsert(node)

                report.stats = {"vba_projects": 1, **graph.stats()}
                return report

        except Exception as e:  # noqa: BLE001
            report.add_issue(str(e), kind="runtime")
            report.stats = graph.stats()
            return report


def _extract_vba_modules(
    part_name: str,
    data: bytes,
    report: BackendReport,
) -> list[dict[str, Any]]:
    ole_magic = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
    if not data.startswith(ole_magic):
        return []
    try:
        from oletools.olevba import VBA_Parser  # type: ignore[import-not-found]
    except ModuleNotFoundError as exc:
        report.add_issue(
            "oletools not installed; VBA module text unavailable",
            kind="missing_optional",
            detail=str(exc),
        )
        return []

    modules: list[dict[str, Any]] = []
    vba_parser: Any | None = None
    import os
    import tempfile

    temp_filename: str | None = None
    compat_markers = (
        "missing 1 required positional argument",
        "unexpected keyword argument",
    )
    try:
        try:
            vba_parser = VBA_Parser(part_name, data=data)
        except TypeError as exc:
            msg = str(exc)
            if any(marker in msg for marker in compat_markers):
                vba_parser = None
            else:
                raise

        if vba_parser is None:
            tf = tempfile.NamedTemporaryFile(delete=False)
            try:
                tf.write(data)
                tf.flush()
                temp_filename = tf.name
            finally:
                tf.close()
            vba_parser = VBA_Parser(temp_filename)
        if not vba_parser.detect_vba_macros():
            return []
        for _, stream_path, vba_filename, vba_code in vba_parser.extract_macros():
            module_name = vba_filename or _format_stream_path(stream_path)
            code_text = _normalize_vba_code(vba_code)
            if not module_name and not code_text:
                continue
            modules.append(
                {
                    "name": module_name or "Module",
                    "code": code_text or "",
                    "part": part_name,
                    "stream_path": _format_stream_path(stream_path),
                }
            )
    except Exception as exc:  # noqa: BLE001
        msg = str(exc)
        # Treat oletools API mismatches as best-effort (no issues).
        if any(marker in msg for marker in compat_markers):
            return []
        report.add_issue(
            f"failed to parse VBA module text from {part_name}",
            kind="parse_error",
            detail=msg,
        )
    finally:
        if vba_parser is not None:
            try:
                vba_parser.close()
            except Exception:  # noqa: BLE001
                pass
        if temp_filename:
            try:
                os.unlink(temp_filename)
            except Exception:
                pass

    return modules


def _normalize_vba_code(code: Any) -> str | None:
    if code is None:
        return None
    if isinstance(code, bytes):
        return code.decode("utf-8", errors="replace")
    return str(code)


def _format_stream_path(stream_path: Any) -> str:
    if not stream_path:
        return ""
    if isinstance(stream_path, (list, tuple)):
        return "/".join(str(item) for item in stream_path)
    return str(stream_path)
