from __future__ import annotations

import hashlib
import json
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Sequence

SUPPORTED_MERGE_OBJECTS: Dict[str, Dict[str, Any]] = {
    "account": {"label": "Account", "victim_batch_size": 2},
    "contact": {"label": "Contact", "victim_batch_size": 2},
    "lead": {"label": "Lead", "victim_batch_size": 1},
}


def plan_digest(plan_text: str) -> str:
    return hashlib.sha256(plan_text.encode("utf-8")).hexdigest()


def write_state(state_path: Path, state: Dict[str, Any]) -> None:
    state_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = state_path.with_suffix(state_path.suffix + ".tmp")
    tmp_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
    tmp_path.replace(state_path)


def load_state(state_path: Path) -> Dict[str, Any]:
    try:
        raw = json.loads(state_path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise ValueError(f"Invalid merge apply state JSON: {exc}") from exc
    if not isinstance(raw, dict):
        raise ValueError("Merge apply state file must contain a JSON object.")
    return raw


def write_receipt(receipt_path: Path, payload: Dict[str, Any]) -> None:
    receipt_path.parent.mkdir(parents=True, exist_ok=True)
    receipt_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def is_retryable_merge_error(exc: Exception) -> bool:
    status_code = getattr(exc, "status_code", None)
    if isinstance(status_code, int) and status_code in {401, 409, 429, 500, 502, 503, 504}:
        return True
    text = str(exc).lower()
    retryable_markers = (
        "unable_to_lock_row",
        "request_limit_exceeded",
        "rate limit",
        "rate limited",
        "invalid_session_id",
        "service unavailable",
        "gateway timeout",
        "internal server error",
        "timed out",
        "(429)",
        "(500)",
        "(502)",
        "(503)",
        "(504)",
    )
    return any(marker in text for marker in retryable_markers)


def retry_delay(attempt: int, base_delay: float, max_delay: float) -> float:
    delay = base_delay * (2 ** max(attempt - 1, 0))
    return min(delay, max_delay)


def build_merge_operations(payload: Dict[str, Any]) -> Dict[str, Any]:
    return build_dedupe_operations(payload)


def build_dedupe_operations(
    payload: Dict[str, Any],
    *,
    converted_status: str | None = None,
    do_not_create_opportunity: bool = True,
) -> Dict[str, Any]:
    if not isinstance(payload, dict):
        raise ValueError("Merge apply plan must be a JSON object.")
    schema = str(payload.get("$schema") or "").strip()
    job_type = str(payload.get("job_type") or "").strip()
    clusters = payload.get("clusters")
    if schema != "MergePlan/v2" or job_type != "crm_dedupe" or not isinstance(clusters, list):
        raise ValueError("Expected a MergePlan/v2 crm_dedupe plan with a 'clusters' list.")

    operations: List[Dict[str, Any]] = []
    skipped: List[Dict[str, Any]] = []
    skipped_by_reason: Dict[str, int] = defaultdict(int)

    for index, cluster in enumerate(clusters, start=1):
        if not isinstance(cluster, dict):
            _append_skipped(
                skipped,
                skipped_by_reason,
                cluster_id=f"cluster_{index:04d}",
                reason="invalid_cluster_entry",
                cluster_type="unknown",
                recommended_action="unknown",
                object_types=[],
                member_ids=[],
                review_notes=[],
            )
            continue

        cluster_id = str(cluster.get("cluster_id") or f"cluster_{index:04d}").strip()
        recommended_action = str(cluster.get("recommended_action") or "merge").strip()
        cluster_type = str(cluster.get("cluster_type") or "").strip() or "unknown"
        members = list(cluster.get("members") or [])
        golden_record = cluster.get("golden_record") or {}
        object_types = _ordered_unique(
            str(member.get("object_type") or "").strip()
            for member in members
            if str(member.get("object_type") or "").strip()
        )
        member_ids = _ordered_unique(
            str(member.get("record_id") or "").strip()
            for member in members
            if str(member.get("record_id") or "").strip()
        )
        review_notes = _string_list(cluster.get("review_notes"))
        anchors = _string_list(cluster.get("anchors"))
        merge_confidence = cluster.get("merge_confidence")

        if recommended_action == "lead_to_contact_resolution":
            lead_convert_operation = _build_lead_convert_operation(
                cluster_id=cluster_id,
                cluster_type=cluster_type,
                recommended_action=recommended_action,
                members=members,
                member_ids=member_ids,
                object_types=object_types,
                anchors=anchors,
                merge_confidence=merge_confidence,
                review_notes=review_notes,
                converted_status=converted_status,
                do_not_create_opportunity=do_not_create_opportunity,
            )
            if lead_convert_operation is None:
                _append_skipped(
                    skipped,
                    skipped_by_reason,
                    cluster_id=cluster_id,
                    reason="requires_manual_cross_object_resolution",
                    cluster_type=cluster_type,
                    recommended_action=recommended_action,
                    object_types=object_types,
                    member_ids=member_ids,
                    review_notes=review_notes,
                )
                continue
            operations.append(lead_convert_operation)
            continue
        if recommended_action != "merge":
            _append_skipped(
                skipped,
                skipped_by_reason,
                cluster_id=cluster_id,
                reason="unsupported_action",
                cluster_type=cluster_type,
                recommended_action=recommended_action,
                object_types=object_types,
                member_ids=member_ids,
                review_notes=review_notes,
            )
            continue

        master_id = str(golden_record.get("record_id") or "").strip()
        if not master_id:
            _append_skipped(
                skipped,
                skipped_by_reason,
                cluster_id=cluster_id,
                reason="missing_master_id",
                cluster_type=cluster_type,
                recommended_action=recommended_action,
                object_types=object_types,
                member_ids=member_ids,
                review_notes=review_notes,
            )
            continue

        normalized_object_types = {
            str(member.get("object_type") or "").strip().lower()
            for member in members
            if str(member.get("object_type") or "").strip()
        }
        if len(normalized_object_types) != 1:
            _append_skipped(
                skipped,
                skipped_by_reason,
                cluster_id=cluster_id,
                reason="mixed_object_types",
                cluster_type=cluster_type,
                recommended_action=recommended_action,
                object_types=object_types,
                member_ids=member_ids,
                review_notes=review_notes,
            )
            continue

        object_type = _canonical_merge_object_type(golden_record.get("object_type") or object_types[0])
        if not object_type:
            _append_skipped(
                skipped,
                skipped_by_reason,
                cluster_id=cluster_id,
                reason="unsupported_object_type",
                cluster_type=cluster_type,
                recommended_action=recommended_action,
                object_types=object_types,
                member_ids=member_ids,
                review_notes=review_notes,
            )
            continue

        victim_ids = [record_id for record_id in member_ids if record_id != master_id]
        if not victim_ids:
            _append_skipped(
                skipped,
                skipped_by_reason,
                cluster_id=cluster_id,
                reason="no_victim_records",
                cluster_type=cluster_type,
                recommended_action=recommended_action,
                object_types=object_types,
                member_ids=member_ids,
                review_notes=review_notes,
            )
            continue

        batch_size = int(SUPPORTED_MERGE_OBJECTS[object_type.lower()]["victim_batch_size"])
        for step_index, victim_chunk in enumerate(_chunks(victim_ids, batch_size), start=1):
            operations.append(
                {
                    "operation_id": f"{cluster_id}:{step_index}",
                    "operation_type": "merge",
                    "cluster_id": cluster_id,
                    "step_index": step_index,
                    "object_type": object_type,
                    "master_id": master_id,
                    "victim_ids": victim_chunk,
                    "victim_count": len(victim_chunk),
                    "cluster_size": len(member_ids),
                    "cluster_type": cluster_type,
                    "recommended_action": recommended_action,
                    "object_types": object_types,
                    "member_ids": member_ids,
                    "anchors": anchors,
                    "merge_confidence": merge_confidence,
                    "review_notes": review_notes,
                }
            )

    return {
        "operations": operations,
        "skipped": skipped,
        "stats": {
            "clusters_total": len(clusters),
            "operations_total": len(operations),
            "skipped_total": len(skipped),
            "skipped_by_reason": dict(sorted(skipped_by_reason.items())),
        },
    }


def make_receipt(
    *,
    command: str,
    plan_path: Path,
    plan_digest_value: str,
    org_alias: str,
    dry_run: bool,
    operations: Sequence[Dict[str, Any]],
    skipped: Sequence[Dict[str, Any]],
    succeeded: Sequence[Dict[str, Any]],
    failed: Sequence[Dict[str, Any]],
    resume: bool,
    state_file: Path | None,
) -> Dict[str, Any]:
    return {
        "version": 1,
        "command": command,
        "plan": str(plan_path),
        "plan_digest": plan_digest_value,
        "org_alias": org_alias,
        "dry_run": dry_run,
        "resume": resume,
        "state_file": str(state_file) if state_file else "",
        "operations_planned": len(operations),
        "operations_skipped": len(skipped),
        "operations_succeeded": len(succeeded),
        "operations_failed": len(failed),
        "skipped": list(skipped),
        "succeeded": list(succeeded),
        "failed": list(failed),
        "generated_at": datetime.now(timezone.utc).isoformat(),
    }


def _append_skipped(
    skipped: List[Dict[str, Any]],
    skipped_by_reason: Dict[str, int],
    *,
    cluster_id: str,
    reason: str,
    cluster_type: str,
    recommended_action: str,
    object_types: Sequence[str],
    member_ids: Sequence[str],
    review_notes: Sequence[str],
) -> None:
    skipped.append(
        {
            "cluster_id": cluster_id,
            "reason": reason,
            "cluster_type": cluster_type,
            "recommended_action": recommended_action,
            "object_types": list(object_types),
            "member_ids": list(member_ids),
            "review_notes": list(review_notes),
        }
    )
    skipped_by_reason[reason] += 1


def _ordered_unique(values: Iterable[str]) -> List[str]:
    seen: set[str] = set()
    ordered: List[str] = []
    for value in values:
        normalized = str(value or "").strip()
        if not normalized or normalized in seen:
            continue
        seen.add(normalized)
        ordered.append(normalized)
    return ordered


def _string_list(value: Any) -> List[str]:
    if not isinstance(value, list):
        return []
    return [str(item).strip() for item in value if str(item).strip()]


def _canonical_merge_object_type(value: Any) -> str:
    normalized = str(value or "").strip().lower()
    details = SUPPORTED_MERGE_OBJECTS.get(normalized)
    return str(details["label"]) if details else ""


def _build_lead_convert_operation(
    *,
    cluster_id: str,
    cluster_type: str,
    recommended_action: str,
    members: Sequence[Dict[str, Any]],
    member_ids: Sequence[str],
    object_types: Sequence[str],
    anchors: Sequence[str],
    merge_confidence: Any,
    review_notes: Sequence[str],
    converted_status: str | None,
    do_not_create_opportunity: bool,
) -> Dict[str, Any] | None:
    lead_members = [member for member in members if str(member.get("object_type") or "").strip().lower() == "lead"]
    contact_members = [
        member for member in members if str(member.get("object_type") or "").strip().lower() == "contact"
    ]
    if len(lead_members) != 1 or len(contact_members) != 1:
        return None

    lead_member = lead_members[0]
    contact_member = contact_members[0]
    lead_id = str(lead_member.get("record_id") or "").strip()
    converted_contact_id = str(contact_member.get("record_id") or "").strip()
    if not lead_id or not converted_contact_id:
        return None

    return {
        "operation_id": f"{cluster_id}:convert",
        "operation_type": "convert_lead",
        "cluster_id": cluster_id,
        "cluster_type": cluster_type,
        "recommended_action": recommended_action,
        "object_types": list(object_types),
        "member_ids": list(member_ids),
        "anchors": list(anchors),
        "merge_confidence": merge_confidence,
        "review_notes": list(review_notes),
        "lead_id": lead_id,
        "converted_contact_id": converted_contact_id,
        "converted_account_id": _resolve_account_id(contact_member),
        "converted_status": str(converted_status or "").strip(),
        "do_not_create_opportunity": bool(do_not_create_opportunity),
    }


def _resolve_account_id(record: Dict[str, Any]) -> str:
    for key in ("AccountId", "accountid", "account_id", "Account.Id", "account.id"):
        value = str(record.get(key) or "").strip()
        if value:
            return value
    return ""


def _chunks(values: Sequence[str], size: int) -> List[List[str]]:
    return [list(values[index : index + size]) for index in range(0, len(values), size)]


__all__ = [
    "SUPPORTED_MERGE_OBJECTS",
    "build_dedupe_operations",
    "build_merge_operations",
    "is_retryable_merge_error",
    "load_state",
    "make_receipt",
    "plan_digest",
    "retry_delay",
    "write_receipt",
    "write_state",
]
