"""
HubSpot pack orchestration (change set bundles + receipts).
"""

from __future__ import annotations

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

from g_gremlin.errors import missing_pyyaml_error
from g_gremlin.hubspot import HubSpotClient
from g_gremlin.hubspot_destructive import DestructiveManifest, load_destructive_manifest
from g_gremlin.hubspot_infra import HubSpotInfraError, HubSpotPropsManager, load_property_spec
from g_gremlin.hubspot_lists import load_list_file, plan_list_changes
from g_gremlin.hubspot_pipelines import load_pipeline_spec, plan_pipeline_changes
from g_gremlin.hubspot_snapshot import HubSpotSnapshotter
from g_gremlin.hubspot_snapshot_compare import compare_snapshots
from g_gremlin.hubspot_validations import apply_validations, load_validations_spec, plan_validations
from g_gremlin.hubspot_workflows import (
    backup_flow,
    create_flow,
    diff_flow,
    ensure_revision_or_raise,
    get_flow,
    load_flow_file,
    resolve_flow_id,
    update_flow,
    validate_flow_payload,
)
from g_gremlin.hubspot_objects import apply_objects, load_objects_spec, plan_objects


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


@dataclass
class PackPlan:
    components: Dict[str, Any]
    warnings: List[str]
    created_at: str = field(default_factory=_utc_now_iso)
    plan_hash: str = ""

    def to_dict(self) -> Dict[str, Any]:
        payload = {
            "components": self.components,
            "warnings": self.warnings,
            "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]) -> "PackPlan":
        return cls(
            components=payload.get("components") or {},
            warnings=list(payload.get("warnings") or []),
            created_at=str(payload.get("created_at") or _utc_now_iso()),
            plan_hash=str(payload.get("plan_hash") or ""),
        )


@dataclass
class DriftComponentResult:
    name: str
    status: str
    changes: int = 0
    details: str = ""
    error: str = ""

    def to_dict(self) -> Dict[str, Any]:
        return {
            "name": self.name,
            "status": self.status,
            "changes": self.changes,
            "details": self.details,
            "error": self.error,
        }


@dataclass
class DriftSummary:
    components: List[DriftComponentResult]
    total_changes: int
    total_warnings: int

    def to_dict(self) -> Dict[str, Any]:
        return {
            "components": [c.to_dict() for c in self.components],
            "total_changes": self.total_changes,
            "total_warnings": self.total_warnings,
        }


def init_pack(dir_path: Path) -> None:
    dir_path.mkdir(parents=True, exist_ok=True)
    (dir_path / "pipelines").mkdir(exist_ok=True)
    (dir_path / "lists").mkdir(exist_ok=True)
    (dir_path / "workflows").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: partner_deal_reg_v1",
                    "include:",
                    "  properties: true",
                    "  validations: false",
                    "  objects: false",
                    "  pipelines: true",
                    "  lists: true",
                    "  workflows: false",
                    "options:",
                    "  workflows_beta: false",
                    "  allow_delete_stages: false",
                    "  allow_archive_properties: false",
                    "  allow_delete_workflows: false",
                    "  require_plan_hash: false",
                    "  smoke_after_apply: false",
                    "",
                ]
            ),
            encoding="utf-8",
        )
    properties_yaml = dir_path / "properties.yaml"
    if not properties_yaml.exists():
        properties_yaml.write_text(
            "\n".join(
                [
                    "version: 1",
                    "objects: {}",
                    "",
                ]
            ),
            encoding="utf-8",
        )
    readme = dir_path / "README.md"
    if not readme.exists():
        readme.write_text(
            "\n".join(
                [
                    "# HubSpot Pack",
                    "",
                    "Workflow:",
                    "1) g-gremlin hubspot snapshot --out snapshots/before",
                    "2) g-gremlin hubspot pack plan .",
                    "3) g-gremlin hubspot pack apply . --yes --plan-hash HASH",
                    "4) g-gremlin hubspot compare-snapshots snapshots/before artifacts/<ts>/after_snapshot",
                    "",
                ]
            ),
            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 destructive op requires BOTH a manifest entry and the corresponding allow_* flag.",
                    "archive_properties: []",
                    "delete_pipeline_stages: []",
                    "delete_workflows: []",
                    "",
                ]
            ),
            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 = {
        "components": plan_payload.get("components") or {},
        "warnings": plan_payload.get("warnings") 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) -> PackPlan:
    payload = json.loads(plan_path.read_text(encoding="utf-8"))
    _validate_plan_payload(payload)
    plan = PackPlan.from_dict(payload)
    if not plan.plan_hash:
        plan.plan_hash = _compute_plan_hash(payload)
    return plan


def _write_plan_artifact(pack_dir: Path, plan_payload: Dict[str, Any]) -> Path:
    artifact_dir = _artifact_dir(pack_dir)
    artifact_dir.mkdir(parents=True, exist_ok=True)
    plan_path = artifact_dir / "plan.json"
    plan_path.write_text(json.dumps(plan_payload, indent=2, sort_keys=True), encoding="utf-8")
    return plan_path


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 pack_doctor(pack_dir: Path, *, json_output: bool = False) -> Dict[str, Any]:
    missing_files = []
    notes: List[str] = []
    env_needed: List[str] = []

    pack_path = pack_dir / "pack.yaml"
    if not pack_path.exists():
        missing_files.append("pack.yaml")
        report = {
            "ok": False,
            "missing_files": missing_files,
            "workflows_beta": False,
            "env_needed": env_needed,
            "required_scopes": {},
            "notes": notes,
        }
        return {"json": report}

    pack = load_pack_yaml(pack_path)
    include = pack.get("include") or {}
    options = pack.get("options") or {}
    workflows_beta = bool(options.get("workflows_beta"))

    if include.get("properties") and not (pack_dir / "properties.yaml").exists():
        missing_files.append("properties.yaml")
    if include.get("validations") and not (pack_dir / "validations.yaml").exists():
        missing_files.append("validations.yaml")
    if include.get("objects") and not (pack_dir / "objects.yaml").exists():
        missing_files.append("objects.yaml")
    if include.get("pipelines") and not (pack_dir / "pipelines").exists():
        missing_files.append("pipelines/")
    if include.get("lists") and not (pack_dir / "lists").exists():
        missing_files.append("lists/")
    if include.get("workflows") and not (pack_dir / "workflows").exists():
        missing_files.append("workflows/")

    required_scopes = {}
    if include.get("properties"):
        required_scopes["properties"] = [
            "crm.schemas.contacts.read",
            "crm.schemas.companies.read",
            "crm.schemas.deals.read",
            "crm.schemas.contacts.write",
            "crm.schemas.companies.write",
            "crm.schemas.deals.write",
        ]
    if include.get("validations"):
        required_scopes["validations"] = [
            "crm.schemas.contacts.read",
            "crm.schemas.contacts.write",
        ]
    if include.get("objects"):
        required_scopes["objects"] = [
            "crm.schemas.custom.read",
            "crm.schemas.custom.write",
        ]
    if include.get("pipelines"):
        required_scopes["pipelines"] = [
            "crm.objects.deals.write",
            "crm.objects.tickets.write",
            "crm.objects.leads.write",
        ]
    if include.get("lists"):
        required_scopes["lists"] = [
            "crm.lists.read",
            "crm.lists.write",
        ]
    if include.get("workflows"):
        required_scopes["workflows"] = [
            "automation",
        ]
        if workflows_beta:
            env_needed.append("G_GREMLIN_HUBSPOT_AUTOMATION_BETA=1")
        else:
            notes.append("Workflows enabled but workflows_beta is false.")

    notes.append("Run: g-gremlin hubspot doctor --json to validate scopes.")

    report = {
        "ok": not missing_files,
        "missing_files": missing_files,
        "workflows_beta": workflows_beta,
        "env_needed": env_needed,
        "required_scopes": required_scopes,
        "notes": notes,
    }
    return {"json": report}


def plan_pack(client: HubSpotClient, pack_dir: Path, *, persist_artifact: bool = True) -> PackPlan:
    pack = load_pack_yaml(pack_dir / "pack.yaml")
    include = pack.get("include") or {}
    options = pack.get("options") or {}
    warnings: List[str] = []

    components: Dict[str, Any] = {}

    if include.get("objects"):
        spec = load_objects_spec(pack_dir / "objects.yaml")
        obj_plan = plan_objects(client, spec)
        components["objects"] = obj_plan.to_dict()

    if include.get("properties"):
        spec = load_property_spec(pack_dir / "properties.yaml")
        props_plan = HubSpotPropsManager(client).plan(spec, refresh_cache=False)
        components["properties"] = [p.to_dict() for p in props_plan]

    if include.get("validations"):
        spec = load_validations_spec(pack_dir / "validations.yaml")
        validations_plan = plan_validations(client, spec)
        components["validations"] = [p.to_dict() for p in validations_plan]

    if include.get("pipelines"):
        pipeline_plans = []
        for spec_path in sorted((pack_dir / "pipelines").glob("*.json")):
            spec = load_pipeline_spec(spec_path)
            object_type = spec.get("object_type") or ""
            pipeline_id = spec.get("pipeline_id") or spec.get("id") or ""
            if not object_type or not pipeline_id:
                continue
            remote = get_pipeline_snapshot(client, object_type, pipeline_id)
            plan = plan_pipeline_changes(spec, remote)
            pipeline_plans.append(plan.to_dict())
        components["pipelines"] = pipeline_plans

    if include.get("lists"):
        list_plans = []
        for spec_path in sorted((pack_dir / "lists").glob("*.json")):
            spec = load_list_file(spec_path)
            list_id = spec.get("listId") or spec.get("id")
            if not list_id:
                continue
            remote = get_list_snapshot(client, str(list_id))
            plan = plan_list_changes(spec, remote)
            list_plans.append(plan.to_dict())
        components["lists"] = list_plans

    if include.get("workflows"):
        if options.get("workflows_beta"):
            workflow_plans = []
            for spec_path in sorted((pack_dir / "workflows").glob("*.json")):
                local = load_flow_file(spec_path)
                flow_id = resolve_flow_id(local)
                if not flow_id:
                    continue
                remote = get_flow(client, flow_id)
                diff = diff_flow(local, remote)
                workflow_plans.append({"id": flow_id, "changed": bool(diff), "diff": diff})
            components["workflows"] = workflow_plans
        else:
            warnings.append("workflows_skipped_beta_flag")

    components["destructive"] = _build_destructive_summary(pack_dir, components)

    plan = PackPlan(components=components, warnings=warnings)
    plan.plan_hash = _compute_plan_hash(plan.to_dict())
    if persist_artifact:
        _write_plan_artifact(pack_dir, plan.to_dict())
    return plan


def _build_destructive_summary(pack_dir: Path, components: Dict[str, Any]) -> Dict[str, Any]:
    destructive_ops = _collect_destructive_ops(components)
    detected = (
        len(destructive_ops["archive_properties"])
        + len(destructive_ops["delete_pipeline_stages"])
        + len(destructive_ops["delete_workflows"])
    )
    manifest = load_destructive_manifest(pack_dir)
    covered, missing_entries, _ = _evaluate_destructive_manifest(manifest, destructive_ops)
    missing = detected - covered
    return {
        "destructive_ops_detected": detected,
        "manifest_coverage": {"covered": covered, "missing": missing},
        "missing_entries": missing_entries,
    }


def _collect_destructive_ops(components: Dict[str, Any]) -> Dict[str, List[Dict[str, str]] | List[str]]:
    archive_properties: List[Dict[str, str]] = []
    delete_pipeline_stages: List[Dict[str, str]] = []
    delete_workflows: List[str] = []

    for item in components.get("properties") or []:
        if not isinstance(item, dict):
            continue
        object_type = str(item.get("object_type") or "")
        props = item.get("properties") or {}
        for prop in props.get("archive") or []:
            name = str(prop.get("name") or "")
            if object_type and name:
                archive_properties.append({"object_type": object_type, "name": name})

    for item in components.get("pipelines") or []:
        if not isinstance(item, dict):
            continue
        object_type = str(item.get("object_type") or "")
        pipeline_id = str(item.get("pipeline_id") or "")
        for stage in item.get("stage_deletes") or []:
            stage_id = str(stage.get("stage_id") or "")
            if object_type and pipeline_id and stage_id:
                delete_pipeline_stages.append(
                    {"object_type": object_type, "pipeline_id": pipeline_id, "stage_id": stage_id}
                )

    for item in components.get("workflows") or []:
        if isinstance(item, dict) and item.get("deleted"):
            delete_workflows.append(str(item.get("id")))

    return {
        "archive_properties": archive_properties,
        "delete_pipeline_stages": delete_pipeline_stages,
        "delete_workflows": delete_workflows,
    }


def _format_manifest_snippet(op_type: str, payload: Dict[str, str]) -> str:
    if op_type == "archive_properties":
        return "\n".join(
            [
                "# Add to destructive.yaml:",
                "archive_properties:",
                f"  - object_type: {payload.get('object_type')}",
                f"    name: {payload.get('name')}",
            ]
        )
    if op_type == "delete_pipeline_stages":
        return "\n".join(
            [
                "# Add to destructive.yaml:",
                "delete_pipeline_stages:",
                f"  - object_type: {payload.get('object_type')}",
                f"    pipeline_id: \"{payload.get('pipeline_id')}\"",
                f"    stage_id: \"{payload.get('stage_id')}\"",
            ]
        )
    if op_type == "delete_workflows":
        return "\n".join(
            [
                "# Add to destructive.yaml:",
                "delete_workflows:",
                f"  - \"{payload.get('flow_id')}\"",
            ]
        )
    return ""


def _iter_destructive_entries(
    destructive_ops: Dict[str, List[Dict[str, str]] | List[str]]
) -> List[tuple[str, Dict[str, str] | str]]:
    entries: List[tuple[str, Dict[str, str] | str]] = []
    for entry in destructive_ops.get("archive_properties") or []:
        entries.append(("archive_properties", entry))
    for entry in destructive_ops.get("delete_pipeline_stages") or []:
        entries.append(("delete_pipeline_stages", entry))
    for entry in destructive_ops.get("delete_workflows") or []:
        entries.append(("delete_workflows", entry))
    return entries


def _manifest_allows(
    manifest: DestructiveManifest | None,
    op_type: str,
    entry: Dict[str, str] | str,
) -> bool:
    if manifest is None:
        return False
    if op_type == "archive_properties":
        return manifest.allows_property_archive(entry.get("object_type"), entry.get("name"))  # type: ignore[arg-type]
    if op_type == "delete_pipeline_stages":
        return manifest.allows_stage_delete(
            entry.get("object_type"),
            entry.get("pipeline_id"),
            entry.get("stage_id"),
        )  # type: ignore[arg-type]
    if op_type == "delete_workflows":
        return manifest.allows_workflow_delete(entry if isinstance(entry, str) else entry.get("flow_id", ""))
    return False


def _evaluate_destructive_manifest(
    manifest: DestructiveManifest | None,
    destructive_ops: Dict[str, List[Dict[str, str]] | List[str]],
) -> tuple[int, List[str], List[tuple[str, Dict[str, str] | str]]]:
    covered = 0
    missing_entries: List[str] = []
    allowed_entries: List[tuple[str, Dict[str, str] | str]] = []
    for op_type, entry in _iter_destructive_entries(destructive_ops):
        if _manifest_allows(manifest, op_type, entry):
            covered += 1
            allowed_entries.append((op_type, entry))
        else:
            payload = entry if op_type != "delete_workflows" else {"flow_id": entry}
            missing_entries.append(_format_manifest_snippet(op_type, payload))  # type: ignore[arg-type]
    return covered, missing_entries, allowed_entries


def _count_changes(component: str, plan_data: Any) -> int:
    if not plan_data:
        return 0
    if component == "properties":
        total = 0
        if isinstance(plan_data, list):
            for item in plan_data:
                if not isinstance(item, dict):
                    continue
                groups = item.get("groups") or {}
                props = item.get("properties") or {}
                total += len(groups.get("create") or [])
                total += len(groups.get("update") or [])
                total += len(props.get("create") or [])
                total += len(props.get("update") or [])
                total += len(props.get("archive") or [])
                total += len(props.get("incompatible") or [])
        return total
    if component == "validations":
        total = 0
        if isinstance(plan_data, list):
            for item in plan_data:
                if not isinstance(item, dict):
                    continue
                total += len(item.get("create_or_update") or [])
                total += len(item.get("blocked") or [])
        return total
    if component == "objects":
        if not isinstance(plan_data, dict):
            return 0
        return (
            len(plan_data.get("create") or [])
            + len(plan_data.get("update") or [])
            + len(plan_data.get("association_create") or [])
            + len(plan_data.get("blocked") or [])
        )
    if component == "pipelines":
        total = 0
        if isinstance(plan_data, list):
            for item in plan_data:
                if not isinstance(item, dict):
                    continue
                total += len(item.get("pipeline_updates") or {})
                total += len(item.get("stage_creates") or [])
                total += len(item.get("stage_updates") or [])
                total += len(item.get("stage_deletes") or [])
        return total
    if component == "lists":
        total = 0
        if isinstance(plan_data, list):
            for item in plan_data:
                if not isinstance(item, dict):
                    continue
                if item.get("create"):
                    total += 1
                    continue
                total += len(item.get("updates") or {})
                total += len(item.get("blocked") or [])
        return total
    if component == "workflows":
        if isinstance(plan_data, list):
            return sum(1 for item in plan_data if isinstance(item, dict) and item.get("changed"))
        return 0
    return 0


def compute_drift(client: HubSpotClient, pack_dir: Path) -> DriftSummary:
    pack = load_pack_yaml(pack_dir / "pack.yaml")
    include = pack.get("include") or {}
    options = pack.get("options") or {}
    components: List[DriftComponentResult] = []
    total_changes = 0
    total_warnings = 0
    component_names = ["properties", "validations", "objects", "pipelines", "lists", "workflows"]

    plan = None
    plan_error: Exception | None = None
    try:
        plan = plan_pack(client, pack_dir)
    except Exception as exc:
        plan_error = exc

    for name in component_names:
        if not include.get(name):
            components.append(
                DriftComponentResult(name=name, status="skipped", details="not included in pack.yaml")
            )
            total_warnings += 1
            continue
        if name == "workflows" and not options.get("workflows_beta"):
            components.append(
                DriftComponentResult(name=name, status="skipped", details="requires workflows_beta")
            )
            total_warnings += 1
            continue
        if plan_error is not None:
            components.append(
                DriftComponentResult(name=name, status="error", error=str(plan_error))
            )
            total_warnings += 1
            continue
        plan_data = plan.components.get(name) if plan else None
        try:
            changes = _count_changes(name, plan_data)
        except Exception as exc:
            components.append(DriftComponentResult(name=name, status="error", error=str(exc)))
            total_warnings += 1
            continue
        status = "drifted" if changes else "clean"
        total_changes += changes
        components.append(DriftComponentResult(name=name, status=status, changes=changes))

    return DriftSummary(components=components, total_changes=total_changes, total_warnings=total_warnings)


def apply_pack(
    client: HubSpotClient,
    pack_dir: Path,
    plan: PackPlan | None = None,
    *,
    allow_delete_stages: bool,
    allow_archive_properties: bool,
    allow_delete_workflows: bool = False,
    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")
    include = pack.get("include") or {}
    options = pack.get("options") or {}
    warnings: List[str] = []

    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 hubspot 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}."
                )

    effective_plan = plan
    effective_plan_path = None
    effective_plan_hash = None
    if replan:
        effective_plan = plan_pack(client, pack_dir, persist_artifact=False)
        effective_plan_hash = effective_plan.plan_hash
        warnings.append("effective_plan_used")

    manifest = load_destructive_manifest(pack_dir)
    destructive_ops = _collect_destructive_ops(effective_plan.components)
    destructive_detected = (
        len(destructive_ops["archive_properties"])
        + len(destructive_ops["delete_pipeline_stages"])
        + len(destructive_ops["delete_workflows"])
    )
    if destructive_detected and manifest is None:
        warnings.append("No destructive.yaml found; destructive ops require flags only.")
    if manifest is not None and destructive_detected:
        _, missing_entries, allowed_entries = _evaluate_destructive_manifest(manifest, destructive_ops)
        flag_errors: List[str] = []
        for op_type, _ in allowed_entries:
            if op_type == "archive_properties" and not allow_archive_properties:
                flag_errors.append("allow_archive_properties")
            if op_type == "delete_pipeline_stages" and not allow_delete_stages:
                flag_errors.append("allow_delete_stages")
            if op_type == "delete_workflows" and not allow_delete_workflows:
                flag_errors.append("allow_delete_workflows")
        if missing_entries:
            raise HubSpotInfraError(
                "Destructive operations not listed in destructive.yaml:\n" + "\n\n".join(missing_entries)
            )
        if flag_errors:
            missing_flags = ", ".join(sorted(set(flag_errors)))
            raise HubSpotInfraError(
                f"Destructive operations require allow_* options in pack.yaml: {missing_flags}."
            )

    artifacts_dir = _artifact_dir(pack_dir)
    artifacts_dir.mkdir(parents=True, exist_ok=True)

    snapshotter = HubSpotSnapshotter(client)
    before_snapshot = artifacts_dir / "before_snapshot"
    snapshotter.snapshot(before_snapshot, include_workflows=options.get("workflows_beta", False))

    applied_plan_path = artifacts_dir / "plan.json"
    applied_plan_path.write_text(json.dumps(plan_payload, indent=2, sort_keys=True), encoding="utf-8")

    if replan:
        effective_plan_path = artifacts_dir / "effective_plan.json"
        effective_plan_path.write_text(
            json.dumps(effective_plan.to_dict(), indent=2, sort_keys=True),
            encoding="utf-8",
        )

    apply_results: Dict[str, Any] = {}

    if include.get("objects"):
        spec = load_objects_spec(pack_dir / "objects.yaml")
        obj_plan = plan_objects(client, spec)
        _ensure_plan_matches("objects", effective_plan.components.get("objects"), obj_plan.to_dict())
        apply_results["objects"] = apply_objects(client, obj_plan, spec)

    if include.get("properties"):
        spec = load_property_spec(pack_dir / "properties.yaml")
        props_plan = HubSpotPropsManager(client).plan(spec, refresh_cache=False)
        props_payload = [p.to_dict() for p in props_plan]
        _ensure_plan_matches("properties", effective_plan.components.get("properties"), props_payload)
        apply_results["properties"] = HubSpotPropsManager(client).apply(
            spec, props_plan, allow_archive=allow_archive_properties
        )

    if include.get("validations"):
        spec = load_validations_spec(pack_dir / "validations.yaml")
        validations_plan = plan_validations(client, spec)
        _ensure_plan_matches(
            "validations",
            effective_plan.components.get("validations"),
            [p.to_dict() for p in validations_plan],
        )
        apply_results["validations"] = apply_validations(client, validations_plan)

    if include.get("pipelines"):
        pipeline_results = []
        for spec_path in sorted((pack_dir / "pipelines").glob("*.json")):
            spec = load_pipeline_spec(spec_path)
            object_type = spec.get("object_type") or ""
            pipeline_id = spec.get("pipeline_id") or spec.get("id") or ""
            if not object_type or not pipeline_id:
                continue
            remote = get_pipeline_snapshot(client, object_type, pipeline_id)
            plan = plan_pipeline_changes(spec, remote)
            _ensure_plan_matches(
                "pipelines",
                effective_plan.components.get("pipelines"),
                plan.to_dict(),
                match_any=True,
            )
            result = apply_pipeline_snapshot(client, plan, spec, allow_delete_stages=allow_delete_stages)
            pipeline_results.append(result)
        apply_results["pipelines"] = pipeline_results

    if include.get("lists"):
        list_results = []
        for spec_path in sorted((pack_dir / "lists").glob("*.json")):
            spec = load_list_file(spec_path)
            list_id = spec.get("listId") or spec.get("id")
            if not list_id:
                continue
            remote = get_list_snapshot(client, str(list_id))
            plan = plan_list_changes(spec, remote)
            _ensure_plan_matches(
                "lists",
                effective_plan.components.get("lists"),
                plan.to_dict(),
                match_any=True,
            )
            if plan.blocked:
                raise HubSpotInfraError(f"Blocked list changes: {plan.blocked}")
            if plan.create:
                result = create_list_snapshot(client, plan.updates)
            else:
                result = update_list_snapshot(client, str(list_id), plan.updates)
            list_results.append(result)
        apply_results["lists"] = list_results

    if include.get("workflows"):
        if options.get("workflows_beta"):
            workflow_results = []
            backups_dir = artifacts_dir / "workflows_backup"
            planned_workflows = {p.get("id"): p for p in (effective_plan.components.get("workflows") or [])}
            for spec_path in sorted((pack_dir / "workflows").glob("*.json")):
                local = load_flow_file(spec_path)
                flow_id = resolve_flow_id(local)
                if flow_id:
                    planned = planned_workflows.get(flow_id)
                    if planned and not planned.get("changed"):
                        workflow_results.append({"id": flow_id, "status": "no_change"})
                        continue
                    validate_flow_payload(local, mode="update")
                    remote = get_flow(client, flow_id)
                    latest_revision = ensure_revision_or_raise(
                        local.get("revisionId"),
                        remote.get("revisionId"),
                        force=False,
                    )
                    _ensure_plan_matches(
                        "workflows",
                        effective_plan.components.get("workflows"),
                        {"id": flow_id, "changed": True},
                        match_any=True,
                    )
                    backup_path = backup_flow(flow_id, remote, backups_dir)
                    local["revisionId"] = latest_revision
                    local["id"] = flow_id
                    result = update_flow(client, flow_id, local)
                    workflow_results.append(
                        {"id": flow_id, "status": "updated", "backup": str(backup_path), "result": result}
                    )
                else:
                    validate_flow_payload(local, mode="create")
                    result = create_flow(client, local)
                    workflow_results.append({"id": result.get("id"), "status": "created", "result": result})
            apply_results["workflows"] = workflow_results
        else:
            warnings.append("workflows_skipped_beta_flag")

    apply_path = artifacts_dir / "apply.json"
    apply_path.write_text(json.dumps({"results": apply_results, "warnings": warnings}, indent=2, sort_keys=True), encoding="utf-8")

    after_snapshot = artifacts_dir / "after_snapshot"
    snapshotter.snapshot(after_snapshot, include_workflows=options.get("workflows_beta", False))

    compare = compare_snapshots(before_snapshot, after_snapshot)
    compare_path = artifacts_dir / "compare.json"
    compare_path.write_text(json.dumps(compare.to_dict(), indent=2, sort_keys=True), encoding="utf-8")

    smoke_path = None
    if options.get("smoke_after_apply") and pack.get("smoke"):
        from g_gremlin import hubspot_smoke as smoke_mod

        assertions = smoke_mod.load_smoke_assertions(pack)
        report = smoke_mod.run_smoke(client, assertions)
        smoke_path = artifacts_dir / "smoke.json"
        smoke_path.write_text(json.dumps(report.to_dict(), indent=2, sort_keys=True), encoding="utf-8")

    summary_path = artifacts_dir / "SUMMARY.md"
    summary_lines = [
        f"Portal: {compare.meta.get('portal_id_a')}",
        f"Created: {compare.meta.get('created_at_a')} -> {compare.meta.get('created_at_b')}",
        f"Plan: {applied_plan_path}",
        f"Plan Hash: {stored_hash}",
        f"Apply: {apply_path}",
        f"Compare: {compare_path}",
    ]
    if smoke_path:
        summary_lines.append(f"Smoke: {smoke_path}")
    if effective_plan_path:
        summary_lines.append(f"Effective Plan: {effective_plan_path}")
    summary_lines.append(f"Warnings: {', '.join(warnings) if warnings else 'none'}")
    summary_path.write_text(
        "\n".join(
            summary_lines
        ),
        encoding="utf-8",
    )

    return {
        "artifacts_dir": str(artifacts_dir),
        "plan_path": str(applied_plan_path),
        "source_plan_path": str(plan_path) if plan_path else None,
        "plan_hash": stored_hash,
        "apply_path": str(apply_path),
        "compare_path": str(compare_path),
        "summary_path": str(summary_path),
        "effective_plan_path": str(effective_plan_path) if effective_plan_path else None,
        "effective_plan_hash": effective_plan_hash,
        "smoke_path": str(smoke_path) if smoke_path else None,
    }


def get_pipeline_snapshot(client: HubSpotClient, object_type: str, pipeline_id: str) -> Dict[str, Any]:
    from g_gremlin.hubspot_pipelines import get_pipeline

    return get_pipeline(client, object_type, pipeline_id)


def apply_pipeline_snapshot(
    client: HubSpotClient,
    plan,
    spec,
    *,
    allow_delete_stages: bool,
) -> Dict[str, Any]:
    from g_gremlin.hubspot_pipelines import apply_pipeline_plan

    return apply_pipeline_plan(client, plan, spec, allow_delete=allow_delete_stages)


def get_list_snapshot(client: HubSpotClient, list_id: str) -> Dict[str, Any]:
    from g_gremlin.hubspot_lists import get_list

    return get_list(client, list_id)


def create_list_snapshot(client: HubSpotClient, payload: Dict[str, Any]) -> Dict[str, Any]:
    from g_gremlin.hubspot_lists import create_list

    return create_list(client, payload)


def update_list_snapshot(client: HubSpotClient, list_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
    from g_gremlin.hubspot_lists import update_list

    return update_list(client, list_id, payload)


def _ensure_plan_matches(
    component: str,
    expected: Any,
    actual: Any,
    *,
    match_any: bool = False,
) -> None:
    if expected is None:
        return
    expected_dump = json.dumps(expected, sort_keys=True)
    actual_dump = json.dumps(actual, sort_keys=True)
    if expected_dump == actual_dump:
        return
    if match_any and isinstance(expected, list):
        for item in expected:
            if json.dumps(item, sort_keys=True) == actual_dump:
                return
    raise HubSpotInfraError(
        f"Remote changed since plan for {component}. "
        "Re-run: g-gremlin hubspot pack plan <dir>"
    )
