"""
Salesforce CLI integration helpers for g-gremlin.
"""

from __future__ import annotations

import csv
import json
import locale
import os
import re
import shutil
import subprocess
import tempfile
import time
from io import StringIO
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

from g_gremlin.paths import gremlin_path

SFDC_CONFIG_PATH = gremlin_path("sfdc.json")
SFDC_LEGACY_PROJECT_CONFIG_PATH = Path(".g_gremlin") / "sfdc.json"
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*$")
_PREFIX_BY_OBJECT = {
    "account": "001",
    "opportunity": "006",
    "contact": "003",
    "lead": "00Q",
}
_ORG_IDENTITY_CACHE: Dict[str, Tuple[float, Dict[str, Any]]] = {}
_ORG_IDENTITY_CACHE_TTL_SEC = 10.0
_DEFAULT_SF_TIMEOUT_SEC = 20
_MIN_CONVERT_LEAD_API_VERSION: Tuple[int, int] = (61, 0)
_DESCRIBE_COMMAND_CANDIDATES: Tuple[Tuple[str, ...], ...] = (
    ("sobject", "describe"),
    ("schema", "sobject", "describe"),
    ("force", "schema", "sobject", "describe"),
)


class SFDCError(RuntimeError):
    """Raised when SFDC CLI operations fail."""


def _get_sf_timeout_sec() -> int:
    raw = str(os.getenv("G_GREMLIN_SF_TIMEOUT_SEC", str(_DEFAULT_SF_TIMEOUT_SEC))).strip()
    try:
        timeout = int(raw)
    except ValueError:
        return _DEFAULT_SF_TIMEOUT_SEC
    return timeout if timeout > 0 else _DEFAULT_SF_TIMEOUT_SEC


def _find_sf_cli() -> str:
    """
    Locate the Salesforce CLI executable (sf or sf.cmd on Windows).
    """
    sf_cmd = shutil.which("sf") or shutil.which("sf.cmd")
    if sf_cmd is None:
        raise SFDCError("Salesforce CLI (sf/sf.cmd) is not on PATH. Install it first.")
    return sf_cmd


def _sf_cli_available() -> bool:
    try:
        _find_sf_cli()
        return True
    except SFDCError:
        return False


def _run_soql_via_platform_rest(query: str, *, org_alias: Optional[str]) -> List[Dict[str, str]]:
    from g_gremlin import platform_sfdc_client

    return platform_sfdc_client.run_soql(query, org_alias=org_alias)


def _describe_sobject_via_platform_rest(sobject: str, *, org_alias: Optional[str]) -> Dict[str, Any]:
    from g_gremlin import platform_sfdc_client

    payload = platform_sfdc_client.describe_sobject(sobject, org_alias=org_alias)
    return {"result": payload}


def _download_content_version_data_via_platform_rest(
    version_id: str,
    *,
    org_alias: Optional[str],
) -> bytes:
    from g_gremlin import platform_sfdc_client

    return platform_sfdc_client.download_content_version_data(version_id, org_alias=org_alias)


def _download_attachment_body_via_platform_rest(
    attachment_id: str,
    *,
    org_alias: Optional[str],
) -> bytes:
    from g_gremlin import platform_sfdc_client

    return platform_sfdc_client.download_attachment_body(attachment_id, org_alias=org_alias)


def _merge_records_via_platform_rest(
    sobject: str,
    master_id: str,
    victim_ids: List[str],
    *,
    org_alias: Optional[str],
) -> Dict[str, Any]:
    from g_gremlin import platform_sfdc_client

    return platform_sfdc_client.merge_records(sobject, master_id, victim_ids, org_alias=org_alias)


def _convert_lead_via_platform_rest(
    lead_id: str,
    *,
    converted_contact_id: str,
    converted_status: str,
    converted_account_id: str | None,
    do_not_create_opportunity: bool,
    org_alias: Optional[str],
) -> Dict[str, Any]:
    from g_gremlin import platform_sfdc_client

    return platform_sfdc_client.convert_lead(
        lead_id,
        converted_contact_id=converted_contact_id,
        converted_status=converted_status,
        converted_account_id=converted_account_id,
        do_not_create_opportunity=do_not_create_opportunity,
        org_alias=org_alias,
    )


def _run_sf_command(args: List[str]) -> subprocess.CompletedProcess[str]:
    sf_cmd = _find_sf_cli()
    cmd = [sf_cmd, *args]
    timeout_sec = _get_sf_timeout_sec()
    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=False,
            timeout=timeout_sec,
            stdin=subprocess.DEVNULL,
        )
        return subprocess.CompletedProcess(
            args=proc.args,
            returncode=proc.returncode,
            stdout=_decode_sf_stream(proc.stdout),
            stderr=_decode_sf_stream(proc.stderr),
        )
    except subprocess.TimeoutExpired as exc:
        command_preview = " ".join(args[:4]).strip() or "sf"
        raise SFDCError(
            f"SF CLI command timed out after {timeout_sec}s: {command_preview}"
        ) from exc


def _decode_sf_stream(value: str | bytes | None) -> str:
    if value is None:
        return ""
    if isinstance(value, str):
        return value
    preferred = locale.getpreferredencoding(False)
    encodings = tuple(
        dict.fromkeys(
            encoding
            for encoding in ("utf-8", "utf-8-sig", preferred, "cp1252", "latin-1")
            if encoding
        )
    )
    for encoding in encodings:
        try:
            return value.decode(encoding)
        except UnicodeDecodeError:
            continue
    return value.decode("utf-8", errors="replace")


def _parse_sf_json_stdout(stdout: str) -> Dict[str, Any]:
    text = (stdout or "").strip()
    if not text:
        return {}
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        filtered = "\n".join(_filtered_lines(stdout))
        if not filtered:
            return {}
        try:
            return json.loads(filtered)
        except json.JSONDecodeError as exc:
            raise SFDCError(f"SF CLI did not return valid JSON: {exc}") from exc


def _run_sf_json_output(args: List[str], *, add_json_flag: bool = True) -> Dict[str, Any]:
    cmd_args = [*args]
    if add_json_flag:
        cmd_args.append("--json")
    proc = _run_sf_command(cmd_args)
    if proc.returncode != 0:
        payload = _parse_sf_json_error_payload(proc.stdout)
        message = _sf_error_message(payload, proc=proc)
        raise SFDCError(message)
    return _parse_sf_json_stdout(proc.stdout)


def _run_sf_json(args: List[str]) -> Dict[str, Any]:
    return _run_sf_json_output(args, add_json_flag=True)


def _run_anonymous_apex(code: str, *, org_alias: str, api_version: str | None = None) -> Dict[str, Any]:
    apex_code = str(code or "").strip()
    if not apex_code:
        raise SFDCError("Anonymous Apex body cannot be empty.")
    with tempfile.NamedTemporaryFile("w", suffix=".apex", delete=False, encoding="utf-8") as handle:
        handle.write(apex_code)
        apex_path = Path(handle.name)
    try:
        args = [
            "apex",
            "run",
            "--target-org",
            org_alias,
            "--file",
            str(apex_path),
        ]
        if api_version:
            args.extend(["--api-version", str(api_version)])
        return _run_sf_json(args)
    finally:
        try:
            apex_path.unlink(missing_ok=True)
        except Exception:
            pass


def _parse_sf_json_error_payload(stdout: str) -> Dict[str, Any]:
    try:
        return _parse_sf_json_stdout(stdout)
    except SFDCError:
        return {}


def _sf_error_message(payload: Dict[str, Any], *, proc: subprocess.CompletedProcess[str]) -> str:
    if isinstance(payload, dict):
        data = payload.get("data")
        if isinstance(data, dict):
            for key in ("message", "errorCode"):
                value = str(data.get(key) or "").strip()
                if key == "message" and value:
                    return value
        for key in ("message", "name", "code", "exitCode"):
            value = str(payload.get(key) or "").strip()
            if value:
                return value
    stdout = (proc.stdout or "").strip()
    stderr = (proc.stderr or "").strip()
    return stdout or stderr or "SF CLI returned non-zero exit code."


def _redact_org_result(result: Dict[str, Any]) -> Dict[str, Any]:
    redacted = dict(result or {})
    for key in ("accessToken", "refreshToken", "oauthToken"):
        if key in redacted:
            redacted[key] = "[REDACTED]"
    return redacted


def get_cli_version() -> str:
    proc = _run_sf_command(["--version"])
    if proc.returncode != 0:
        raise SFDCError(proc.stderr.strip() or proc.stdout.strip() or "Unable to read sf --version.")
    return (proc.stdout or proc.stderr or "").strip()


def check_command_available(command_path: List[str]) -> bool:
    try:
        proc = _run_sf_command([*command_path, "--help"])
    except SFDCError:
        return False
    return proc.returncode == 0


def get_describe_command_path() -> Optional[List[str]]:
    for command_path in _DESCRIBE_COMMAND_CANDIDATES:
        candidate = list(command_path)
        if check_command_available(candidate):
            return candidate
    return None


def _resolve_org_alias(org_alias: Optional[str]) -> Optional[str]:
    explicit = str(org_alias or "").strip()
    if explicit:
        return explicit
    configured = str(get_configured_org() or "").strip()
    return configured or None


def project_deploy_start(
    source_dir: str | Path | None,
    *,
    manifest: str | Path | None = None,
    org_alias: Optional[str] = None,
    dry_run: bool = False,
    wait: int | None = 10,
) -> Dict[str, Any]:
    args = ["project", "deploy", "start"]
    if manifest:
        args += ["--manifest", str(manifest)]
    else:
        if not source_dir:
            raise SFDCError("source_dir is required when manifest is not provided.")
        args += ["--source-dir", str(source_dir)]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    if dry_run:
        args += ["--dry-run"]
    if wait is not None:
        args += ["--wait", str(wait)]
    return _run_sf_json(args)


def project_deploy_report(job_id: str, *, org_alias: Optional[str] = None) -> Dict[str, Any]:
    if not job_id:
        raise SFDCError("job_id is required for deploy report.")
    args = ["project", "deploy", "report", "--job-id", str(job_id)]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    return _run_sf_json(args)


def project_retrieve_start(
    *,
    manifest: str | Path | None = None,
    metadata: str | List[str] | None = None,
    output_dir: str | Path,
    org_alias: Optional[str] = None,
) -> Dict[str, Any]:
    if not manifest and not metadata:
        raise SFDCError("manifest or metadata is required for project retrieve.")
    args = ["project", "retrieve", "start"]
    if manifest:
        args += ["--manifest", str(manifest)]
    if metadata:
        if isinstance(metadata, list):
            metadata = ",".join(str(item) for item in metadata)
        args += ["--metadata", str(metadata)]
    args += ["--output-dir", str(output_dir)]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    return _run_sf_json(args)


def org_list_metadata(metadata_type: str, *, org_alias: Optional[str] = None) -> List[Dict[str, Any]]:
    if not metadata_type:
        raise SFDCError("metadata_type is required.")
    args = ["org", "list", "metadata", "--metadata-type", str(metadata_type)]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    payload = _run_sf_json(args)
    return list(payload.get("result") or [])


def get_org_display(org_alias: Optional[str] = None) -> Dict[str, Any]:
    args = ["org", "display"]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    payload = _run_sf_json(args)
    result = payload.get("result") or {}
    payload["result"] = _redact_org_result(result)
    return payload


def get_org_identity(org_alias: Optional[str] = None, *, use_cache: bool = True) -> Dict[str, Any]:
    alias = _resolve_org_alias(org_alias)
    cache_key = alias or "__default__"
    if use_cache:
        cached = _ORG_IDENTITY_CACHE.get(cache_key)
        if cached and (time.time() - cached[0]) < _ORG_IDENTITY_CACHE_TTL_SEC:
            return dict(cached[1])
    payload = get_org_display(org_alias=alias)
    result = payload.get("result") or {}
    identity = {
        "alias": result.get("alias"),
        "username": result.get("username"),
        "user_id": result.get("userId") or result.get("user_id"),
        "org_id": result.get("orgId") or result.get("org_id"),
        "instance_url": result.get("instanceUrl") or result.get("instance_url"),
        "api_version": result.get("apiVersion") or result.get("api_version"),
        "oauth_method": result.get("oauthMethod") or result.get("oauth_method"),
        "connected_status": result.get("connectedStatus") or result.get("connected_status"),
    }
    _ORG_IDENTITY_CACHE[cache_key] = (time.time(), identity)
    return identity


def update_record(
    sobject: str,
    record_id: str,
    values: Dict[str, Any],
    org_alias: Optional[str] = None,
) -> Dict[str, Any]:
    if not values:
        raise SFDCError("No values provided for update.")
    values_str = _format_values(values)
    args = [
        "data",
        "update",
        "record",
        "--sobject",
        sobject,
        "--record-id",
        record_id,
        "--values",
        values_str,
    ]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    return _run_sf_json(args)


def create_record(
    sobject: str,
    values: Dict[str, Any],
    org_alias: Optional[str] = None,
) -> Dict[str, Any]:
    if not values:
        raise SFDCError("No values provided for create.")
    values_str = _format_values(values)
    args = [
        "data",
        "create",
        "record",
        "--sobject",
        sobject,
        "--values",
        values_str,
    ]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    return _run_sf_json(args)


def delete_record(
    sobject: str,
    record_id: str,
    org_alias: Optional[str] = None,
) -> Dict[str, Any]:
    sobject_name = str(sobject or "").strip()
    target_id = str(record_id or "").strip()
    if not sobject_name:
        raise SFDCError("sobject is required for delete.")
    if not target_id:
        raise SFDCError("record_id is required for delete.")
    args = [
        "data",
        "delete",
        "record",
        "--sobject",
        sobject_name,
        "--record-id",
        target_id,
    ]
    alias = _resolve_org_alias(org_alias)
    if alias:
        args += ["--target-org", alias]
    return _run_sf_json(args)


def merge_records(
    sobject: str,
    master_id: str,
    victim_ids: List[str],
    org_alias: Optional[str] = None,
) -> Dict[str, Any]:
    supported_object_types = {"account": "Account", "contact": "Contact", "lead": "Lead"}
    normalized_sobject = str(sobject or "").strip().lower()
    canonical_sobject = supported_object_types.get(normalized_sobject)
    if not canonical_sobject:
        raise SFDCError("Salesforce merge currently supports Account, Contact, and Lead only.")

    normalized_master_id = _clean_id(str(master_id or ""))
    if not normalized_master_id:
        raise SFDCError("master_id is required for merge.")

    normalized_victim_ids: List[str] = []
    seen_victim_ids: set[str] = set()
    for raw_value in victim_ids or []:
        victim_id = _clean_id(str(raw_value or ""))
        if not victim_id or victim_id == normalized_master_id or victim_id in seen_victim_ids:
            continue
        seen_victim_ids.add(victim_id)
        normalized_victim_ids.append(victim_id)
    if not normalized_victim_ids:
        raise SFDCError("At least one victim record id is required for merge.")

    max_victims = 1 if canonical_sobject == "Lead" else 2
    if len(normalized_victim_ids) > max_victims:
        raise SFDCError(
            f"Salesforce {canonical_sobject} merge supports at most {max_victims} victim record(s) per call."
        )

    alias = _resolve_org_alias(org_alias)
    if _sf_cli_available():
        if not alias:
            raise SFDCError("No SFDC org configured. Run 'g-gremlin sfdc connect --org-alias <alias>' first.")
        api_version = _resolve_rest_api_version(alias)
        endpoint = f"/services/data/v{api_version}/sobjects/{canonical_sobject}/merge"
        body = json.dumps(
            {
                "masterRecord": {"Id": normalized_master_id},
                "recordToMergeIds": normalized_victim_ids,
            }
        )
        payload = _run_sf_json_output(
            [
                "api",
                "request",
                "rest",
                endpoint,
                "--method",
                "POST",
                "--body",
                body,
                "--target-org",
                alias,
            ],
            add_json_flag=False,
        )
        raw_status = payload.get("status") if isinstance(payload, dict) else None
        try:
            status_code = int(raw_status) if raw_status is not None else None
        except (TypeError, ValueError):
            status_code = None
        result = payload.get("result") if isinstance(payload, dict) else None
        if isinstance(result, dict) and result:
            response = dict(result)
            if status_code is not None and "status_code" not in response:
                response["status_code"] = status_code
            response.setdefault("object_type", canonical_sobject)
            response.setdefault("master_id", normalized_master_id)
            response.setdefault("victim_ids", normalized_victim_ids)
            return response
        return {
            "success": True,
            "status_code": status_code,
            "object_type": canonical_sobject,
            "master_id": normalized_master_id,
            "victim_ids": normalized_victim_ids,
        }

    try:
        return _merge_records_via_platform_rest(
            canonical_sobject,
            normalized_master_id,
            normalized_victim_ids,
            org_alias=alias,
        )
    except Exception as exc:
        raise SFDCError(str(exc)) from exc


def convert_lead(
    lead_id: str,
    *,
    converted_contact_id: str,
    converted_status: str,
    converted_account_id: str | None = None,
    do_not_create_opportunity: bool = True,
    org_alias: Optional[str] = None,
) -> Dict[str, Any]:
    normalized_lead_id = _clean_id(str(lead_id or ""))
    normalized_contact_id = _clean_id(str(converted_contact_id or ""))
    normalized_status = str(converted_status or "").strip()
    normalized_account_id = _clean_id(str(converted_account_id or ""))
    if not normalized_lead_id:
        raise SFDCError("lead_id is required for convertLead.")
    if not normalized_contact_id:
        raise SFDCError("converted_contact_id is required for convertLead.")
    if not normalized_status:
        raise SFDCError("converted_status is required for convertLead.")

    alias = _resolve_org_alias(org_alias)

    if _sf_cli_available():
        if not alias:
            raise SFDCError("No SFDC org configured. Run 'g-gremlin sfdc connect --org-alias <alias>' first.")
        apex_payload = _run_anonymous_apex(
            _build_convert_lead_apex(
                lead_id=normalized_lead_id,
                converted_contact_id=normalized_contact_id,
                converted_status=normalized_status,
                converted_account_id=normalized_account_id,
                do_not_create_opportunity=bool(do_not_create_opportunity),
            ),
            org_alias=alias,
        )
        _raise_for_failed_apex_run(apex_payload, operation="convertLead")
        lead_state = _fetch_lead_conversion_state(normalized_lead_id, org_alias=alias)
        return _build_convert_lead_response(
            lead_id=normalized_lead_id,
            converted_contact_id=normalized_contact_id,
            converted_account_id=normalized_account_id,
            converted_status=normalized_status,
            do_not_create_opportunity=bool(do_not_create_opportunity),
            lead_state=lead_state,
            execution_method="sf_apex_run",
        )

    try:
        return _convert_lead_via_platform_rest(
            normalized_lead_id,
            converted_contact_id=normalized_contact_id,
            converted_status=normalized_status,
            converted_account_id=normalized_account_id,
            do_not_create_opportunity=bool(do_not_create_opportunity),
            org_alias=alias,
        )
    except Exception as exc:
        raise SFDCError(str(exc)) from exc


def _build_convert_lead_apex(
    *,
    lead_id: str,
    converted_contact_id: str,
    converted_status: str,
    converted_account_id: str,
    do_not_create_opportunity: bool,
) -> str:
    lead_literal = _to_apex_string_literal(lead_id)
    contact_literal = _to_apex_string_literal(converted_contact_id)
    status_literal = _to_apex_string_literal(converted_status)
    account_literal = _to_apex_string_literal(converted_account_id)
    do_not_create_opportunity_literal = "true" if do_not_create_opportunity else "false"
    return "\n".join(
        [
            f"String gremlinLeadId = '{lead_literal}';",
            f"String gremlinContactId = '{contact_literal}';",
            f"String gremlinStatus = '{status_literal}';",
            f"String gremlinAccountId = '{account_literal}';",
            "if (String.isBlank(gremlinAccountId) && !String.isBlank(gremlinContactId)) {",
            "    List<Contact> gremlinContacts = [SELECT AccountId FROM Contact WHERE Id = :gremlinContactId LIMIT 1];",
            "    if (!gremlinContacts.isEmpty() && gremlinContacts[0].AccountId != null) {",
            "        gremlinAccountId = gremlinContacts[0].AccountId;",
            "    }",
            "}",
            "Database.LeadConvert gremlinConvert = new Database.LeadConvert();",
            "gremlinConvert.setLeadId(gremlinLeadId);",
            "gremlinConvert.setContactId(gremlinContactId);",
            "gremlinConvert.setConvertedStatus(gremlinStatus);",
            f"gremlinConvert.setDoNotCreateOpportunity({do_not_create_opportunity_literal});",
            "if (!String.isBlank(gremlinAccountId)) {",
            "    gremlinConvert.setAccountId(gremlinAccountId);",
            "}",
            "Database.LeadConvertResult gremlinResult = Database.convertLead(gremlinConvert);",
            "String gremlinErrors = '';",
            "if (!gremlinResult.isSuccess()) {",
            "    for (Database.Error gremlinError : gremlinResult.getErrors()) {",
            "        gremlinErrors += String.valueOf(gremlinError.getStatusCode()) + ': ' + gremlinError.getMessage() + '; ';",
            "    }",
            "}",
            "System.assert(gremlinResult.isSuccess(), gremlinErrors);",
        ]
    )


def _raise_for_failed_apex_run(payload: Dict[str, Any], *, operation: str) -> None:
    result = payload.get("result") if isinstance(payload, dict) else None
    if not isinstance(result, dict):
        raise SFDCError(f"sf apex run did not return a result payload for {operation}.")
    if not result.get("compiled", False):
        raise SFDCError(str(result.get("compileProblem") or f"Anonymous Apex failed to compile for {operation}."))
    if not result.get("success", False):
        raise SFDCError(str(result.get("exceptionMessage") or f"Anonymous Apex execution failed for {operation}."))


def _fetch_lead_conversion_state(lead_id: str, *, org_alias: Optional[str]) -> Dict[str, str]:
    rows = run_soql(
        (
            "SELECT Id, IsConverted, Status, ConvertedContactId, ConvertedAccountId, ConvertedOpportunityId "
            f"FROM Lead WHERE Id = '{escape_soql_value(lead_id)}' LIMIT 1"
        ),
        org_alias=org_alias,
    )
    return rows[0] if rows else {}


def _build_convert_lead_response(
    *,
    lead_id: str,
    converted_contact_id: str,
    converted_account_id: str,
    converted_status: str,
    do_not_create_opportunity: bool,
    lead_state: Dict[str, str],
    execution_method: str,
) -> Dict[str, Any]:
    resolved_contact_id = str(lead_state.get("ConvertedContactId") or converted_contact_id).strip()
    resolved_account_id = str(lead_state.get("ConvertedAccountId") or converted_account_id).strip()
    resolved_status = str(lead_state.get("Status") or converted_status).strip()
    resolved_opp_id = str(lead_state.get("ConvertedOpportunityId") or "").strip()
    is_converted = str(lead_state.get("IsConverted") or "").strip().lower() == "true"
    response: Dict[str, Any] = {
        "success": True,
        "status_code": 200,
        "lead_id": lead_id,
        "converted_contact_id": resolved_contact_id,
        "converted_account_id": resolved_account_id,
        "converted_status": resolved_status,
        "do_not_create_opportunity": bool(do_not_create_opportunity),
        "is_converted": is_converted,
        "execution_method": execution_method,
    }
    if resolved_opp_id:
        response["converted_opportunity_id"] = resolved_opp_id
    return response


def _to_apex_string_literal(value: str) -> str:
    return (
        str(value or "")
        .replace("\\", "\\\\")
        .replace("'", "\\'")
        .replace("\r", "\\r")
        .replace("\n", "\\n")
    )


def list_orgs() -> Dict[str, Any]:
    return _run_sf_json(["org", "list"])


def describe_sobject(sobject: str, org_alias: Optional[str] = None) -> Dict[str, Any]:
    alias = _resolve_org_alias(org_alias)
    command_path = get_describe_command_path()
    if command_path is not None:
        args = [*command_path, "--sobject", sobject]
        if alias:
            args += ["--target-org", alias]
        return _run_sf_json(args)
    if _sf_cli_available():
        raise SFDCError("No supported Salesforce describe command found (tried sobject describe variants).")
    try:
        return _describe_sobject_via_platform_rest(sobject, org_alias=alias)
    except Exception as exc:
        raise SFDCError(str(exc)) from exc


def list_reports(org_alias: Optional[str] = None) -> List[Dict[str, Any]]:
    soql = (
        "SELECT Id, Name, DeveloperName, FolderName, Format, LastRunDate "
        "FROM Report ORDER BY LastRunDate DESC"
    )
    return run_soql(soql, org_alias=org_alias)


def export_report(
    report_id: str,
    org_alias: Optional[str] = None,
    *,
    api_version: str = "62.0",
) -> Dict[str, Any]:
    report_id = str(report_id or "").strip()
    if not report_id:
        raise SFDCError("report_id is required.")
    if not _is_valid_id(report_id):
        raise SFDCError("Invalid report_id; expected a Salesforce 15/18 character id.")
    alias = org_alias or get_configured_org()
    if not alias:
        raise SFDCError("No SFDC org configured. Run 'g-gremlin sfdc connect --org-alias <alias>' first.")

    args = [
        "api",
        "request",
        "rest",
        f"/services/data/v{api_version}/analytics/reports/{report_id}",
        "--target-org",
        alias,
    ]
    payload = _run_sf_json_output(args, add_json_flag=False)
    result = payload.get("result")
    report_payload = result if isinstance(result, dict) else payload
    return _parse_exported_report(report_payload, report_id=report_id, api_version=api_version)


def download_content_version_data(
    version_id: str,
    org_alias: Optional[str] = None,
) -> bytes:
    normalized_version_id = str(version_id or "").strip()
    if not normalized_version_id:
        raise SFDCError("version_id is required.")

    alias = org_alias or get_configured_org()
    if _sf_cli_available():
        if not alias:
            raise SFDCError("No SFDC org configured. Run 'g-gremlin sfdc connect --org-alias <alias>' first.")
        api_version = _resolve_rest_api_version(alias)
        sf_cmd = _find_sf_cli()
        timeout_sec = _get_sf_timeout_sec()
        endpoint = f"/services/data/v{api_version}/sobjects/ContentVersion/{normalized_version_id}/VersionData"
        try:
            proc = subprocess.run(
                [sf_cmd, "api", "request", "rest", endpoint, "--target-org", alias],
                capture_output=True,
                text=False,
                timeout=timeout_sec,
                stdin=subprocess.DEVNULL,
            )
        except subprocess.TimeoutExpired as exc:
            raise SFDCError(
                f"SF CLI command timed out after {timeout_sec}s: api request rest ContentVersion {normalized_version_id}"
            ) from exc
        if proc.returncode != 0:
            raise SFDCError(
                _decode_sf_stream(proc.stderr).strip()
                or _decode_sf_stream(proc.stdout).strip()
                or "SF CLI returned non-zero exit code."
            )
        return bytes(proc.stdout or b"")

    try:
        return _download_content_version_data_via_platform_rest(normalized_version_id, org_alias=alias)
    except Exception as exc:
        raise SFDCError(str(exc)) from exc


def download_attachment_body(
    attachment_id: str,
    org_alias: Optional[str] = None,
) -> bytes:
    normalized_attachment_id = str(attachment_id or "").strip()
    if not normalized_attachment_id:
        raise SFDCError("attachment_id is required.")

    alias = org_alias or get_configured_org()
    if _sf_cli_available():
        if not alias:
            raise SFDCError("No SFDC org configured. Run 'g-gremlin sfdc connect --org-alias <alias>' first.")
        api_version = _resolve_rest_api_version(alias)
        sf_cmd = _find_sf_cli()
        timeout_sec = _get_sf_timeout_sec()
        endpoint = f"/services/data/v{api_version}/sobjects/Attachment/{normalized_attachment_id}/Body"
        try:
            proc = subprocess.run(
                [sf_cmd, "api", "request", "rest", endpoint, "--target-org", alias],
                capture_output=True,
                text=False,
                timeout=timeout_sec,
                stdin=subprocess.DEVNULL,
            )
        except subprocess.TimeoutExpired as exc:
            raise SFDCError(
                f"SF CLI command timed out after {timeout_sec}s: api request rest Attachment {normalized_attachment_id}"
            ) from exc
        if proc.returncode != 0:
            raise SFDCError(
                _decode_sf_stream(proc.stderr).strip()
                or _decode_sf_stream(proc.stdout).strip()
                or "SF CLI returned non-zero exit code."
            )
        return bytes(proc.stdout or b"")

    try:
        return _download_attachment_body_via_platform_rest(normalized_attachment_id, org_alias=alias)
    except Exception as exc:
        raise SFDCError(str(exc)) from exc


def is_valid_identifier(name: str) -> bool:
    if not name:
        return False
    return bool(_IDENTIFIER_RE.match(name))


def ensure_valid_identifier(name: str, label: str) -> None:
    if not is_valid_identifier(name):
        raise SFDCError(
            f"Invalid {label}: '{name}'. Expected alphanumeric/underscore (dot-separated) identifier."
        )


def escape_soql_value(value: Any) -> str:
    text = "" if value is None else str(value)
    return text.replace("\\", "\\\\").replace("'", "\\'")


def match_key(value: str) -> str:
    return _match_key(value)


def read_csv(path: Path) -> tuple[list[dict[str, str]], list[str]]:
    with path.open("r", newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None:
            raise SFDCError(f"CSV {path} is missing a header row.")
        rows = [dict(row) for row in reader]
        return rows, list(reader.fieldnames)


def configure_org(org_alias: str) -> None:
    """
    Validate that the Salesforce CLI and the target org are reachable, then persist the alias.
    """
    proc = _run_sf_command(["org", "display", "--target-org", org_alias, "--json"])
    if proc.returncode != 0:
        raise SFDCError(
            f"Failed to connect to org alias '{org_alias}'. "
            f"sf output: {proc.stderr.strip() or proc.stdout.strip()}"
        )

    set_configured_org(org_alias)


def set_configured_org(
    org_alias: str,
    *,
    instance_url: str | None = None,
    access_token: str | None = None,
    refresh_token: str | None = None,
) -> None:
    """Persist Salesforce config without invoking the CLI verification flow."""
    payload: Dict[str, Any] = {"org_alias": str(org_alias or "").strip()}
    if instance_url:
        payload["instance_url"] = str(instance_url).strip()
    if access_token:
        payload["access_token"] = str(access_token).strip()
    if refresh_token:
        payload["refresh_token"] = str(refresh_token).strip()
    SFDC_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    SFDC_CONFIG_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def get_configured_org() -> Optional[str]:
    """
    Read the stored default org alias, if any.

    Resolution order:
    1. G_GREMLIN_SFDC_ORG_ALIAS env var (Docker/CI)
    2. ~/.g_gremlin/sfdc.json
    3. Project-local .g_gremlin/sfdc.json (legacy fallback)
    """
    env_alias = os.environ.get("G_GREMLIN_SFDC_ORG_ALIAS", "").strip()
    if env_alias:
        return env_alias
    for path in (SFDC_CONFIG_PATH, SFDC_LEGACY_PROJECT_CONFIG_PATH):
        if not path.exists():
            continue
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            continue
        alias = data.get("org_alias")
        if isinstance(alias, str) and alias.strip():
            return alias.strip()
    return None


def run_soql(query: str, org_alias: Optional[str] = None) -> List[Dict[str, str]]:
    """
    Execute a SOQL query via the Salesforce CLI and return rows as dicts.

    Skips warning lines, parses CSV stdout, and raises on non-zero exit.
    """
    alias = org_alias or get_configured_org()
    if _sf_cli_available():
        if not alias:
            raise SFDCError("No SFDC org configured. Run 'g-gremlin sfdc connect --org-alias <alias>' first.")
        cmd = [
            "data",
            "query",
            "--target-org",
            alias,
            "--query",
            query,
            "--result-format",
            "csv",
        ]
        proc = _run_sf_command(cmd)
        if proc.returncode != 0:
            raise SFDCError(
                f"SOQL query failed (org {alias}). "
                f"stderr: {proc.stderr.strip() or proc.stdout.strip()}"
            )

        lines = _filtered_lines(proc.stdout)
        if not lines:
            return []
        reader = csv.reader(StringIO("\n".join(lines)))
        try:
            headers = next(reader)
        except StopIteration:
            return []

        rows: List[Dict[str, str]] = []
        for row in reader:
            if not row:
                continue
            row_dict = {headers[i]: row[i] if i < len(row) else "" for i in range(len(headers))}
            rows.append(row_dict)
        return rows

    try:
        return _run_soql_via_platform_rest(query, org_alias=alias)
    except Exception as exc:
        raise SFDCError(str(exc)) from exc


def enrich_csv_with_sfdc(
    input_path: Path,
    id_column: str,
    sobject: str,
    fields: List[Tuple[str, Optional[str]]],
    output_path: Path,
    org_alias: Optional[str] = None,
    chunk_size: int = 150,
) -> None:
    """
    Enrich a CSV by fetching fields from Salesforce for the given IDs.
    """
    rows, headers = read_csv(input_path)
    if id_column not in headers:
        raise SFDCError(f"ID column '{id_column}' not found in {input_path}.")

    # Keep original-case IDs for SOQL; use match keys (upper 15) for joins.
    raw_by_match: Dict[str, str] = {}
    for row in rows:
        raw_id = _clean_id(row.get(id_column, ""))
        if not raw_id:
            continue
        match_key = _match_key(raw_id)
        if match_key and match_key not in raw_by_match:
            raw_by_match[match_key] = raw_id

    if not raw_by_match:
        raise SFDCError(f"No IDs found in column '{id_column}'.")

    fields_unique = list(dict.fromkeys(fields))  # preserve order, remove dupes
    api_fields = [api for api, _ in fields_unique]
    select_fields = ["Id"] + [f for f in api_fields if f.lower() != "id"]
    alias_map: Dict[str, Optional[str]] = {api: alias for api, alias in fields_unique}

    results: Dict[str, Dict[str, str]] = {}
    raw_ids = list(raw_by_match.values())
    for chunk in _chunks(raw_ids, chunk_size):
        soql = _build_soql(sobject, select_fields, chunk)
        chunk_rows = run_soql(soql, org_alias=org_alias)
        for record in chunk_rows:
            sf_id = record.get("Id", "")
            match_key = _match_key(sf_id)
            if not sf_id:
                continue
            results[match_key] = record

    output_headers = list(headers)
    for field in select_fields:
        target = alias_map.get(field) or field
        if target not in output_headers:
            output_headers.append(target)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=output_headers)
        writer.writeheader()
        for row in rows:
            output_row = dict(row)
            match_key = _match_key(row.get(id_column, ""))
            sf_record = results.get(match_key, {})
            for field in select_fields:
                target = alias_map.get(field) or field
                if target == id_column:
                    continue
                output_row[target] = sf_record.get(field, "")
            writer.writerow(output_row)


def _parse_exported_report(report_payload: Dict[str, Any], *, report_id: str, api_version: str) -> Dict[str, Any]:
    report_metadata = report_payload.get("reportMetadata") or {}
    detail_columns = list(report_metadata.get("detailColumns") or [])

    ext = report_payload.get("reportExtendedMetadata") or {}
    detail_info = ext.get("detailColumnInfo") or {}
    columns: List[Dict[str, Any]] = []
    for col in detail_columns:
        info = detail_info.get(col) or {}
        columns.append(
            {
                "key": col,
                "label": info.get("label") or col,
                "data_type": info.get("dataType") or info.get("dataTypeName") or "",
            }
        )

    rows: List[Dict[str, Any]] = []
    fact_map = report_payload.get("factMap") or {}
    for section in _report_detail_sections(fact_map):
        for row in section.get("rows") or []:
            data_cells = row.get("dataCells") or []
            row_payload: Dict[str, Any] = {}
            for idx, col in enumerate(detail_columns):
                cell = data_cells[idx] if idx < len(data_cells) else {}
                value = cell.get("value")
                if value is None:
                    value = cell.get("label")
                row_payload[col] = value
            rows.append(row_payload)

    return {
        "report_id": report_id,
        "api_version": str(api_version),
        "name": report_metadata.get("name"),
        "fact_map_keys": sorted(fact_map.keys()),
        "columns": columns,
        "rows": rows,
    }


def _resolve_rest_api_version(org_alias: Optional[str]) -> str:
    try:
        identity = get_org_identity(org_alias=org_alias)
    except Exception:
        identity = {}
    raw = str(
        identity.get("api_version")
        or os.getenv("G_GREMLIN_SALESFORCE_API_VERSION", "").strip()
        or "62.0"
    ).strip()
    normalized = raw.lstrip("vV")
    return normalized or "62.0"


def _parse_api_version(value: str) -> Tuple[int, int]:
    normalized = str(value or "").strip().lstrip("vV")
    if not normalized:
        return (0, 0)
    parts = normalized.split(".")
    try:
        major = int(parts[0])
    except Exception:
        return (0, 0)
    minor = 0
    if len(parts) > 1:
        try:
            minor = int(parts[1])
        except Exception:
            minor = 0
    return (major, minor)


def _ensure_convert_lead_api_supported(api_version: str) -> None:
    parsed = _parse_api_version(api_version)
    if parsed < _MIN_CONVERT_LEAD_API_VERSION:
        raise SFDCError(
            "Salesforce convertLead REST requires API version 61.0 or newer; "
            f"resolved org API version was {api_version}."
        )


def _report_detail_sections(fact_map: Dict[str, Any]) -> List[Dict[str, Any]]:
    sections: List[Dict[str, Any]] = []
    for key in sorted(fact_map.keys()):
        section = fact_map.get(key) or {}
        if not isinstance(section, dict):
            continue
        if section.get("rows"):
            sections.append(section)
    return sections


def _filtered_lines(stdout: str) -> List[str]:
    lines: List[str] = []
    for line in stdout.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.lower().startswith("warning"):
            continue
        lines.append(stripped)
    return lines


def _clean_id(value: str) -> str:
    return str(value).strip() if value is not None else ""


def _match_key(value: str) -> str:
    """
    Case-insensitive match key (first 15 chars uppercased) for joining.
    """
    return _clean_id(value)[:15].upper()


def _chunks(items: List[str], size: int) -> Iterable[List[str]]:
    for i in range(0, len(items), size):
        yield items[i : i + size]


def _build_soql(sobject: str, fields: List[str], ids: List[str]) -> str:
    quoted_ids = ", ".join(f"'{escape_soql_value(id_val)}'" for id_val in ids)
    select_clause = ", ".join(fields)
    return f"SELECT {select_clause} FROM {sobject} WHERE Id IN ({quoted_ids})"


def _escape_value(value: Any) -> str:
    return escape_soql_value(value)


def _format_values(values: Dict[str, Any]) -> str:
    parts: List[str] = []
    for field, value in values.items():
        if value is None:
            parts.append(f"{field}=")
            continue
        if isinstance(value, bool):
            parts.append(f"{field}={'true' if value else 'false'}")
            continue
        if isinstance(value, (int, float)):
            parts.append(f"{field}={value}")
            continue
        escaped = _escape_value(value)
        parts.append(f"{field}='{escaped}'")
    return " ".join(parts)


def _is_valid_id(value: str) -> bool:
    if not value:
        return False
    trimmed = str(value).strip()
    if len(trimmed) not in (15, 18):
        return False
    return trimmed.isalnum()


def audit_ids(
    csv_path: Path,
    id_column: str,
    sobject: str,
    org_alias: Optional[str] = None,
    chunk_size: int = 150,
) -> Dict[str, object]:
    """
    Audit IDs in a CSV against Salesforce for format, prefix, duplicates, and existence.
    """
    rows, headers = read_csv(csv_path)
    if id_column not in headers:
        raise SFDCError(f"ID column '{id_column}' not found in {csv_path}.")

    expected_prefix = _PREFIX_BY_OBJECT.get(sobject.lower())
    invalid_format: List[Dict[str, object]] = []
    wrong_prefix: List[Dict[str, object]] = []
    duplicates: List[Dict[str, object]] = []
    id_to_rows: Dict[str, List[int]] = {}
    raw_by_match: Dict[str, str] = {}

    for idx, row in enumerate(rows):
        raw = _clean_id(row.get(id_column, ""))
        if not raw:
            continue
        match_key = _match_key(raw)
        if not _is_valid_id(raw):
            invalid_format.append({"row": idx + 2, "value": raw})
            continue
        if expected_prefix and not match_key.startswith(expected_prefix):
            wrong_prefix.append({"row": idx + 2, "value": raw, "expected_prefix": expected_prefix})
        id_to_rows.setdefault(match_key, []).append(idx + 2)
        if match_key not in raw_by_match:
            raw_by_match[match_key] = raw

    for canonical, rows_list in id_to_rows.items():
        if len(rows_list) > 1:
            duplicates.append({"id": canonical, "rows": rows_list})

    found: set[str] = set()
    raw_ids = list(raw_by_match.values())
    if raw_ids:
        for chunk in _chunks(raw_ids, chunk_size):
            soql = _build_soql(sobject, ["Id"], chunk)
            for record in run_soql(soql, org_alias=org_alias):
                sf_id = record.get("Id", "")
                match_key = _match_key(sf_id)
                if sf_id:
                    found.add(match_key)

    missing_in_sfdc: List[Dict[str, object]] = []
    for canonical, rows_list in id_to_rows.items():
        if canonical not in found:
            missing_in_sfdc.append({"id": canonical, "rows": rows_list})

    report = {
        "total_rows": len(rows),
        "checked_ids": len(raw_ids),
        "issues": {
            "invalid_format": invalid_format,
            "wrong_prefix": wrong_prefix,
            "duplicates": duplicates,
            "missing_in_sfdc": missing_in_sfdc,
        },
    }
    return report
