"""
Downhole Dynamics — Python SDK
================================

Thin, dependency-free Python client for the public SDK endpoints exposed at
``/api/public/sdk/v1/*``. Uses only the Python standard library (``urllib`` +
``dataclasses``). Authenticates with a Bearer API key minted from the
in-app *Settings → API keys* card.

This module currently ships first-class typed wrappers for:

    * ``solver_spec()``                        — GET  /solver-spec
    * ``non_planar_3d_run(...)``               — POST /non-planar-3d/run
    * ``parent_child_analyze(...)``            — POST /parent-child/analyze   ← typed below
    * ``list_simulations() / create_simulation() / ...``  — /simulations CRUD

Example
-------

    from downhole_sdk import DownholeClient, Parent, Child, Reservoir, XY

    client = DownholeClient(
        base_url="https://wellboregenius.com",
        api_key="dh_live_…",
    )

    result = client.parent_child_analyze(
        parents=[
            Parent(
                id="P1",
                heel=XY(x=0.0,    y=-660.0),
                toe =XY(x=10_000, y=-660.0),
                drawdown_psi=2_500.0,
                drainage_radius_ft=600.0,
            ),
            Parent(
                id="P2",
                heel=XY(x=0.0,    y= 660.0),
                toe =XY(x=10_000, y= 660.0),
                drawdown_psi=2_500.0,
            ),
        ],
        child=Child(
            id="C1",
            heel=XY(x=0.0,    y=0.0),
            toe =XY(x=10_000, y=0.0),
            stage_count=40,
        ),
        reservoir=Reservoir(biot_alpha=0.7, poisson_ratio=0.25),
    )

    print(result.summary.worst_stage_id, result.summary.mean_depletion_psi)
    for stage in result.stages[:3]:
        print(stage.stage_id, stage.delta_p_psi, stage.bashing_risk)
"""

from __future__ import annotations

import json
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field, is_dataclass
from typing import Any, Dict, List, Literal, Optional


# ---------------------------------------------------------------------------
# Request models — mirror the Zod schemas in
# src/routes/api/public/sdk/v1/parent-child.analyze.ts
# ---------------------------------------------------------------------------

BashingRisk = Literal["low", "watch", "high"]


@dataclass
class XY:
    """2-D plan-view coordinate in feet."""

    x: float
    y: float


@dataclass
class Parent:
    """One producing parent well."""

    id: str
    heel: XY
    toe: XY
    drawdown_psi: float
    label: Optional[str] = None
    drainage_radius_ft: Optional[float] = None  # falls back to lateral length


@dataclass
class Child:
    """The new child well being analysed for frac-hit risk."""

    id: str
    heel: XY
    toe: XY
    stage_count: int
    label: Optional[str] = None


@dataclass
class Reservoir:
    biot_alpha: float            # 0–1.5, typically 0.6–0.8
    poisson_ratio: float         # 0.01–0.49, typically 0.20–0.28


@dataclass
class ParentChildRequest:
    parents: List[Parent]
    child: Child
    reservoir: Reservoir
    asymmetry_sensitivity: Optional[float] = None


# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------


@dataclass
class StageResult:
    stage_id: str
    """Stable per-stage id, e.g. ``"C1-s07"``."""
    midpoint: XY
    delta_p_psi: float
    """Local Δp at the stage midpoint from log-radial depletion."""
    delta_sigma_h_psi: float
    """Eaton-style horizontal stress drop = α(1-2ν)/(1-ν) · Δp."""
    nearest_parent_id: str
    nearest_parent_distance_ft: float
    asymmetry_pct: float
    """Half-length imbalance toward the depleted parent, in percent."""
    bashing_risk: BashingRisk


@dataclass
class SummaryResult:
    high_count: int
    watch_count: int
    low_count: int
    worst_stage_id: Optional[str]
    mean_depletion_psi: float
    mean_d_sigma_h_psi: float


@dataclass
class ParentChildResponse:
    stages: List[StageResult]
    summary: SummaryResult
    generated_at: str


# ---------------------------------------------------------------------------
# JSON (de)serialisation helpers — snake_case ↔ camelCase
# ---------------------------------------------------------------------------


def _snake_to_camel(name: str) -> str:
    head, *tail = name.split("_")
    return head + "".join(part.title() for part in tail)


def _camel_to_snake(name: str) -> str:
    out: List[str] = []
    for i, ch in enumerate(name):
        if ch.isupper() and i > 0:
            out.append("_")
        out.append(ch.lower())
    return "".join(out)


def _to_camel(obj: Any) -> Any:
    if is_dataclass(obj):
        return _to_camel(asdict(obj))
    if isinstance(obj, dict):
        return {
            _snake_to_camel(k): _to_camel(v)
            for k, v in obj.items()
            if v is not None
        }
    if isinstance(obj, list):
        return [_to_camel(v) for v in obj]
    return obj


def _to_snake(obj: Any) -> Any:
    if isinstance(obj, dict):
        return {_camel_to_snake(k): _to_snake(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_to_snake(v) for v in obj]
    return obj


# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------


class DownholeApiError(Exception):
    """Raised when the SDK endpoint returns a non-2xx response."""

    def __init__(self, status: int, code: Optional[str], message: str, body: Any = None):
        super().__init__(f"[{status} {code or 'error'}] {message}")
        self.status = status
        self.code = code
        self.message = message
        self.body = body


# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------


@dataclass
class DownholeClient:
    base_url: str
    api_key: str
    timeout_seconds: float = 60.0
    user_agent: str = "downhole-sdk-python/1.0"

    # -- low-level transport -------------------------------------------------

    def _request(
        self,
        method: str,
        path: str,
        *,
        body: Optional[Any] = None,
        query: Optional[Dict[str, Any]] = None,
    ) -> Any:
        url = self.base_url.rstrip("/") + path
        if query:
            from urllib.parse import urlencode

            url += "?" + urlencode({k: v for k, v in query.items() if v is not None})

        data: Optional[bytes] = None
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Accept": "application/json",
            "User-Agent": self.user_agent,
        }
        if body is not None:
            data = json.dumps(_to_camel(body)).encode("utf-8")
            headers["Content-Type"] = "application/json"

        req = urllib.request.Request(url, data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout_seconds) as resp:
                raw = resp.read().decode("utf-8") if resp.length != 0 else ""
                return json.loads(raw) if raw else None
        except urllib.error.HTTPError as exc:
            payload: Any = None
            try:
                payload = json.loads(exc.read().decode("utf-8"))
            except Exception:
                payload = None
            code = payload.get("code") if isinstance(payload, dict) else None
            msg = (
                payload.get("error")
                if isinstance(payload, dict) and payload.get("error")
                else exc.reason or "Request failed"
            )
            raise DownholeApiError(exc.code, code, str(msg), payload) from exc

    # -- parent–child --------------------------------------------------------

    def parent_child_analyze(
        self,
        *,
        parents: List[Parent],
        child: Child,
        reservoir: Reservoir,
        asymmetry_sensitivity: Optional[float] = None,
    ) -> ParentChildResponse:
        """Run the analytical parent–child interference engine.

        Pure in-memory call — no DB writes. Returns per-stage Δp, Δσ_h,
        asymmetry %, nearest parent + bashing-risk chip, plus a rolled-up
        summary.
        """
        request = ParentChildRequest(
            parents=parents,
            child=child,
            reservoir=reservoir,
            asymmetry_sensitivity=asymmetry_sensitivity,
        )
        raw = self._request(
            "POST",
            "/api/public/sdk/v1/parent-child/analyze",
            body=request,
        )
        snake = _to_snake(raw)
        return ParentChildResponse(
            stages=[
                StageResult(
                    stage_id=s["stage_id"],
                    midpoint=XY(**s["midpoint"]),
                    delta_p_psi=s["delta_p_psi"],
                    delta_sigma_h_psi=s["delta_sigma_h_psi"],
                    nearest_parent_id=s["nearest_parent_id"],
                    nearest_parent_distance_ft=s["nearest_parent_distance_ft"],
                    asymmetry_pct=s["asymmetry_pct"],
                    bashing_risk=s["bashing_risk"],
                )
                for s in snake["stages"]
            ],
            summary=SummaryResult(**snake["summary"]),
            generated_at=snake["generated_at"],
        )

    # -- other endpoints (thin pass-through) --------------------------------

    def solver_spec(self) -> Dict[str, Any]:
        return self._request("GET", "/api/public/sdk/v1/solver-spec")

    def non_planar_3d_run(self, **kwargs: Any) -> Dict[str, Any]:
        return self._request("POST", "/api/public/sdk/v1/non-planar-3d/run", body=kwargs)

    def non_planar_3d_multi_stage(
        self,
        *,
        steps: int,
        stages: List[Dict[str, Any]],
        crossStageMerge: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """POST /non-planar-3d/multi-stage with per-stage specs + cross-stage merge knobs
        (tolFt, cadenceSteps?, minStageGap?, debug?).
        """
        body: Dict[str, Any] = {"steps": steps, "stages": stages}
        if crossStageMerge is not None:
            body["crossStageMerge"] = crossStageMerge
        return self._request(
            "POST", "/api/public/sdk/v1/non-planar-3d/multi-stage", body=body,
        )
