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

from __future__ import annotations

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

SFDC_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
_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 _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:
        return subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout_sec,
            stdin=subprocess.DEVNULL,
        )
    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 _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:
        raise SFDCError(proc.stderr.strip() or proc.stdout.strip() or "SF CLI returned non-zero exit code.")
    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 _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 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)]
    if org_alias:
        args += ["--target-org", 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)]
    if org_alias:
        args += ["--target-org", 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)]
    if org_alias:
        args += ["--target-org", 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)]
    if org_alias:
        args += ["--target-org", 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"]
    if org_alias:
        args += ["--target-org", 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]:
    cache_key = org_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=org_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,
    ]
    if org_alias:
        args += ["--target-org", 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,
    ]
    if org_alias:
        args += ["--target-org", org_alias]
    return _run_sf_json(args)


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]:
    command_path = get_describe_command_path()
    if command_path is None:
        raise SFDCError("No supported Salesforce describe command found (tried sobject describe variants).")
    args = [*command_path, "--sobject", sobject]
    if org_alias:
        args += ["--target-org", org_alias]
    return _run_sf_json(args)


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 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()}"
        )

    SFDC_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    SFDC_CONFIG_PATH.write_text(json.dumps({"org_alias": org_alias}, indent=2), encoding="utf-8")


def get_configured_org() -> Optional[str]:
    """
    Read the stored default org alias, if any.
    """
    if not SFDC_CONFIG_PATH.exists():
        return None
    try:
        data = json.loads(SFDC_CONFIG_PATH.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return None
    return data.get("org_alias")


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 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


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 _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
