from __future__ import annotations

import csv
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,
    cluster_approvals: Dict[str, Dict[str, Any]] | None = None,
    require_approval: bool = False,
) -> 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")
        approval_entry = dict((cluster_approvals or {}).get(cluster_id) or {})
        approval_status = (
            _normalize_approval_status(approval_entry.get("approval_status")) if approval_entry else ""
        )
        reviewer_notes = str(approval_entry.get("reviewer_notes") or "").strip()
        override_master_id = str(approval_entry.get("override_master_id") or "").strip()

        if require_approval:
            if not approval_entry:
                _append_skipped(
                    skipped,
                    skipped_by_reason,
                    cluster_id=cluster_id,
                    reason="approval_missing",
                    cluster_type=cluster_type,
                    recommended_action=recommended_action,
                    object_types=object_types,
                    member_ids=member_ids,
                    review_notes=review_notes,
                    approval_status=approval_status,
                    reviewer_notes=reviewer_notes,
                    override_master_id=override_master_id,
                )
                continue
            if not _approval_allows_apply(approval_status):
                _append_skipped(
                    skipped,
                    skipped_by_reason,
                    cluster_id=cluster_id,
                    reason="approval_not_approved",
                    cluster_type=cluster_type,
                    recommended_action=recommended_action,
                    object_types=object_types,
                    member_ids=member_ids,
                    review_notes=review_notes,
                    approval_status=approval_status,
                    reviewer_notes=reviewer_notes,
                    override_master_id=override_master_id,
                )
                continue

        if override_master_id:
            override_member = _resolve_override_member(
                members,
                override_master_id=override_master_id,
                cluster_type=cluster_type,
            )
            if override_member is None:
                _append_skipped(
                    skipped,
                    skipped_by_reason,
                    cluster_id=cluster_id,
                    reason="invalid_override_master_id",
                    cluster_type=cluster_type,
                    recommended_action=recommended_action,
                    object_types=object_types,
                    member_ids=member_ids,
                    review_notes=review_notes,
                    approval_status=approval_status,
                    reviewer_notes=reviewer_notes,
                    override_master_id=override_master_id,
                )
                continue
            golden_record = override_member

        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,
                    approval_status=approval_status,
                    reviewer_notes=reviewer_notes,
                    override_master_id=override_master_id,
                )
                continue
            lead_convert_operation["approval_status"] = approval_status
            lead_convert_operation["reviewer_notes"] = reviewer_notes
            lead_convert_operation["override_master_id"] = override_master_id
            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,
                approval_status=approval_status,
                reviewer_notes=reviewer_notes,
                override_master_id=override_master_id,
            )
            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,
                approval_status=approval_status,
                reviewer_notes=reviewer_notes,
                override_master_id=override_master_id,
            )
            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,
                approval_status=approval_status,
                reviewer_notes=reviewer_notes,
                override_master_id=override_master_id,
            )
            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,
                approval_status=approval_status,
                reviewer_notes=reviewer_notes,
                override_master_id=override_master_id,
            )
            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,
                approval_status=approval_status,
                reviewer_notes=reviewer_notes,
                override_master_id=override_master_id,
            )
            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,
                    "approval_status": approval_status,
                    "reviewer_notes": reviewer_notes,
                    "override_master_id": override_master_id,
                }
            )

    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,
    approval_file: Path | None = None,
    approval_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,
        "approval_file": str(approval_file) if approval_file else "",
        "approval_digest": approval_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],
    approval_status: str = "",
    reviewer_notes: str = "",
    override_master_id: str = "",
) -> None:
    payload = {
        "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),
    }
    if approval_status:
        payload["approval_status"] = approval_status
    if reviewer_notes:
        payload["reviewer_notes"] = reviewer_notes
    if override_master_id:
        payload["override_master_id"] = override_master_id
    skipped.append(payload)
    skipped_by_reason[reason] += 1


def load_cluster_approvals(approval_path: Path) -> Dict[str, Dict[str, Any]]:
    suffix = str(approval_path.suffix or "").strip().lower()
    if suffix == ".json":
        try:
            raw = json.loads(approval_path.read_text(encoding="utf-8"))
        except Exception as exc:
            raise ValueError(f"Invalid approval JSON: {exc}") from exc
        if isinstance(raw, dict):
            entries = raw.get("clusters")
            if not isinstance(entries, list):
                raise ValueError("Approval JSON must contain a top-level 'clusters' array or be a list.")
        elif isinstance(raw, list):
            entries = raw
        else:
            raise ValueError("Approval JSON must be a list or object.")
        return _normalize_cluster_approvals(entries, source=str(approval_path))

    with approval_path.open(newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        fieldnames = list(reader.fieldnames or [])
        if not fieldnames:
            raise ValueError("Approval CSV is missing a header row.")
        entries = list(reader)
    return _normalize_cluster_approvals(entries, source=str(approval_path))


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 _normalize_cluster_approvals(
    entries: Sequence[Dict[str, Any]],
    *,
    source: str,
) -> Dict[str, Dict[str, Any]]:
    approvals: Dict[str, Dict[str, Any]] = {}
    for index, entry in enumerate(entries, start=1):
        if not isinstance(entry, dict):
            raise ValueError(f"Approval entry {index} in {source} must be an object.")
        cluster_id = _case_insensitive_value(entry, "cluster_id")
        if not cluster_id:
            raise ValueError(f"Approval entry {index} in {source} is missing cluster_id.")
        if cluster_id in approvals:
            raise ValueError(f"Duplicate cluster_id '{cluster_id}' in {source}.")

        approval_status = _normalize_approval_status(
            _case_insensitive_value(entry, "approval_status", "status", "decision")
        )
        approved_flag = _parse_optional_bool(
            _case_insensitive_value(entry, "approved", "apply", "execute")
        )
        if approved_flag is True and approval_status == "pending":
            approval_status = "approved"
        elif approved_flag is False and approval_status == "pending":
            approval_status = "rejected"

        approvals[cluster_id] = {
            "cluster_id": cluster_id,
            "approval_status": approval_status,
            "override_master_id": _case_insensitive_value(entry, "override_master_id", "master_id"),
            "reviewer_notes": _case_insensitive_value(entry, "reviewer_notes", "notes", "comment"),
        }
    return approvals


def _case_insensitive_value(entry: Dict[str, Any], *candidates: str) -> str:
    lowered = {str(key).strip().lower(): value for key, value in entry.items()}
    for candidate in candidates:
        value = lowered.get(str(candidate).strip().lower())
        if value is not None:
            return str(value or "").strip()
    return ""


def _normalize_approval_status(value: Any) -> str:
    normalized = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
    if not normalized:
        return "pending"
    if normalized in {"approved", "approve", "apply", "yes", "true", "1", "merge", "convert", "go"}:
        return "approved"
    if normalized in {"hold", "held"}:
        return "hold"
    if normalized in {"rejected", "reject", "no", "false", "0"}:
        return "rejected"
    if normalized in {"skip", "skipped"}:
        return "skipped"
    if normalized in {"pending", "review", "needs_review", "manual_review"}:
        return "pending"
    return normalized


def _parse_optional_bool(value: Any) -> bool | None:
    normalized = str(value or "").strip().lower()
    if not normalized:
        return None
    if normalized in {"1", "true", "yes", "y"}:
        return True
    if normalized in {"0", "false", "no", "n"}:
        return False
    return None


def _approval_allows_apply(status: str) -> bool:
    return _normalize_approval_status(status) == "approved"


def _resolve_override_member(
    members: Sequence[Dict[str, Any]],
    *,
    override_master_id: str,
    cluster_type: str,
) -> Dict[str, Any] | None:
    normalized_override = str(override_master_id or "").strip()
    if not normalized_override:
        return None
    for member in members:
        if str(member.get("record_id") or "").strip() != normalized_override:
            continue
        if str(cluster_type or "").strip().lower() == "lead_to_contact":
            object_type = str(member.get("object_type") or "").strip().lower()
            if object_type != "contact":
                return None
        return member
    return None


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_cluster_approvals",
    "load_state",
    "make_receipt",
    "plan_digest",
    "retry_delay",
    "write_receipt",
    "write_state",
]
