"""
Deterministic Salesforce data/schema snapshots for g-gremlin.
"""

from __future__ import annotations

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

from g_gremlin import sfdc as sfdc_helpers
from g_gremlin.errors import missing_pyyaml_error


@dataclass
class SnapshotFile:
    path: str
    sha256: str
    rows: int
    columns: int
    kind: str

    def to_dict(self) -> Dict[str, Any]:
        return {
            "path": self.path,
            "sha256": self.sha256,
            "rows": self.rows,
            "columns": self.columns,
            "kind": self.kind,
        }


@dataclass
class SnapshotResult:
    out_dir: Path
    manifest_path: Path
    files: List[SnapshotFile]
    warnings: List[str] = field(default_factory=list)
    spec: Dict[str, Any] = field(default_factory=dict)
    identity: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "out_dir": str(self.out_dir),
            "manifest_path": str(self.manifest_path),
            "files": [f.to_dict() for f in self.files],
            "warnings": self.warnings,
            "spec": self.spec,
            "identity": self.identity,
        }


def load_snapshot_spec(path: Path) -> Dict[str, Any]:
    if not path.exists():
        raise ValueError(f"Snapshot spec 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 snapshot(
    out_dir: Path,
    *,
    spec: Dict[str, Any],
    org_alias: Optional[str] = None,
    include_schema: bool = False,
) -> SnapshotResult:
    out_dir.mkdir(parents=True, exist_ok=True)
    data_dir = out_dir / "data"
    schema_dir = out_dir / "schema"
    data_dir.mkdir(exist_ok=True)
    if include_schema:
        schema_dir.mkdir(exist_ok=True)

    identity = {}
    try:
        identity = sfdc_helpers.get_org_identity(org_alias=org_alias)
    except Exception:
        identity = {}

    normalized = _normalize_spec(spec)
    files: List[SnapshotFile] = []
    warnings: List[str] = []

    for obj_spec in normalized.get("objects", []):
        name = obj_spec.get("name") or obj_spec.get("object")
        alias = obj_spec.get("alias") or name
        if not name:
            warnings.append("Skipping object snapshot: missing name.")
            continue
        try:
            sfdc_helpers.ensure_valid_identifier(str(name), "object name")
        except Exception as exc:
            warnings.append(f"Invalid object name '{name}': {exc}")
            continue
        try:
            rows = _query_object(obj_spec, org_alias=org_alias)
        except Exception as exc:
            warnings.append(f"Failed to snapshot {name}: {exc}")
            continue

        headers = _determine_headers(rows, obj_spec)
        rows_sorted = _sort_rows(rows, headers, obj_spec)

        csv_path = data_dir / f"{alias}.csv"
        json_path = data_dir / f"{alias}.json"
        _write_csv(csv_path, rows_sorted, headers)
        _write_json(json_path, rows_sorted, headers)

        files.append(_file_info(csv_path, rows_sorted, headers, kind="data_csv"))
        files.append(_file_info(json_path, rows_sorted, headers, kind="data_json"))

        if include_schema:
            try:
                schema_payload = _describe_object(name, org_alias=org_alias)
                schema_path = schema_dir / f"{alias}.json"
                schema_path.write_text(json.dumps(schema_payload, indent=2), encoding="utf-8")
                files.append(_file_info(schema_path, [], [], kind="schema_json"))
            except Exception as exc:
                warnings.append(f"Schema describe failed for {name}: {exc}")

    manifest = {
        "version": 1,
        "created_at": datetime.now(timezone.utc).isoformat(),
        "org": identity,
        "spec": normalized,
        "files": [f.to_dict() for f in files],
        "warnings": warnings,
    }
    manifest_path = out_dir / "manifest.json"
    manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    files.append(_file_info(manifest_path, [], [], kind="manifest"))

    return SnapshotResult(
        out_dir=out_dir,
        manifest_path=manifest_path,
        files=files,
        warnings=warnings,
        spec=normalized,
        identity=identity,
    )


def _normalize_spec(spec: Dict[str, Any]) -> Dict[str, Any]:
    normalized = dict(spec or {})
    objects = normalized.get("objects") or []
    if not isinstance(objects, list):
        objects = []
    normalized["objects"] = objects
    return normalized


def _query_object(obj_spec: Dict[str, Any], *, org_alias: Optional[str]) -> List[Dict[str, Any]]:
    soql = obj_spec.get("soql")
    if soql:
        return sfdc_helpers.run_soql(str(soql), org_alias=org_alias)

    name = obj_spec.get("name") or obj_spec.get("object")
    fields = obj_spec.get("fields") or []
    if not fields or not name:
        raise ValueError("Snapshot object spec must include name and fields (or soql).")
    sfdc_helpers.ensure_valid_identifier(str(name), "object name")
    fields_list = [str(f).strip() for f in fields if str(f).strip()]
    for field in fields_list:
        sfdc_helpers.ensure_valid_identifier(field, "field")
    if "Id" not in fields_list:
        fields_list.insert(0, "Id")
    select_clause = ", ".join(fields_list)
    where = obj_spec.get("where") or obj_spec.get("filter")
    order_by = obj_spec.get("order_by")
    limit = obj_spec.get("limit")
    if where and ";" in str(where):
        raise ValueError("WHERE clause contains ';' which is not allowed.")
    if order_by:
        _validate_order_by(str(order_by))
    soql = f"SELECT {select_clause} FROM {name}"
    if where:
        soql += f" WHERE {where}"
    if order_by:
        soql += f" ORDER BY {order_by}"
    if limit:
        soql += f" LIMIT {int(limit)}"
    return sfdc_helpers.run_soql(soql, org_alias=org_alias)


def _determine_headers(rows: List[Dict[str, Any]], obj_spec: Dict[str, Any]) -> List[str]:
    fields = obj_spec.get("fields")
    if fields:
        ordered = [str(f).strip() for f in fields if str(f).strip()]
        if "Id" in ordered:
            return ordered
        return ["Id", *ordered] if rows and "Id" in rows[0] else ordered
    if not rows:
        return []
    keys = list(rows[0].keys())
    if "Id" in keys:
        keys.remove("Id")
        return ["Id", *sorted(keys)]
    return sorted(keys)


def _sort_rows(
    rows: List[Dict[str, Any]],
    headers: List[str],
    obj_spec: Dict[str, Any],
) -> List[Dict[str, Any]]:
    if not rows:
        return []
    sort_key = obj_spec.get("sort_by")
    if sort_key:
        return sorted(rows, key=lambda r: str(r.get(sort_key) or ""))
    if "Id" in headers:
        return sorted(rows, key=lambda r: str(r.get("Id") or ""))
    primary = headers[0] if headers else ""
    return sorted(rows, key=lambda r: str(r.get(primary) or ""))


def _write_csv(path: Path, rows: List[Dict[str, Any]], headers: List[str]) -> None:
    with path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=headers)
        writer.writeheader()
        for row in rows:
            writer.writerow({h: row.get(h, "") for h in headers})


def _write_json(path: Path, rows: List[Dict[str, Any]], headers: List[str]) -> None:
    ordered_rows = [{h: row.get(h, "") for h in headers} for row in rows]
    path.write_text(json.dumps(ordered_rows, indent=2), encoding="utf-8")


def _file_info(path: Path, rows: List[Dict[str, Any]], headers: List[str], kind: str) -> SnapshotFile:
    sha = _sha256_file(path)
    return SnapshotFile(
        path=str(path),
        sha256=sha,
        rows=len(rows),
        columns=len(headers),
        kind=kind,
    )


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 _describe_object(name: str, org_alias: Optional[str]) -> Dict[str, Any]:
    return sfdc_helpers.describe_sobject(name, org_alias=org_alias)


def _validate_order_by(order_by: str) -> None:
    parts = [p.strip() for p in order_by.split(",") if p.strip()]
    for part in parts:
        tokens = part.split()
        if len(tokens) == 1:
            field = tokens[0]
        elif len(tokens) == 2 and tokens[1].upper() in {"ASC", "DESC"}:
            field = tokens[0]
        else:
            raise ValueError(f"Invalid ORDER BY clause: '{part}'")
        sfdc_helpers.ensure_valid_identifier(field, "order_by field")
