"""
xmldev
~~~~~~
Python library for validating, repairing, and normalizing XML against a
developer-defined JSON ground-truth schema.

Public API::

    from xmldev import Xmldev, SchemaLoadError

    xd = Xmldev(config_path="xmldev.config.env", fuzzy_profile="balanced")
    schema = xd.load_schema_from_file("schema.person.json")
    result = xd.validate_and_fix(broken_xml_str, schema)
"""
from __future__ import annotations

import copy
import logging
import re
from pathlib import Path
from typing import Any

from lxml import etree

from .audit import AuditWriter, Patch
from .fuzzy import FuzzyMatcher
from .llm import LLMClient, LLMConfig, LLMError
from .parser import ParseError, canonicalize, tolerant_parse, xpath_path
from .repair import DeterministicRepairer, HeuristicRepairer
from .schema import Schema, SchemaLoadError, SchemaLoader
from .validator import Validator

__all__ = ["Xmldev", "SchemaLoadError"]

logger = logging.getLogger("xmldev")


class XmldevError(RuntimeError):
    """Generic xmldev runtime error."""
    def __init__(self, code: str, message: str, details: str = "", suggested_action: str = "") -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.details = details
        self.suggested_action = suggested_action

    def to_dict(self) -> dict[str, Any]:
        return {
            "code": self.code,
            "message": self.message,
            "details": self.details,
            "suggested_action": self.suggested_action,
        }


class Xmldev:
    """
    Main entry point for the xmldev library.

    Parameters
    ----------
    config_path : str | Path | None
        Path to xmldev.config.env for LLM configuration.
    fuzzy_profile : str
        One of "strict", "balanced", "permissive".
    aggressive : bool
        Enable heuristic repair pass when deterministic fails.
    log_dir : str | Path | None
        Directory for audit log rotation. If None, logs are not persisted.
    """

    def __init__(
        self,
        config_path: str | Path | None = None,
        fuzzy_profile: str = "balanced",
        aggressive: bool = False,
        log_dir: str | Path | None = None,
    ) -> None:
        self.fuzzy_profile = fuzzy_profile
        self.aggressive = aggressive
        self.log_dir = Path(log_dir) if log_dir else None
        self._llm_config: LLMConfig | None = LLMConfig.load(config_path)

    # ------------------------------------------------------------------
    # Schema
    # ------------------------------------------------------------------

    def load_schema_from_file(self, path: str | Path) -> Schema:
        """
        Load and validate a ground-truth JSON schema from a file.

        Raises SchemaLoadError (XM01) on failure.
        """
        return SchemaLoader.load(path)

    def load_schema_from_string(self, json_str: str) -> Schema:
        """Load a schema from a JSON string."""
        return SchemaLoader.load(json_str)

    # ------------------------------------------------------------------
    # Main pipeline
    # ------------------------------------------------------------------

    def validate_and_fix(
        self,
        xml: str | bytes,
        schema: Schema,
        allow_llm: bool | None = None,
        max_renames: int | None = None,
    ) -> dict[str, Any]:
        """
        Validate and repair XML against the given schema.

        Parameters
        ----------
        xml : str | bytes
            Raw XML input.
        schema : Schema
            Loaded schema from load_schema_from_file / load_schema_from_string.
        allow_llm : bool | None
            Override LLM usage. None = use config (default).
        max_renames : int | None
            Override max renames per document.

        Returns
        -------
        dict with keys:
            fixed_xml   : str | None
            ok          : bool
            patches     : list[dict]
            diagnostics : list[str]
            provenance  : dict  {source: "deterministic"|"heuristic"|"llm"}
            original    : str
        """
        original_str = xml if isinstance(xml, str) else xml.decode("utf-8", errors="replace")
        patches: list[Patch] = []
        diagnostics: list[str] = []
        provenance: dict[str, str] = {"source": "deterministic"}

        # ── 1. Parse ───────────────────────────────────────────────────
        try:
            parse_result = tolerant_parse(xml)
        except ParseError as exc:
            diagnostics.append(str(exc))
            return self._failure_result(original_str, diagnostics, patches)

        root = parse_result.root
        if parse_result.parse_errors:
            diagnostics.extend(parse_result.parse_errors)

        # ── 2. Canonicalize ────────────────────────────────────────────
        canonicalize(root)

        # ── 3. Build repairer & fuzzy matcher ─────────────────────────
        max_renames = max_renames or schema.global_config.max_renames_per_doc
        # Schema global thresholds override profile IF explicitly set by the user.
        # A None value means the user didn't set it → use the profile default.
        schema_name_threshold = schema.global_config.name_threshold  # may be schema default
        schema_permissive_threshold = schema.global_config.permissive_threshold
        # Only override if the schema's global block explicitly defined them.
        # We detect "explicitly set" by comparing against the GlobalConfig field defaults.
        from .schema import GlobalConfig as _GCfg
        _default_gc = _GCfg()
        explicit_name_threshold = (
            schema_name_threshold if schema_name_threshold != _default_gc.name_threshold else None
        )
        explicit_permissive_threshold = (
            schema_permissive_threshold
            if schema_permissive_threshold != _default_gc.permissive_threshold
            else None
        )
        fuzzy = FuzzyMatcher(
            profile=self.fuzzy_profile,
            max_renames=max_renames,
            name_threshold=explicit_name_threshold,
            permissive_threshold=explicit_permissive_threshold,
        )
        repairer = DeterministicRepairer(schema, fuzzy)

        # ── 4a. Alias normalization pre-pass (always runs, unconditionally) ──
        # Renames aliased tags (e.g. <fullname> → <name>) to their canonical names.
        # This must happen BEFORE violation detection because the validator accepts
        # aliases and counts them correctly — so no UNKNOWN_TAG violation is raised,
        # and repair() would never be called otherwise.
        alias_patches: list[Patch] = []
        repairer._normalize_alias_tags(root, root, schema.root, alias_patches)
        patches.extend(alias_patches)

        # ── 4b. Iterative deterministic repair (up to 3 passes) ────────
        MAX_PASSES = 3
        for pass_num in range(MAX_PASSES):
            violations = Validator(schema).validate(root)
            if not violations:
                break
            result = repairer.repair(root, violations)
            root = result.root
            patches.extend(result.patches)

        # ── 5. Re-validate ─────────────────────────────────────────────
        final_violations = Validator(schema).validate(root)
        if not final_violations:
            fixed_xml = etree.tostring(root, encoding="unicode", pretty_print=True)
            return self._success_result(original_str, fixed_xml, patches, provenance)

        # ── 6. Heuristic pass ──────────────────────────────────────────
        if self.aggressive:
            h_repairer = HeuristicRepairer(schema)
            h_patches = h_repairer.repair(root, root)
            patches.extend(h_patches)
            provenance["source"] = "heuristic"

            heuristic_violations = Validator(schema).validate(root)
            if not heuristic_violations:
                fixed_xml = etree.tostring(root, encoding="unicode", pretty_print=True)
                return self._success_result(original_str, fixed_xml, patches, provenance)

            for v in heuristic_violations:
                diagnostics.append(str(v))

        # ── 7. LLM fallback ────────────────────────────────────────────
        use_llm = allow_llm if allow_llm is not None else (self._llm_config is not None)
        if use_llm and self._llm_config:
            try:
                llm_client = LLMClient(self._llm_config)
                current_xml = etree.tostring(root, encoding="unicode")
                llm_result = llm_client.repair(schema, current_xml, diagnostics)

                # Validate LLM output
                llm_parse = tolerant_parse(llm_result)
                llm_violations = Validator(schema).validate(llm_parse.root)

                if not llm_violations:
                    canonicalize(llm_parse.root)
                    fixed_xml = etree.tostring(llm_parse.root, encoding="unicode", pretty_print=True)
                    patches.append(Patch(
                        type="rename",
                        path="/",
                        original=current_xml[:200],
                        replacement=llm_result[:200],
                        rule_id="llm_repair_v1",
                        confidence=0.8,
                        auto_applied=True,
                        source="llm",
                        notes="LLM repair accepted after validation",
                    ))
                    provenance["source"] = "llm"
                    return self._success_result(original_str, fixed_xml, patches, provenance)
                else:
                    for v in llm_violations:
                        diagnostics.append(f"LLM output violation: {v}")

            except (LLMError, ParseError) as exc:
                diagnostics.append(f"LLM fallback failed: {exc}")
        elif use_llm and not self._llm_config:
            diagnostics.append("[XM04] LLM requested but not configured (no config file or XMDEV_ALLOW_LLM=false)")

        # ── 8. Return best-effort partial fix ─────────────────────────
        for v in final_violations:
            diagnostics.append(str(v))
        fixed_xml = etree.tostring(root, encoding="unicode", pretty_print=True)
        return {
            "fixed_xml": fixed_xml,
            "ok": False,
            "patches": [p.to_dict() for p in patches],
            "diagnostics": diagnostics,
            "provenance": provenance,
            "original": original_str,
        }

    # ------------------------------------------------------------------
    # Patch utilities
    # ------------------------------------------------------------------

    def generate_patch(
        self,
        original_node: etree._Element,
        new_node: etree._Element,
        rule_id: str,
        confidence: float,
    ) -> dict[str, Any]:
        """Create a patch record comparing two element nodes."""
        return Patch(
            type="rename",
            path="",
            original=etree.tostring(original_node, encoding="unicode"),
            replacement=etree.tostring(new_node, encoding="unicode"),
            rule_id=rule_id,
            confidence=confidence,
            auto_applied=confidence >= 0.9,
            source="deterministic",
        ).to_dict()

    def apply_patches(
        self,
        xml: str | bytes,
        patches: list[dict[str, Any]],
        accept_list: list[str] | None = None,
    ) -> str:
        """
        Apply a list of patch dicts to XML.

        Parameters
        ----------
        xml : str | bytes
            Original or partially repaired XML.
        patches : list[dict]
            Patch dicts as returned by validate_and_fix.
        accept_list : list[str] | None
            If provided, only apply patches whose 'id' is in this list.
            If None, apply all patches with auto_applied=True.

        Returns
        -------
        str
            Patched XML string.
        """
        # The patches are already applied in the tree during validate_and_fix.
        # This method is for the review workflow: given the ORIGINAL xml, re-apply
        # a chosen subset of patches.
        # For the current implementation, if accept_list is provided we return
        # a note that manual replay is needed; full replay requires storing
        # the intermediate tree snapshots. We return the original for now and
        # log a warning.
        logger.warning(
            "apply_patches: full patch replay from original XML not yet implemented. "
            "Run validate_and_fix with accept_list filtering instead."
        )
        return xml if isinstance(xml, str) else xml.decode("utf-8")

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    @staticmethod
    def _success_result(
        original: str,
        fixed_xml: str,
        patches: list[Patch],
        provenance: dict[str, str],
    ) -> dict[str, Any]:
        return {
            "fixed_xml": fixed_xml,
            "ok": True,
            "patches": [p.to_dict() for p in patches],
            "diagnostics": [],
            "provenance": provenance,
            "original": original,
        }

    @staticmethod
    def _failure_result(
        original: str,
        diagnostics: list[str],
        patches: list[Patch],
    ) -> dict[str, Any]:
        return {
            "fixed_xml": None,
            "ok": False,
            "patches": [p.to_dict() for p in patches],
            "diagnostics": diagnostics,
            "provenance": {"source": "none"},
            "original": original,
        }
