"""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


def _utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


@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]
    created_at: str = field(default_factory=_utc_now_iso)
    plan_hash: str = ""

    def to_dict(self) -> Dict[str, Any]:
        payload = {
            "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,
            "created_at": self.created_at,
        }
        payload["plan_hash"] = self.plan_hash or _compute_plan_hash(payload)
        return payload

    @classmethod
    def from_dict(cls, payload: Dict[str, Any]) -> "DynamicsPackPlan":
        return cls(
            solution_name=str(payload.get("solution_name") or ""),
            components=payload.get("components") or {},
            warnings=list(payload.get("warnings") or []),
            destructive_summary=payload.get("destructive") or {},
            baseline_fingerprint=str(payload.get("baseline_fingerprint") or ""),
            toolchain_fingerprint=str(payload.get("toolchain_fingerprint") or ""),
            auth_fingerprint=payload.get("auth_fingerprint") or {},
            created_at=str(payload.get("created_at") or _utc_now_iso()),
            plan_hash=str(payload.get("plan_hash") or ""),
        )


@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",
                    "  require_plan_hash: false",
                    "  run_smoke_after_apply: false",
                    "  max_plan_age_minutes: null",
                    "",
                    "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 --plan-hash HASH",
                    "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 _artifact_dir(pack_dir: Path) -> Path:
    base = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    artifacts_root = pack_dir / "artifacts"
    candidate = artifacts_root / base
    if not candidate.exists():
        return candidate
    counter = 1
    while True:
        candidate = artifacts_root / f"{base}-{counter:02d}"
        if not candidate.exists():
            return candidate
        counter += 1


def _latest_plan(pack_dir: Path) -> Path | None:
    artifacts_dir = pack_dir / "artifacts"
    if not artifacts_dir.exists():
        return None
    plan_paths = sorted(
        (entry / "plan.json" for entry in artifacts_dir.iterdir() if entry.is_dir() and (entry / "plan.json").exists()),
        reverse=True,
    )
    return plan_paths[0] if plan_paths else None


def _compute_plan_hash(plan_payload: Dict[str, Any]) -> str:
    canonical = {
        "solution_name": plan_payload.get("solution_name"),
        "components": plan_payload.get("components") or {},
        "warnings": plan_payload.get("warnings") or [],
        "destructive": plan_payload.get("destructive") or {},
        "baseline_fingerprint": plan_payload.get("baseline_fingerprint"),
        "toolchain_fingerprint": plan_payload.get("toolchain_fingerprint"),
        "auth_fingerprint": plan_payload.get("auth_fingerprint") or {},
    }
    raw = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()


def _validate_plan_payload(plan_payload: Dict[str, Any]) -> str:
    stored_hash = str(plan_payload.get("plan_hash") or "").strip()
    if stored_hash:
        recomputed = _compute_plan_hash(plan_payload)
        if recomputed != stored_hash:
            raise ValueError("Plan hash mismatch -- plan file may have been tampered with.")
        return stored_hash
    return ""


def _load_plan(plan_path: Path) -> DynamicsPackPlan:
    payload = json.loads(plan_path.read_text(encoding="utf-8"))
    _validate_plan_payload(payload)
    plan = DynamicsPackPlan.from_dict(payload)
    if not plan.plan_hash:
        plan.plan_hash = _compute_plan_hash(payload)
    return plan


def _parse_iso8601(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
    except ValueError:
        return None


def plan_pack(
    pac: PacWrapper,
    pack_dir: Path,
    *,
    persist_artifact: bool = True,
) -> 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,
    }

    plan = 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,
    )
    plan.plan_hash = _compute_plan_hash(plan.to_dict())
    if persist_artifact:
        artifact_dir = _artifact_dir(pack_dir)
        artifact_dir.mkdir(parents=True, exist_ok=True)
        _write_json(artifact_dir / "plan.json", plan.to_dict())
    return plan


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

    if plan is None:
        if plan_path is None:
            plan_path = _latest_plan(pack_dir)
        if plan_path is None or not plan_path.exists():
            raise ValueError("No plan found. Run: g-gremlin dynamics pack plan <dir>")
        plan = _load_plan(plan_path)

    plan_payload = plan.to_dict()
    stored_hash = _validate_plan_payload(plan_payload)
    if not stored_hash:
        stored_hash = _compute_plan_hash(plan_payload)
        plan.plan_hash = stored_hash
        plan_payload["plan_hash"] = stored_hash
    if options.get("require_plan_hash") and not expected_plan_hash:
        raise ValueError(
            "This pack requires --plan-hash. Review a saved plan and re-run apply with --plan-hash <hash>."
        )
    if expected_plan_hash:
        expected = str(expected_plan_hash).strip()
        if stored_hash != expected:
            raise ValueError("Provided --plan-hash does not match plan file hash.")

    max_plan_age = options.get("max_plan_age_minutes")
    if max_plan_age:
        created_dt = _parse_iso8601(plan_payload.get("created_at"))
        if created_dt:
            age_minutes = (datetime.now(timezone.utc) - created_dt).total_seconds() / 60.0
            if age_minutes > float(max_plan_age):
                raise ValueError(
                    f"Plan is {age_minutes:.1f} minutes old; exceeds max_plan_age_minutes={max_plan_age}."
                )

    solution_name = plan.solution_name

    snapshotter = DynamicsSnapshotter(pac)

    artifact_dir = _artifact_dir(pack_dir)
    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)
    effective_plan_hash = None
    effective_plan_path = None
    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, persist_artifact=False)
        effective_plan_hash = plan.plan_hash
        effective_plan_path = artifact_dir / "effective_plan.json"
        _write_json(effective_plan_path, 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_payload)

    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,
        "plan_hash": stored_hash,
        "compare": diff.to_dict(),
    }
    if effective_plan_hash:
        apply_payload["effective_plan_hash"] = effective_plan_hash
    _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)
    summary += f"\nPlan Hash: {stored_hash}"
    if effective_plan_hash:
        summary += f"\nEffective Plan Hash: {effective_plan_hash}"
    (artifact_dir / "SUMMARY.md").write_text(summary + "\n", encoding="utf-8")

    return {
        "artifact_dir": str(artifact_dir),
        "plan": plan_payload,
        "plan_path": str(artifact_dir / "plan.json"),
        "source_plan_path": str(plan_path) if plan_path else None,
        "plan_hash": stored_hash,
        "effective_plan_path": str(effective_plan_path) if effective_plan_path else None,
        "effective_plan_hash": effective_plan_hash,
        "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")
