"""Dynamics 365 snapshot helpers (deterministic, read-only export).

Uses pac solution export + pac solution unpack.
Produces a manifest.json (relpath -> sha256, size, type_tag)
plus the raw unpacked folder for reference.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
import hashlib
import json
import shutil
import xml.etree.ElementTree as ET

from g_gremlin import __version__
from g_gremlin.dynamics import PacWrapper, PacRunResult


@dataclass
class ManifestEntry:
    """Single file in the solution manifest."""
    relpath: str
    sha256: str
    size: int
    type_tag: str

    def to_dict(self) -> Dict[str, Any]:
        return {
            "relpath": self.relpath,
            "sha256": self.sha256,
            "size": self.size,
            "type_tag": self.type_tag,
        }


@dataclass
class DynamicsSnapshotResult:
    out_dir: Path
    meta_path: Path
    manifest_path: Path
    solution_folder: Path
    manifest_entries: List[ManifestEntry]
    category_summary: Dict[str, int]
    warnings: List[str]
    pac_logs: List[PacRunResult]

    def to_dict(self) -> Dict[str, Any]:
        return {
            "out_dir": str(self.out_dir),
            "meta_path": str(self.meta_path),
            "manifest_path": str(self.manifest_path),
            "solution_folder": str(self.solution_folder),
            "manifest_entries": [e.to_dict() for e in self.manifest_entries],
            "category_summary": self.category_summary,
            "warnings": self.warnings,
            "pac_logs": [log.to_dict() for log in self.pac_logs],
        }


class DynamicsSnapshotter:
    """
    Creates deterministic, comparable snapshots of Dynamics 365 solutions.

    Workflow:
    1. pac solution export -> temp .zip
    2. pac solution unpack -> folder (--allowWrite Yes, --allowDelete No)
    3. Conservative normalization (whitespace, attr ordering, line endings - NOT child reorder)
    4. Build manifest.json (relpath -> sha256, size, type_tag)
    5. Write meta.json with environment + toolchain info
    """

    def __init__(self, pac: PacWrapper) -> None:
        self.pac = pac

    def snapshot(
        self,
        out_dir: Path,
        solution_name: str,
        *,
        managed: bool = False,
        allow_delete: bool = False,
    ) -> DynamicsSnapshotResult:
        out_dir = out_dir.resolve()
        out_dir.mkdir(parents=True, exist_ok=True)

        solution_folder = out_dir / "solution"
        if solution_folder.exists():
            shutil.rmtree(solution_folder)
        solution_folder.mkdir(parents=True, exist_ok=True)

        zip_path = out_dir / f"{solution_name}.zip"
        pac_logs: List[PacRunResult] = []

        export_result = self.pac.solution_export(solution_name, zip_path, managed=managed)
        pac_logs.append(export_result)
        unpack_result = self.pac.solution_unpack(zip_path, solution_folder, allow_write=True, allow_delete=allow_delete)
        pac_logs.append(unpack_result)

        if zip_path.exists():
            try:
                zip_path.unlink()
            except Exception:
                pass

        warnings: List[str] = []
        self._normalize_solution_folder(solution_folder)
        entries = self._build_manifest(solution_folder)
        category_summary = self._category_summary(entries)

        env_info: Dict[str, Any] = {}
        try:
            env_info = self.pac.org_who()
        except Exception:
            env_info = {}
            warnings.append("org_who_failed")

        solution_version = ""
        solution_friendly_name = ""
        try:
            solutions = self.pac.solution_list()
            for item in solutions:
                if str(item.get("unique_name") or "").lower() == solution_name.lower():
                    solution_version = str(item.get("version") or "")
                    solution_friendly_name = str(item.get("friendly_name") or "")
                    break
        except Exception:
            warnings.append("solution_list_failed")

        if not solution_version:
            version, unique_name = _parse_solution_xml(solution_folder)
            if version:
                solution_version = version
            if not solution_friendly_name and unique_name:
                solution_friendly_name = unique_name

        caps = self.pac.capabilities()
        meta = {
            "created_at": datetime.now(timezone.utc).isoformat(),
            "environment_url": _pick_value(env_info, ["environment_url", "environment", "org_url", "url"]),
            "organization_id": _pick_value(env_info, ["organization_id", "org_id", "organizationid", "orgid"]),
            "organization_name": _pick_value(env_info, ["organization_name", "org_name", "organizationfriendlyname"]),
            "solution_name": solution_name,
            "solution_friendly_name": solution_friendly_name,
            "solution_version": solution_version,
            "managed": managed,
            "g_gremlin_version": __version__,
            "toolchain": {
                "pac_version": caps.pac_version,
                "pac_path": caps.pac_path,
                "has_solution_pack": caps.has_solution_pack,
                "has_solution_unpack": caps.has_solution_unpack,
                "has_solution_publish": caps.has_solution_publish,
            },
            "category_summary": category_summary,
            "total_files": len(entries),
            "warnings": warnings,
        }

        meta_path = out_dir / "meta.json"
        manifest_path = out_dir / "manifest.json"
        self._write_json(meta_path, meta)
        self._write_json(
            manifest_path,
            {
                "version": 1,
                "solution_name": solution_name,
                "total_files": len(entries),
                "entries": [e.to_dict() for e in entries],
            },
        )

        return DynamicsSnapshotResult(
            out_dir=out_dir,
            meta_path=meta_path,
            manifest_path=manifest_path,
            solution_folder=solution_folder,
            manifest_entries=entries,
            category_summary=category_summary,
            warnings=warnings,
            pac_logs=pac_logs,
        )

    def normalize_solution_folder(self, folder: Path) -> None:
        """Public wrapper for normalization."""
        self._normalize_solution_folder(folder)

    def build_manifest(self, folder: Path) -> tuple[List[ManifestEntry], Dict[str, int]]:
        """Public wrapper for manifest + category summary."""
        entries = self._build_manifest(folder)
        return entries, self._category_summary(entries)

    # ---------- Normalization ----------
    def _normalize_solution_folder(self, folder: Path) -> None:
        """Conservative normalization:
        - Normalize line endings to LF
        - Stable XML attribute ordering (alphabetical)
        - Stable JSON: json.dumps(sort_keys=True, indent=2)
        - Strip trailing whitespace
        - Do NOT reorder XML child elements (order may be semantic)
        """
        for path in sorted(folder.rglob("*")):
            if not path.is_file():
                continue
            suffix = path.suffix.lower()
            if suffix == ".xml":
                self._normalize_xml_file(path)
                continue
            if suffix == ".json":
                self._normalize_json_file(path)
                continue
            _normalize_text_file(path)

    def _normalize_xml_file(self, path: Path) -> None:
        """Sort attributes alphabetically. Do NOT reorder children."""
        raw = path.read_text(encoding="utf-8")
        declaration = _extract_xml_declaration(raw)
        try:
            try:
                parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))
            except TypeError:
                parser = ET.XMLParser()
            root = ET.fromstring(raw, parser=parser)
        except Exception:
            _normalize_text_file(path)
            return

        _sort_xml_attributes(root)
        xml_text = ET.tostring(root, encoding="unicode")
        xml_text = _normalize_text(xml_text)
        if declaration and not xml_text.lstrip().startswith("<?xml"):
            xml_text = declaration.strip() + "\n" + xml_text.lstrip("\n")
        path.write_text(xml_text, encoding="utf-8")

    def _normalize_json_file(self, path: Path) -> None:
        """json.dumps(sort_keys=True, indent=2) + LF line ending."""
        raw = path.read_text(encoding="utf-8")
        try:
            payload = json.loads(raw)
        except Exception:
            _normalize_text_file(path)
            return
        normalized = json.dumps(payload, sort_keys=True, indent=2, ensure_ascii=False)
        if not normalized.endswith("\n"):
            normalized += "\n"
        path.write_text(normalized, encoding="utf-8")

    # ---------- Manifest ----------
    def _build_manifest(self, folder: Path) -> List[ManifestEntry]:
        """Walk folder, hash each file, classify by type_tag."""
        entries: List[ManifestEntry] = []
        for path in sorted(folder.rglob("*")):
            if not path.is_file():
                continue
            relpath = path.relative_to(folder).as_posix()
            digest = _hash_file(path)
            entries.append(
                ManifestEntry(
                    relpath=relpath,
                    sha256=digest,
                    size=path.stat().st_size,
                    type_tag=self._classify_type_tag(relpath),
                )
            )
        return entries

    @staticmethod
    def _classify_type_tag(relpath: str) -> str:
        """Classify file by solution folder prefix."""
        prefix = relpath.replace("\\", "/")
        if prefix.startswith("Entities/"):
            return "entity"
        if prefix.startswith("Forms/"):
            return "form"
        if prefix.startswith("Workflows/"):
            return "workflow"
        if prefix.startswith("WebResources/"):
            return "webresource"
        if prefix.startswith("OptionSets/"):
            return "optionset"
        if prefix.startswith("Reports/"):
            return "report"
        if prefix.startswith("Dashboards/"):
            return "dashboard"
        if prefix.startswith("Plugins/"):
            return "plugin"
        if prefix.startswith("Templates/"):
            return "template"
        return "other"

    @staticmethod
    def _category_summary(entries: List[ManifestEntry]) -> Dict[str, int]:
        """Count entries by type_tag."""
        summary: Dict[str, int] = {}
        for entry in entries:
            summary[entry.type_tag] = summary.get(entry.type_tag, 0) + 1
        return summary

    @staticmethod
    def _write_json(path: Path, obj: Any) -> None:
        """json.dumps(sort_keys=True, indent=2, ensure_ascii=False) + LF."""
        text = json.dumps(obj, sort_keys=True, indent=2, ensure_ascii=False)
        if not text.endswith("\n"):
            text += "\n"
        path.write_text(text, encoding="utf-8")


def _normalize_text(text: str) -> str:
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    lines = [line.rstrip() for line in text.split("\n")]
    normalized = "\n".join(lines)
    if not normalized.endswith("\n"):
        normalized += "\n"
    return normalized


def _normalize_text_file(path: Path) -> None:
    data = path.read_bytes()
    try:
        text = data.decode("utf-8")
    except Exception:
        return
    normalized = _normalize_text(text)
    path.write_text(normalized, encoding="utf-8")


def _sort_xml_attributes(element: ET.Element) -> None:
    if element.attrib:
        items = sorted(element.attrib.items(), key=lambda kv: kv[0])
        element.attrib.clear()
        element.attrib.update(items)
    for child in list(element):
        _sort_xml_attributes(child)


def _hash_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(8192), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _parse_solution_xml(folder: Path) -> tuple[str, str]:
    """Best-effort parse of solution.xml for version and unique name."""
    candidates = [
        folder / "solution.xml",
        folder / "Solution.xml",
        folder / "Other" / "Solution.xml",
        folder / "Other" / "solution.xml",
    ]
    for path in candidates:
        if not path.exists():
            continue
        try:
            raw = path.read_text(encoding="utf-8")
            root = ET.fromstring(raw)
        except Exception:
            continue
        version = _find_text(root, ["Version", "version"])
        unique = _find_text(root, ["UniqueName", "uniqueName", "uniquename"])
        return version, unique
    return "", ""


def _find_text(root: ET.Element, tags: List[str]) -> str:
    for elem in root.iter():
        tag = elem.tag
        if "/" in tag:
            tag = tag.split("/")[-1]
        if tag in tags and elem.text:
            return elem.text.strip()
    return ""


def _pick_value(payload: Dict[str, Any], keys: List[str]) -> str:
    for key in keys:
        for variant in (key, key.lower(), key.upper()):
            if variant in payload and payload[variant]:
                return str(payload[variant])
    return ""


def _extract_xml_declaration(raw: str) -> str:
    stripped = raw.lstrip()
    if not stripped.startswith("<?xml"):
        return ""
    end = stripped.find("?>")
    if end == -1:
        return ""
    return stripped[: end + 2]
