"""Dynamics 365 pack orchestration (solution change sets + receipts)."""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
import hashlib
import json
import shutil
import tempfile

from g_gremlin.errors import missing_pyyaml_error
from g_gremlin.dynamics import PacWrapper
from g_gremlin.dynamics_destructive import DynamicsDestructiveManifest, load_destructive_manifest, validate_destructive_coverage
from g_gremlin.dynamics_snapshot import DynamicsSnapshotter
from g_gremlin.dynamics_snapshot_compare import compare_snapshots
from g_gremlin.dynamics_smoke import load_smoke_assertions, run_smoke


@dataclass
class DynamicsPackPlan:
    solution_name: str
    components: Dict[str, Any]
    warnings: List[str]
    destructive_summary: Dict[str, Any]
    baseline_fingerprint: str
    toolchain_fingerprint: str
    auth_fingerprint: Dict[str, Any]

    def to_dict(self) -> Dict[str, Any]:
        return {
            "solution_name": self.solution_name,
            "components": self.components,
            "warnings": self.warnings,
            "destructive": self.destructive_summary,
            "baseline_fingerprint": self.baseline_fingerprint,
            "toolchain_fingerprint": self.toolchain_fingerprint,
            "auth_fingerprint": self.auth_fingerprint,
        }


@dataclass
class DynamicsDriftSummary:
    files_changed: int
    files_added: int
    files_removed: int
    category_delta: Dict[str, Dict[str, int]]
    total_warnings: int

    def to_dict(self) -> Dict[str, Any]:
        return {
            "files_changed": self.files_changed,
            "files_added": self.files_added,
            "files_removed": self.files_removed,
            "category_delta": self.category_delta,
            "total_warnings": self.total_warnings,
        }


def init_pack(dir_path: Path) -> None:
    """Create pack structure with defaults."""
    dir_path.mkdir(parents=True, exist_ok=True)
    (dir_path / "solution").mkdir(exist_ok=True)
    (dir_path / "artifacts").mkdir(exist_ok=True)

    pack_yaml = dir_path / "pack.yaml"
    if not pack_yaml.exists():
        pack_yaml.write_text(
            "\n".join(
                [
                    "name: my_dynamics_pack",
                    "solution_name: MySolution",
                    "",
                    "options:",
                    "  package_type: Both",
                    "  allow_force_overwrite: false",
                    "  publish_after_import: true",
                    "  async_import: true",
                    "  allow_unpack_delete: false",
                    "  run_smoke_after_apply: false",
                    "",
                    "smoke: []",
                    "",
                ]
            ),
            encoding="utf-8",
        )

    destructive_yaml = dir_path / "destructive.yaml"
    if not destructive_yaml.exists():
        destructive_yaml.write_text(
            "\n".join(
                [
                    "# Destructive operations manifest for this pack.",
                    "# Each operation requires BOTH a manifest entry AND the corresponding allow_* flag.",
                    "#",
                    "# IMPORTANT: Dynamics unmanaged vs managed behavior:",
                    "# - Unmanaged: removing a component from solution XML does NOT delete it from",
                    "#   the environment. It is only \"detached\" from the solution.",
                    "# - Managed upgrade: CAN remove components not in the new version.",
                    "# - This manifest gates what pac CLI flags we pass (--allowDelete, --force-overwrite).",
                    "",
                    "allow_unpack_delete_files:",
                    "  # - \"WebResources/old_script.js\"",
                    "",
                    "force_overwrite_components:",
                    "  # - type: webresource",
                    "  #   name: \"new_/scripts/partner_validation.js\"",
                    "  #   reason: \"Overwriting customizations from manual edits\"",
                    "",
                    "detach_components:",
                    "  # - type: entity",
                    "  #   logical_name: \"new_obsolete_table\"",
                    "  #   reason: \"No longer part of this solution; remains in org\"",
                    "",
                ]
            ),
            encoding="utf-8",
        )

    readme = dir_path / "README.md"
    if not readme.exists():
        readme.write_text(
            "\n".join(
                [
                    "# Dynamics Pack",
                    "",
                    "Workflow:",
                    "1) g-gremlin dynamics snapshot --solution MySolution --out snapshots/baseline",
                    "2) g-gremlin dynamics pack plan .",
                    "3) g-gremlin dynamics pack apply . --yes",
                    "4) g-gremlin dynamics compare-snapshots snapshots/baseline artifacts/<ts>/after_snapshot",
                    "",
                ]
            ),
            encoding="utf-8",
        )


def load_pack_yaml(path: Path) -> Dict[str, Any]:
    if not path.exists():
        raise ValueError(f"Pack file not found: {path}")
    raw_text = path.read_text(encoding="utf-8")
    if path.suffix.lower() in {".yaml", ".yml"}:
        try:
            import yaml  # type: ignore
        except Exception as exc:
            raise missing_pyyaml_error() from exc
        return yaml.safe_load(raw_text) or {}
    return json.loads(raw_text)


def plan_pack(
    pac: PacWrapper,
    pack_dir: Path,
) -> DynamicsPackPlan:
    pack_path = pack_dir / "pack.yaml"
    pack = load_pack_yaml(pack_path)
    solution_name = str(pack.get("solution_name") or "").strip()
    if not solution_name:
        raise ValueError("pack.yaml missing solution_name")

    options = pack.get("options") or {}
    warnings: List[str] = []

    snapshotter = DynamicsSnapshotter(pac)
    baseline_fp = ""
    with tempfile.TemporaryDirectory() as tmpdir:
        remote_dir = Path(tmpdir) / "remote"
        local_dir = Path(tmpdir) / "local"
        remote_snapshot = snapshotter.snapshot(remote_dir, solution_name, managed=False)
        _write_local_snapshot(snapshotter, pack_dir / "solution", local_dir, solution_name)

        diff = compare_snapshots(remote_dir, local_dir)
        baseline_fp = _sha256_file(remote_snapshot.manifest_path)

    toolchain_fp = _toolchain_fingerprint(pac)
    auth_fp = _auth_fingerprint(pac)

    destructive_manifest = load_destructive_manifest(pack_dir)
    destructive_summary = {
        "allow_unpack_delete": destructive_manifest.allows_unpack_delete() if destructive_manifest else False,
        "allow_force_overwrite": destructive_manifest.allows_force_overwrite() if destructive_manifest else False,
        "allow_unpack_delete_files": len(destructive_manifest.allow_unpack_delete_files) if destructive_manifest else 0,
        "force_overwrite_components": len(destructive_manifest.force_overwrite_components) if destructive_manifest else 0,
    }

    return DynamicsPackPlan(
        solution_name=solution_name,
        components=diff.to_dict(),
        warnings=warnings,
        destructive_summary=destructive_summary,
        baseline_fingerprint=baseline_fp,
        toolchain_fingerprint=toolchain_fp,
        auth_fingerprint=auth_fp,
    )


def apply_pack(
    pac: PacWrapper,
    pack_dir: Path,
    plan: DynamicsPackPlan,
    *,
    replan: bool = False,
) -> Dict[str, Any]:
    pack = load_pack_yaml(pack_dir / "pack.yaml")
    options = pack.get("options") or {}
    solution_name = plan.solution_name

    snapshotter = DynamicsSnapshotter(pac)

    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    artifact_dir = pack_dir / "artifacts" / timestamp
    artifact_dir.mkdir(parents=True, exist_ok=True)
    pac_logs_dir = artifact_dir / "pac_logs"
    pac_logs_dir.mkdir(parents=True, exist_ok=True)

    destructive_manifest = load_destructive_manifest(pack_dir)
    allow_unpack_delete = bool(options.get("allow_unpack_delete")) and bool(
        destructive_manifest and destructive_manifest.allows_unpack_delete()
    )
    before_snapshot = snapshotter.snapshot(
        artifact_dir / "before_snapshot",
        solution_name,
        allow_delete=allow_unpack_delete,
    )
    _write_pac_log(pac_logs_dir / "export_before.log", before_snapshot.pac_logs[0])
    _write_pac_log(pac_logs_dir / "unpack_before.log", before_snapshot.pac_logs[1])

    baseline_fp = _sha256_file(before_snapshot.manifest_path)
    if baseline_fp != plan.baseline_fingerprint:
        if not replan:
            raise ValueError("Baseline fingerprint mismatch. Re-run plan or use --replan.")
        plan = plan_pack(pac, pack_dir)
        _write_json(artifact_dir / "effective_plan.json", plan.to_dict())

    toolchain_fp = _toolchain_fingerprint(pac)
    if toolchain_fp != plan.toolchain_fingerprint:
        raise ValueError("Toolchain fingerprint mismatch. Run plan with current pac CLI.")

    errors = validate_destructive_coverage(destructive_manifest, options, plan)
    if errors:
        raise ValueError("Destructive manifest validation failed: " + "; ".join(errors))

    _write_json(artifact_dir / "plan.json", plan.to_dict())

    zip_path = artifact_dir / f"{solution_name}.zip"
    pack_result = pac.solution_pack(pack_dir / "solution", zip_path, package_type=str(options.get("package_type") or "Both"))
    _write_pac_log(pac_logs_dir / "pack.log", pack_result)

    force_overwrite = bool(options.get("allow_force_overwrite")) and bool(
        destructive_manifest and destructive_manifest.allows_force_overwrite()
    )
    import_result = pac.solution_import(
        zip_path,
        force_overwrite=force_overwrite,
        publish_changes=False,
        async_=bool(options.get("async_import", True)),
    )
    _write_pac_log(pac_logs_dir / "import.log", import_result)

    publish_after = bool(options.get("publish_after_import", True))
    if publish_after:
        publish_result = pac.solution_publish()
        _write_pac_log(pac_logs_dir / "publish.log", publish_result)

    after_snapshot = snapshotter.snapshot(
        artifact_dir / "after_snapshot",
        solution_name,
        allow_delete=allow_unpack_delete,
    )
    _write_pac_log(pac_logs_dir / "export_after.log", after_snapshot.pac_logs[0])
    _write_pac_log(pac_logs_dir / "unpack_after.log", after_snapshot.pac_logs[1])

    diff = compare_snapshots(before_snapshot.out_dir, after_snapshot.out_dir)
    _write_json(artifact_dir / "compare.json", diff.to_dict())

    apply_payload = {
        "artifact_dir": str(artifact_dir),
        "solution_name": solution_name,
        "baseline_fingerprint": baseline_fp,
        "compare": diff.to_dict(),
    }
    _write_json(artifact_dir / "apply.json", apply_payload)

    smoke_path = None
    if bool(options.get("run_smoke_after_apply")):
        smoke_path = artifact_dir / "smoke.json"
        assertions = load_smoke_assertions(pack)
        report = run_smoke(pac, assertions, solution_folder=pack_dir / "solution")
        _write_json(smoke_path, report.to_dict())

    summary = _format_summary(solution_name, diff)
    (artifact_dir / "SUMMARY.md").write_text(summary + "\n", encoding="utf-8")

    return {
        "artifact_dir": str(artifact_dir),
        "plan": plan.to_dict(),
        "compare": diff.to_dict(),
        "smoke_path": str(smoke_path) if smoke_path else None,
    }


def compute_drift(pac: PacWrapper, pack_dir: Path) -> DynamicsDriftSummary:
    pack = load_pack_yaml(pack_dir / "pack.yaml")
    solution_name = str(pack.get("solution_name") or "").strip()
    snapshotter = DynamicsSnapshotter(pac)

    with tempfile.TemporaryDirectory() as tmpdir:
        remote_dir = Path(tmpdir) / "remote"
        local_dir = Path(tmpdir) / "local"
        snapshotter.snapshot(remote_dir, solution_name)
        _write_local_snapshot(snapshotter, pack_dir / "solution", local_dir, solution_name)
        diff = compare_snapshots(remote_dir, local_dir)

    return DynamicsDriftSummary(
        files_changed=len(diff.files_changed),
        files_added=len(diff.files_added),
        files_removed=len(diff.files_removed),
        category_delta=diff.category_delta,
        total_warnings=0,
    )


def pack_doctor(pack_dir: Path) -> Dict[str, Any]:
    missing: List[str] = []
    notes: List[str] = []
    if not (pack_dir / "pack.yaml").exists():
        missing.append("pack.yaml")
    if not (pack_dir / "solution").exists():
        missing.append("solution/")
    if not (pack_dir / "destructive.yaml").exists():
        missing.append("destructive.yaml")
    ok = not missing
    return {"ok": ok, "missing_files": missing, "notes": notes}


# ----------------- helpers -----------------

def _write_local_snapshot(snapshotter: DynamicsSnapshotter, source_folder: Path, out_dir: Path, solution_name: str) -> None:
    if not source_folder.exists():
        raise ValueError(f"solution folder not found: {source_folder}")
    if out_dir.exists():
        shutil.rmtree(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    solution_dir = out_dir / "solution"
    shutil.copytree(source_folder, solution_dir)

    snapshotter.normalize_solution_folder(solution_dir)
    entries, category_summary = snapshotter.build_manifest(solution_dir)

    snapshotter._write_json(
        out_dir / "meta.json",
        {
            "created_at": datetime.now(timezone.utc).isoformat(),
            "solution_name": solution_name,
            "category_summary": category_summary,
            "total_files": len(entries),
            "warnings": [],
        },
    )
    snapshotter._write_json(
        out_dir / "manifest.json",
        {
            "version": 1,
            "solution_name": solution_name,
            "total_files": len(entries),
            "entries": [e.to_dict() for e in entries],
        },
    )


def _sha256_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 _toolchain_fingerprint(pac: PacWrapper) -> str:
    caps = pac.capabilities()
    return (
        f"pac-{caps.pac_version}:pack={str(caps.has_solution_pack).lower()}"
        f":unpack={str(caps.has_solution_unpack).lower()}"
        f":publish={str(caps.has_solution_publish).lower()}"
    )


def _auth_fingerprint(pac: PacWrapper) -> Dict[str, Any]:
    profiles = []
    try:
        profiles = pac.auth_list()
    except Exception:
        profiles = []
    active = next((p for p in profiles if p.active), None)
    env_url = pac.environment or (active.environment_url if active else "")
    return {
        "pac_profile_index": active.index if active else None,
        "pac_profile_name": active.name if active else None,
        "environment_url": env_url,
    }


def _write_pac_log(path: Path, result: Any) -> None:
    lines = [
        f"command: {' '.join(result.command)}",
        f"returncode: {result.returncode}",
        f"duration_ms: {result.duration_ms}",
        "",
        "stdout:",
        result.stdout or "",
        "",
        "stderr:",
        result.stderr or "",
    ]
    path.write_text("\n".join(lines), encoding="utf-8")


def _format_summary(solution_name: str, diff: Any) -> str:
    lines = [
        f"Solution: {solution_name}",
        f"Files: +{len(diff.files_added)} -{len(diff.files_removed)} ~{len(diff.files_changed)}",
    ]
    for tag in sorted(diff.category_delta.keys()):
        delta = diff.category_delta[tag]
        lines.append(
            f"{tag}: +{delta.get('added', 0)} -{delta.get('removed', 0)} ~{delta.get('changed', 0)}"
        )
    return "\n".join(lines)


def _write_json(path: Path, obj: Any) -> None:
    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")
