"""
Salesforce pack orchestration for data changes (plan/apply/drift).
"""

from __future__ import annotations

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

from g_gremlin import sfdc as sfdc_helpers
from g_gremlin import sfdc_schema as sfdc_schema_helpers
from g_gremlin.errors import missing_pyyaml_error
from g_gremlin.pacing import BatchPacer, get_profile, PacingProfile


@dataclass
class PackPlan:
    payload: Dict[str, Any]

    def to_dict(self) -> Dict[str, Any]:
        return self.payload


@dataclass
class ApplyResult:
    payload: Dict[str, Any]

    def to_dict(self) -> Dict[str, Any]:
        return self.payload


@dataclass
class DriftResult:
    payload: Dict[str, Any]

    def to_dict(self) -> Dict[str, Any]:
        return self.payload


def init_pack(dir_path: Path) -> None:
    dir_path.mkdir(parents=True, exist_ok=True)
    (dir_path / "artifacts").mkdir(exist_ok=True)
    (dir_path / "data").mkdir(exist_ok=True)

    pack_yaml = dir_path / "pack.yaml"
    if not pack_yaml.exists():
        pack_yaml.write_text(
            "\n".join(
                [
                    "name: sfdc_data_pack",
                    "object: Account",
                    "csv: ./data/updates.csv",
                    "id_column: Id",
                    "key_field: Id",
                    "fields:",
                    "  - Name",
                    "  - Industry",
                    "",
                    "options:",
                    "  mode: safe",
                    "  batch_size: 50",
                    "  inter_batch_delay: 1.0",
                    "  allow_blank_fields: false",
                    "  deny_nulls: false",
                    "  allowlist_fields: []",
                    "  max_updates: null",
                    "  max_plan_age_minutes: null",
                    "  require_key_field: true",
                    "  snapshot_dir: null",
                    "  smoke_after_apply: false",
                    "",
                    "smoke: []",
                    "",
                ]
            ),
            encoding="utf-8",
        )

    readme = dir_path / "README.md"
    if not readme.exists():
        readme.write_text(
            "\n".join(
                [
                    "# Salesforce Pack",
                    "",
                    "Workflow:",
                    "1) g-gremlin sfdc snapshot --object Account --fields Id,Name --out snapshots/baseline",
                    "2) g-gremlin sfdc pack plan .",
                    "3) g-gremlin sfdc pack apply . --yes",
                    "4) g-gremlin sfdc drift .",
                    "5) g-gremlin sfdc smoke .",
                    "",
                ]
            ),
            encoding="utf-8",
        )


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


def plan_pack(pack_dir: Path, *, org_alias: Optional[str] = None) -> PackPlan:
    pack = load_pack_yaml(pack_dir / "pack.yaml")
    object_name = str(pack.get("object") or pack.get("sobject") or "").strip()
    if not object_name:
        raise ValueError("pack.yaml missing object")
    sfdc_helpers.ensure_valid_identifier(object_name, "object name")
    csv_path = _resolve_path(pack_dir, pack.get("csv"))
    id_column = str(pack.get("id_column") or "Id")
    key_field = str(pack.get("key_field") or "Id").strip()
    fields = _parse_fields(pack.get("fields") or [])
    options = dict(pack.get("options") or {})
    if options.get("snapshot_dir"):
        resolved = _resolve_path(pack_dir, options.get("snapshot_dir"))
        if resolved:
            options["snapshot_dir"] = str(resolved)

    if not csv_path or not csv_path.exists():
        raise ValueError("CSV path not found. Set pack.yaml csv to an existing file.")
    if not fields:
        raise ValueError("pack.yaml requires at least one field.")
    if not key_field:
        raise ValueError("pack.yaml missing key_field (default is Id).")
    sfdc_helpers.ensure_valid_identifier(key_field, "key_field")
    require_key_field = bool(options.get("require_key_field", True))
    if require_key_field and id_column.lower() != "id" and key_field.lower() == "id":
        raise ValueError("id_column is not Id; set key_field to the Salesforce external ID field.")
    if require_key_field and key_field.lower() != "id" and id_column.lower() == "id":
        raise ValueError("key_field is external; set id_column to the matching CSV column.")
    if require_key_field and id_column.lower() != key_field.lower():
        if id_column.lower() != "id" and key_field.lower() != "id":
            raise ValueError("id_column must match key_field when using an external ID key.")

    rows, headers = sfdc_helpers.read_csv(csv_path)
    if id_column not in headers:
        raise ValueError(f"ID column '{id_column}' not found in {csv_path}.")

    field_columns = {f["field"]: f["column"] for f in fields}
    for f in fields:
        if f["column"] not in headers:
            raise ValueError(f"Column '{f['column']}' not found in {csv_path}.")

    allow_blank = bool(options.get("allow_blank_fields", False))
    deny_nulls = bool(options.get("deny_nulls", False))
    allowlist_fields = options.get("allowlist_fields") or []
    if isinstance(allowlist_fields, str):
        allowlist_fields = [f.strip() for f in allowlist_fields.split(",") if f.strip()]
    max_updates = options.get("max_updates")

    id_map, duplicates = _build_id_map(rows, id_column, key_field)
    if duplicates:
        raise ValueError(f"Duplicate keys found in '{id_column}': {len(duplicates)} duplicates.")
    records = _fetch_records(
        object_name,
        list(id_map.values()),
        fields,
        key_field=key_field,
        org_alias=org_alias,
    )

    changes, not_found = _build_changes(
        rows,
        id_column,
        fields,
        field_columns,
        records,
        key_field=key_field,
        allow_blank=allow_blank,
    )

    summary = {
        "total_records": len(id_map),
        "records_with_changes": len(changes),
        "records_unchanged": max(len(id_map) - len(changes) - len(not_found), 0),
        "records_not_found": len(not_found),
    }

    validation_errors: List[str] = []
    validation_warnings: List[str] = []

    if allowlist_fields:
        allowlist_fields = [str(f).strip() for f in allowlist_fields if str(f).strip()]
        for field in allowlist_fields:
            sfdc_helpers.ensure_valid_identifier(field, "allowlist field")
        for field in [f["field"] for f in fields]:
            if field not in allowlist_fields:
                validation_errors.append(f"Field '{field}' not in allowlist_fields.")

    if max_updates is not None:
        if len(changes) > int(max_updates):
            validation_errors.append(
                f"max_updates exceeded: {len(changes)} updates > {int(max_updates)} limit."
            )

    toolchain = _toolchain_fingerprint()
    auth = _auth_fingerprint(org_alias)
    input_hash = _sha256_file(csv_path)
    baseline_hash = _sha256_json({"changes": changes, "not_found": not_found})

    identity = sfdc_helpers.get_org_identity(org_alias=org_alias)
    schema_info = sfdc_schema_helpers.get_schema(
        object_name,
        identity=identity,
        org_alias=org_alias,
    )
    schema_warnings = schema_info.warnings or []
    validation_warnings.extend(schema_warnings)
    if key_field and key_field not in schema_info.fields:
        validation_errors.append(f"key_field '{key_field}' not found in schema.")
    for field in [f["field"] for f in fields]:
        schema_field = schema_info.fields.get(field)
        if not schema_field:
            validation_errors.append(f"Field '{field}' not found in schema.")
            continue
        if not schema_field.updateable:
            validation_errors.append(f"Field '{field}' is not updateable.")
    schema_errors, schema_warns, changes = _validate_changes(
        changes,
        schema_info.fields,
        deny_nulls=deny_nulls,
    )
    validation_errors.extend(schema_errors)
    validation_warnings.extend(schema_warns)
    validation_errors = _dedupe_messages(validation_errors)
    validation_warnings = _dedupe_messages(validation_warnings)

    plan_payload = {
        "$schema": "SfdcPackPlan/v1",
        "plan_id": _new_plan_id(),
        "created_at": datetime.now(timezone.utc).isoformat(),
        "pack_name": pack.get("name") or pack_dir.name,
        "object": object_name,
        "key_field": key_field,
        "csv": str(csv_path),
        "id_column": id_column,
        "fields": fields,
        "summary": summary,
        "changes": changes,
        "not_found": not_found,
        "baseline_fingerprint": baseline_hash,
        "input_fingerprint": input_hash,
        "toolchain_fingerprint": toolchain,
        "auth_fingerprint": auth,
        "schema": {
            "schema_hash": schema_info.schema_hash,
            "capabilities_fingerprint": schema_info.metadata.get("capabilities_fingerprint"),
            "cache_dir": str(schema_info.cache_dir),
        },
        "validation": {
            "blocked": bool(validation_errors),
            "errors": validation_errors,
            "warnings": validation_warnings,
        },
        "options": options,
    }
    plan_payload["plan_hash"] = _compute_data_plan_hash(plan_payload)

    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), encoding="utf-8")

    return PackPlan(payload=plan_payload)


def apply_pack(
    pack_dir: Path,
    *,
    org_alias: Optional[str] = None,
    plan_path: Optional[Path] = None,
    yes: bool = False,
    replan: bool = False,
    expected_plan_hash: str | None = None,
) -> ApplyResult:
    if not yes:
        raise ValueError("Refusing to apply without --yes.")

    pack = load_pack_yaml(pack_dir / "pack.yaml")
    object_name = str(pack.get("object") or pack.get("sobject") or "").strip()
    if not object_name:
        raise ValueError("pack.yaml missing object")
    sfdc_helpers.ensure_valid_identifier(object_name, "object name")
    csv_path = _resolve_path(pack_dir, pack.get("csv"))
    id_column = str(pack.get("id_column") or "Id")
    fields = _parse_fields(pack.get("fields") or [])
    options = pack.get("options") or {}

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

    plan = json.loads(plan_path.read_text(encoding="utf-8"))
    stored_hash = str(plan.get("plan_hash") or "").strip()
    if stored_hash:
        recomputed = _compute_data_plan_hash(plan)
        if recomputed != stored_hash:
            raise ValueError("Plan hash mismatch -- plan file may have been tampered with.")
    if expected_plan_hash:
        expected = str(expected_plan_hash).strip()
        if not stored_hash:
            raise ValueError("Plan hash missing from plan file; cannot verify --plan-hash.")
        if stored_hash != expected:
            raise ValueError("Provided --plan-hash does not match plan file hash.")

    validation = plan.get("validation") or {}
    if validation.get("blocked"):
        raise ValueError("Plan is blocked due to validation errors. Re-run plan after fixing errors.")
    plan_opts = plan.get("options") or {}
    max_plan_age = plan_opts.get("max_plan_age_minutes")
    if max_plan_age:
        created_at = plan.get("created_at")
        if created_at:
            created_dt = datetime.fromisoformat(created_at)
            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}."
                )
    drift = detect_drift(pack_dir, plan, org_alias=org_alias)
    if drift["has_drift"]:
        if not replan:
            raise ValueError("Plan drift detected. Re-run plan or pass --replan.")
        replanned = plan_pack(pack_dir, org_alias=org_alias)
        plan = replanned.payload
        drift = detect_drift(pack_dir, plan, org_alias=org_alias)
        if drift["has_drift"]:
            raise ValueError("Drift still detected after replan.")

    if not csv_path or not csv_path.exists():
        raise ValueError("CSV path not found. Set pack.yaml csv to an existing file.")

    changes = plan.get("changes") or []
    if not changes:
        return ApplyResult(
            payload={
                "$schema": "SfdcPackApply/v1",
                "status": "noop",
                "summary": {"updated": 0, "failed": 0},
                "plan_id": plan.get("plan_id"),
                "artifact_dir": None,
            }
        )

    pacing = _resolve_pacing(plan_opts or options)
    pacer = BatchPacer(pacing)

    successes: List[Dict[str, Any]] = []
    failures: List[Dict[str, Any]] = []

    artifact_dir = _artifact_dir(pack_dir)
    artifact_dir.mkdir(parents=True, exist_ok=True)
    receipts_dir = artifact_dir / "receipts"
    receipts_dir.mkdir(exist_ok=True)
    (artifact_dir / "plan_used.json").write_text(json.dumps(plan, indent=2), encoding="utf-8")
    (artifact_dir / "drift_check.json").write_text(json.dumps(drift, indent=2), encoding="utf-8")

    before_rows = _receipt_rows_from_plan(changes)
    _write_receipt_csv(receipts_dir / "before.csv", before_rows)

    for batch in pacer.chunk(changes):
        for change in batch:
            record_id = change.get("id") or ""
            fields_payload = change.get("fields") or {}
            update_values = {field: meta.get("new") for field, meta in fields_payload.items()}
            try:
                result = sfdc_helpers.update_record(
                    object_name,
                    record_id,
                    update_values,
                    org_alias=org_alias,
                )
                successes.append({"id": record_id, "result": result})
            except Exception as exc:
                failures.append({"id": record_id, "error": str(exc)})
        pacer.pace()

    after_rows = _fetch_after_rows(object_name, changes, org_alias=org_alias)
    _write_receipt_csv(receipts_dir / "after.csv", after_rows)
    _write_jsonl(receipts_dir / "success.jsonl", successes)
    _write_jsonl(receipts_dir / "errors.jsonl", failures)

    summary = {
        "updated": len(successes),
        "failed": len(failures),
        "total": len(changes),
    }
    payload = {
        "$schema": "SfdcPackApply/v1",
        "status": "success" if not failures else "partial",
        "summary": summary,
        "plan_id": plan.get("plan_id"),
        "plan_path": str(plan_path) if plan_path else None,
        "artifact_dir": str(artifact_dir),
    }
    (artifact_dir / "apply.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
    return ApplyResult(payload=payload)


def detect_drift(
    pack_dir: Path,
    plan: Dict[str, Any],
    *,
    org_alias: Optional[str] = None,
) -> Dict[str, Any]:
    changes = plan.get("changes") or []
    object_name = plan.get("object") or ""
    ids = [c.get("id") for c in changes if c.get("id")]
    drifted: List[Dict[str, Any]] = []
    snapshot_drifted: List[Dict[str, Any]] = []

    fields = plan.get("fields") or []
    current = _fetch_records(object_name, ids, fields, key_field="Id", org_alias=org_alias)
    for change in changes:
        record_id = change.get("id") or ""
        current_record = current.get(_normalize_key(record_id, "Id"))
        if not current_record:
            drifted.append({"id": record_id, "reason": "record_missing"})
            continue
        for field, meta in (change.get("fields") or {}).items():
            baseline = meta.get("current")
            live = current_record.get(field)
            if str(baseline or "") != str(live or ""):
                drifted.append(
                    {
                        "id": record_id,
                        "field": field,
                        "baseline": baseline,
                        "current": live,
                    }
                )

    options = plan.get("options") or {}
    snapshot_dir = options.get("snapshot_dir")
    if snapshot_dir:
        snapshot_baseline = _load_snapshot_baseline(Path(snapshot_dir), object_name)
        if snapshot_baseline:
            for change in changes:
                record_id = change.get("id") or ""
                baseline_row = snapshot_baseline.get(_normalize_key(record_id, "Id"))
                current_record = current.get(_normalize_key(record_id, "Id"))
                if not baseline_row or not current_record:
                    continue
                for field in (change.get("fields") or {}).keys():
                    snap_val = baseline_row.get(field)
                    live_val = current_record.get(field)
                    if str(snap_val or "") != str(live_val or ""):
                        snapshot_drifted.append(
                            {
                                "id": record_id,
                                "field": field,
                                "snapshot": snap_val,
                                "current": live_val,
                            }
                        )

    input_fingerprint = plan.get("input_fingerprint")
    csv_path = plan.get("csv")
    csv_changed = False
    if csv_path and input_fingerprint:
        try:
            csv_changed = _sha256_file(Path(csv_path)) != input_fingerprint
        except Exception:
            csv_changed = True

    return {
        "has_drift": bool(drifted) or csv_changed or bool(snapshot_drifted),
        "records_changed": drifted,
        "csv_changed": csv_changed,
        "snapshot_changed": snapshot_drifted,
        "total_records_checked": len(ids),
    }


def compute_drift(pack_dir: Path, *, org_alias: Optional[str] = None) -> DriftResult:
    plan_path = _latest_plan(pack_dir)
    if not plan_path or not plan_path.exists():
        raise ValueError("No plan found. Run: g-gremlin sfdc pack plan <dir>")
    plan = json.loads(plan_path.read_text(encoding="utf-8"))
    drift = detect_drift(pack_dir, plan, org_alias=org_alias)
    payload = {
        "$schema": "SfdcPackDrift/v1",
        "plan_id": plan.get("plan_id"),
        "has_drift": drift["has_drift"],
        "csv_changed": drift["csv_changed"],
        "records_changed": drift["records_changed"],
        "snapshot_changed": drift.get("snapshot_changed") or [],
        "total_records_checked": drift["total_records_checked"],
    }
    return DriftResult(payload=payload)


def _artifact_dir(pack_dir: Path) -> Path:
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    return pack_dir / "artifacts" / timestamp


def _latest_plan(pack_dir: Path) -> Optional[Path]:
    artifacts = pack_dir / "artifacts"
    if not artifacts.exists():
        return None
    dirs = sorted([d for d in artifacts.iterdir() if d.is_dir()], reverse=True)
    for d in dirs:
        plan_path = d / "plan.json"
        if plan_path.exists():
            return plan_path
    return None


def _resolve_path(pack_dir: Path, raw_path: Any) -> Optional[Path]:
    if not raw_path:
        return None
    path = Path(str(raw_path))
    if path.is_absolute():
        return path
    return (pack_dir / path).resolve()


def _parse_fields(items: List[Any]) -> List[Dict[str, str]]:
    parsed: List[Dict[str, str]] = []
    for item in items:
        if isinstance(item, str):
            field = item.strip()
            if field:
                sfdc_helpers.ensure_valid_identifier(field, "field")
                parsed.append({"field": field, "column": field})
            continue
        if isinstance(item, dict):
            field = str(item.get("field") or item.get("name") or "").strip()
            column = str(item.get("column") or item.get("csv") or field).strip()
            if field:
                sfdc_helpers.ensure_valid_identifier(field, "field")
                parsed.append({"field": field, "column": column or field})
    return parsed


def _build_id_map(
    rows: List[Dict[str, str]],
    id_column: str,
    key_field: str,
) -> tuple[Dict[str, str], List[str]]:
    mapping: Dict[str, str] = {}
    duplicates: List[str] = []
    for row in rows:
        raw_id = (row.get(id_column) or "").strip()
        if not raw_id:
            continue
        key = _normalize_key(raw_id, key_field)
        if not key:
            continue
        if key in mapping:
            duplicates.append(raw_id)
            continue
        mapping[key] = raw_id
    return mapping, duplicates


def _fetch_records(
    sobject: str,
    ids: List[str],
    fields: List[Dict[str, str]],
    *,
    key_field: str,
    org_alias: Optional[str] = None,
) -> Dict[str, Dict[str, str]]:
    records: Dict[str, Dict[str, str]] = {}
    if not ids:
        return records
    field_list = [f["field"] for f in fields]
    select_fields = ["Id"]
    if "Name" not in field_list:
        select_fields.append("Name")
    if key_field not in select_fields:
        select_fields.append(key_field)
    select_fields += field_list

    chunk_size = 150
    for i in range(0, len(ids), chunk_size):
        chunk = ids[i : i + chunk_size]
        soql_ids = ", ".join(f"'{sfdc_helpers.escape_soql_value(val)}'" for val in chunk)
        soql = f"SELECT {', '.join(select_fields)} FROM {sobject} WHERE {key_field} IN ({soql_ids})"
        rows = sfdc_helpers.run_soql(soql, org_alias=org_alias)
        for record in rows:
            key_val = record.get(key_field, "") if key_field else record.get("Id", "")
            key = _normalize_key(str(key_val), key_field)
            if key:
                records[key] = record
    return records


def _build_changes(
    rows: List[Dict[str, str]],
    id_column: str,
    fields: List[Dict[str, str]],
    field_columns: Dict[str, str],
    records: Dict[str, Dict[str, str]],
    *,
    key_field: str,
    allow_blank: bool,
) -> tuple[List[Dict[str, Any]], List[str]]:
    changes: List[Dict[str, Any]] = []
    not_found: List[str] = []

    for row in rows:
        raw_id = (row.get(id_column) or "").strip()
        if not raw_id:
            continue
        key = _normalize_key(raw_id, key_field)
        current = records.get(key)
        if not current:
            if raw_id not in not_found:
                not_found.append(raw_id)
            continue
        field_changes: Dict[str, Dict[str, Any]] = {}
        for field in fields:
            field_name = field["field"]
            column = field_columns[field_name]
            new_val = _clean_val(row.get(column))
            current_val = _clean_val(current.get(field_name))
            if new_val in (None, "") and not allow_blank:
                continue
            if _values_different(current_val, new_val):
                field_changes[field_name] = {"current": current_val, "new": new_val}
        if field_changes:
            changes.append(
                {
                    "id": current.get("Id", raw_id),
                    "name": current.get("Name"),
                    "key_value": raw_id,
                    "fields": field_changes,
                }
            )
    return changes, not_found


def _resolve_pacing(options: Dict[str, Any]) -> PacingProfile:
    mode = str(options.get("mode") or "safe").lower().strip()
    base = get_profile(mode)
    profile = PacingProfile(
        name=base.name,
        batch_size=base.batch_size,
        inter_batch_delay=base.inter_batch_delay,
        rate_limit_headroom=base.rate_limit_headroom,
    )
    batch_size = options.get("batch_size")
    inter_delay = options.get("inter_batch_delay")
    if batch_size:
        profile.batch_size = int(batch_size)
    if inter_delay is not None:
        profile.inter_batch_delay = float(inter_delay)
    return profile


def _receipt_rows_from_plan(changes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    rows: List[Dict[str, Any]] = []
    for change in changes:
        row = {"Id": change.get("id"), "Name": change.get("name")}
        if change.get("key_value") is not None:
            row["KeyValue"] = change.get("key_value")
        for field, meta in (change.get("fields") or {}).items():
            row[field] = meta.get("current")
        rows.append(row)
    return rows


def _fetch_after_rows(
    sobject: str,
    changes: List[Dict[str, Any]],
    *,
    org_alias: Optional[str],
) -> List[Dict[str, Any]]:
    ids = [c.get("id") for c in changes if c.get("id")]
    change_fields = _fields_from_changes(changes)
    fields = [{"field": f, "column": f} for f in change_fields]
    records = _fetch_records(sobject, ids, fields, key_field="Id", org_alias=org_alias)
    rows: List[Dict[str, Any]] = []
    for change in changes:
        record_id = change.get("id")
        record = records.get(_normalize_key(record_id, "Id"))
        if not record:
            continue
        row = {"Id": record.get("Id"), "Name": record.get("Name")}
        if change.get("key_value") is not None:
            row["KeyValue"] = change.get("key_value")
        for field in change_fields:
            row[field] = record.get(field)
        rows.append(row)
    return rows


def _fields_from_changes(changes: List[Dict[str, Any]]) -> List[str]:
    return list(
        dict.fromkeys(
            field
            for change in changes
            for field in (change.get("fields") or {}).keys()
        )
    )


def _write_receipt_csv(path: Path, rows: List[Dict[str, Any]]) -> None:
    if not rows:
        return
    headers = list(rows[0].keys())
    with path.open("w", newline="", encoding="utf-8") as handle:
        import csv as csv_module

        writer = csv_module.DictWriter(handle, fieldnames=headers)
        writer.writeheader()
        for row in rows:
            writer.writerow({h: row.get(h, "") for h in headers})


def _write_jsonl(path: Path, rows: List[Dict[str, Any]]) -> None:
    if not rows:
        return
    with path.open("w", encoding="utf-8") as handle:
        for row in rows:
            handle.write(json.dumps(row) + "\n")


def _toolchain_fingerprint() -> Dict[str, Any]:
    fingerprint = {}
    try:
        fingerprint["sf_version"] = sfdc_helpers.get_cli_version()
    except Exception as exc:
        fingerprint["sf_version_error"] = str(exc)
    try:
        fingerprint["data_query_available"] = sfdc_helpers.check_command_available(["data", "query"])
        fingerprint["data_update_available"] = sfdc_helpers.check_command_available(["data", "update", "record"])
        describe_cmd = sfdc_helpers.get_describe_command_path()
        fingerprint["schema_describe_available"] = describe_cmd is not None
        if describe_cmd:
            fingerprint["schema_describe_command"] = " ".join(describe_cmd)
    except Exception as exc:
        fingerprint["command_check_error"] = str(exc)
    return fingerprint


def _auth_fingerprint(org_alias: Optional[str]) -> Dict[str, Any]:
    try:
        return sfdc_helpers.get_org_identity(org_alias=org_alias)
    except Exception as exc:
        return {"error": str(exc)}


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 _sha256_json(payload: Dict[str, Any]) -> str:
    raw = json.dumps(payload, sort_keys=True).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()


def _compute_data_plan_hash(plan_payload: Dict[str, Any]) -> str:
    canonical = {
        "plan_id": plan_payload.get("plan_id"),
        "object": plan_payload.get("object"),
        "key_field": plan_payload.get("key_field"),
        "fields": plan_payload.get("fields") or [],
        "changes": plan_payload.get("changes") or [],
        "not_found": plan_payload.get("not_found") or [],
        "baseline_fingerprint": plan_payload.get("baseline_fingerprint"),
        "input_fingerprint": plan_payload.get("input_fingerprint"),
    }
    raw = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()


def _new_plan_id() -> str:
    return datetime.now(timezone.utc).strftime("plan-%Y%m%d-%H%M%S")


def _dedupe_messages(items: List[str]) -> List[str]:
    seen: set[str] = set()
    unique: List[str] = []
    for item in items:
        if item in seen:
            continue
        seen.add(item)
        unique.append(item)
    return unique

def _normalize_key(value: Any, key_field: str) -> str:
    if value is None:
        return ""
    text = str(value).strip()
    if key_field.lower() == "id":
        return sfdc_helpers.match_key(text)
    return text

def _clean_val(value: Any) -> Optional[str]:
    if value is None:
        return None
    text = str(value).strip()
    return text if text != "" else None


def _values_different(current: Optional[str], new: Optional[str]) -> bool:
    return (current or "") != (new or "")


def _validate_changes(
    changes: List[Dict[str, Any]],
    field_map: Dict[str, sfdc_schema_helpers.FieldInfo],
    *,
    deny_nulls: bool,
) -> tuple[List[str], List[str], List[Dict[str, Any]]]:
    errors: List[str] = []
    warnings: List[str] = []
    updated_changes: List[Dict[str, Any]] = []

    for change in changes:
        updated_fields: Dict[str, Dict[str, Any]] = {}
        for field, meta in (change.get("fields") or {}).items():
            schema = field_map.get(field)
            if not schema:
                errors.append(f"Field '{field}' not found in schema.")
                continue
            if not schema.updateable:
                errors.append(f"Field '{field}' is not updateable.")
            new_val = meta.get("new")
            if new_val in (None, ""):
                if deny_nulls or not schema.nillable:
                    errors.append(f"Field '{field}' cannot be set to null/blank.")
                updated_fields[field] = meta
                continue

            coerced, warn, err = _coerce_value(new_val, schema)
            if err:
                errors.append(f"{field}: {err}")
                continue
            if warn:
                warnings.append(f"{field}: {warn}")
            updated_fields[field] = {"current": meta.get("current"), "new": coerced}

        if updated_fields:
            updated_change = dict(change)
            updated_change["fields"] = updated_fields
            updated_changes.append(updated_change)
        else:
            updated_changes.append(change)

    return errors, warnings, updated_changes


def _coerce_value(value: Any, schema: sfdc_schema_helpers.FieldInfo) -> tuple[Any, str, str]:
    text = str(value).strip()
    ftype = (schema.type or "").lower()

    if ftype in {"boolean"}:
        truthy = {"true", "1", "yes", "y", "t"}
        falsy = {"false", "0", "no", "n", "f"}
        lowered = text.lower()
        if lowered in truthy:
            return True, "", ""
        if lowered in falsy:
            return False, "", ""
        return value, "", f"invalid boolean value '{text}'"

    if ftype in {"int"}:
        try:
            return int(text), "", ""
        except ValueError:
            return value, "", f"invalid int value '{text}'"

    if ftype in {"double", "currency", "percent"}:
        try:
            float_val = float(text)
            return float_val, "", ""
        except ValueError:
            return value, "", f"invalid number value '{text}'"

    if ftype == "date":
        try:
            parsed = date.fromisoformat(text)
            return parsed.isoformat(), "", ""
        except ValueError:
            return value, "", f"invalid date '{text}' (expected YYYY-MM-DD)"

    if ftype == "datetime":
        try:
            iso_text = text[:-1] + "+00:00" if text.endswith("Z") else text
            parsed = datetime.fromisoformat(iso_text)
            warn = ""
            if parsed.tzinfo is None:
                warn = "datetime has no timezone; using local/naive format"
            return parsed.isoformat(), warn, ""
        except ValueError:
            return value, "", f"invalid datetime '{text}'"

    if ftype == "picklist":
        if schema.restricted_picklist and text not in schema.picklist_values:
            return value, "", f"value '{text}' not in restricted picklist"
        if schema.picklist_values and text not in schema.picklist_values:
            return value, f"value '{text}' not in picklist; will attempt anyway", ""
        return value, "", ""

    if ftype == "multipicklist":
        parts = [p.strip() for p in text.split(";") if p.strip()]
        if schema.restricted_picklist:
            for part in parts:
                if part not in schema.picklist_values:
                    return value, "", f"multipicklist value '{part}' not allowed"
        return value, "", ""

    # Default: no coercion
    return value, "", ""


def _load_snapshot_baseline(snapshot_dir: Path, object_name: str) -> Dict[str, Dict[str, Any]]:
    data_path = snapshot_dir / "data" / f"{object_name}.json"
    if not data_path.exists():
        return {}
    try:
        rows = json.loads(data_path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    baseline: Dict[str, Dict[str, Any]] = {}
    if isinstance(rows, list):
        for row in rows:
            if not isinstance(row, dict):
                continue
            record_id = row.get("Id") or row.get("id")
            if record_id:
                baseline[_normalize_key(str(record_id), "Id")] = row
    return baseline
