"""SP40: thin HTTP client for the EdiNation / Edifabric validation API.

Wraps the two-step /v2/x12/read (raw EDI → ``X12Interchange`` JSON) and
/v2/x12/validate (``X12Interchange`` JSON → ``OperationResult``) flow.

Cyclone has no other outbound HTTP today; this is the first such
client. ``httpx`` is already a project dep (used by the test suite)
so we don't introduce a new dependency.

Public surface:

- :func:`read_interchange` — POST raw EDI bytes to /x12/read.
- :func:`validate_interchange` — POST ``X12Interchange`` JSON to /x12/validate.
- :func:`validate_edi` — composes the two; this is what the CLI and
  pre-upload gate use.
- :class:`EdifabricError` — raised on 4xx/5xx so callers can surface
  the Edifabric error verbatim (the body is a dict or string).

The API key is taken from :func:`cyclone.secrets.get_secret('edifabric.api_key')`
which maps to ``CYCLONE_EDIFABRIC_API_KEY`` env var or macOS Keychain.
Tests inject a canned key (and a mocked transport) via the factory
hook :func:`set_transport_factory` so no live HTTP hits the network.

The endpoint contract (from the EdiNation API reference,
``https://support.edifabric.com/hc/en-us/sections/360005605638``):

- ``POST https://api.edination.com/v2/x12/read``
  - Headers: ``Ocp-Apim-Subscription-Key: <key>``, ``Content-Type: application/octet-stream``
  - Body: raw EDI bytes
  - Response (200): JSON array of ``X12Interchange`` objects (cyclone always sends one interchange)
- ``POST https://api.edination.com/v2/x12/validate``
  - Headers: ``Ocp-Apim-Subscription-Key: <key>``, ``Content-Type: application/json``
  - Body: one ``X12Interchange`` object
  - Response (200): ``OperationResult`` (``Status`` ∈ {``"success"``, ``"warning"``, ``"error"``}, ``Details`` array)

We treat any non-2xx as an :class:`EdifabricError`. The response body
is preserved verbatim so callers can inspect it.
"""
from __future__ import annotations

import logging
import os
from typing import Any, Callable

import httpx

_log = logging.getLogger(__name__)

_BASE_URL = "https://api.edination.com/v2/x12"
_READ_PATH = "/read"
_VALIDATE_PATH = "/validate"

# Subscription-key header name (Azure API Management convention).
_SUB_HEADER = "Ocp-Apim-Subscription-Key"


class EdifabricError(RuntimeError):
    """Raised when the Edifabric API returns a non-2xx response.

    Attributes:
        status_code: HTTP status code returned by Edifabric.
        body: The response body — usually a dict with ``error`` /
            ``message`` keys for 4xx, or a string for 5xx / network
            errors. Preserved verbatim so the caller can surface it
            to the operator.
    """

    def __init__(self, status_code: int, body: Any) -> None:
        self.status_code = status_code
        self.body = body
        body_repr = repr(body) if not isinstance(body, str) else body
        msg = f"Edifabric API returned {status_code}: {body_repr}"
        super().__init__(msg)


# --- Transport injection (test seam) -----------------------------------

# Default transport factory builds a normal httpx.Client. Tests can
# call set_transport_factory() with a callable that returns a Client
# backed by httpx.MockTransport (no live HTTP).
_transport_factory: Callable[[], httpx.Client] = lambda: httpx.Client(timeout=30.0)


def set_transport_factory(factory: Callable[[], httpx.Client]) -> None:
    """Inject an ``httpx.Client`` factory for tests.

    The factory must return an ``httpx.Client`` whose ``transport`` is
    a mock (e.g. ``httpx.MockTransport(handler)``) so no real HTTP is
    performed. Returns the previous factory so tests can restore it.
    """
    global _transport_factory
    prev = _transport_factory
    _transport_factory = factory
    return prev  # type: ignore[return-value]


def _reset_transport_factory() -> None:
    """Restore the default transport factory (called in test cleanup)."""
    global _transport_factory
    _transport_factory = lambda: httpx.Client(timeout=30.0)


# --- Public surface ----------------------------------------------------


def _get_api_key() -> str:
    """Resolve the Edifabric API key.

    Looks up ``CYCLONE_EDIFABRIC_API_KEY`` (or the keychain entry
    ``cyclone / edifabric.api_key``). The secrets module is imported
    lazily so test setups that mock it can do so before first use.
    """
    from cyclone.secrets import get_secret

    key = get_secret("edifabric.api_key")
    if not key:
        raise EdifabricError(
            0,
            "Edifabric API key not configured; set CYCLONE_EDIFABRIC_API_KEY "
            "env var or run `cyclone secrets set edifabric.api_key <key>`",
        )
    return key


def read_interchange(edi_bytes: bytes, *, api_key: str | None = None) -> dict:
    """POST raw EDI bytes to /x12/read and return the first X12Interchange.

    The /x12/read endpoint accepts a multi-interchange file and returns
    an array. Cyclone only ever sends single interchanges, so we return
    the first (and only) element. If the file contains multiple
    interchanges, callers should call ``validate_interchange`` on each.
    """
    if not isinstance(edi_bytes, (bytes, bytearray)):
        raise TypeError(
            f"edi_bytes must be bytes, got {type(edi_bytes).__name__}"
        )
    key = api_key if api_key is not None else _get_api_key()
    headers = {
        _SUB_HEADER: key,
        "Content-Type": "application/octet-stream",
    }
    with _transport_factory() as client:
        resp = client.post(
            f"{_BASE_URL}{_READ_PATH}",
            content=bytes(edi_bytes),
            headers=headers,
        )
    if not (200 <= resp.status_code < 300):
        raise EdifabricError(resp.status_code, _safe_body(resp))
    data = resp.json()
    if not isinstance(data, list) or not data:
        raise EdifabricError(
            502,
            f"unexpected /x12/read response shape: expected non-empty list, "
            f"got {type(data).__name__} of length {len(data) if hasattr(data, '__len__') else '?'}",
        )
    return data[0]


def validate_interchange(x12_json: dict, *, api_key: str | None = None) -> dict:
    """POST an X12Interchange JSON to /x12/validate and return the OperationResult.

    The OperationResult schema (per the EdiNation docs):

    - ``Status`` — ``"success"`` / ``"warning"`` / ``"error"``.
    - ``Details`` — array of ``{Index, SegmentId, Value, Message, Status, ...}``.
    - ``LastIndex`` — 1-based index of the last processed segment.

    We do NOT raise on ``Status == "error"`` — the caller decides whether
    to fail-closed (the pre-upload gate does; the CLI prints and exits
    with the appropriate code). Non-2xx HTTP responses DO raise
    :class:`EdifabricError`.
    """
    if not isinstance(x12_json, dict):
        raise TypeError(
            f"x12_json must be a dict, got {type(x12_json).__name__}"
        )
    key = api_key if api_key is not None else _get_api_key()
    headers = {
        _SUB_HEADER: key,
        "Content-Type": "application/json",
    }
    with _transport_factory() as client:
        resp = client.post(
            f"{_BASE_URL}{_VALIDATE_PATH}",
            json=x12_json,
            headers=headers,
        )
    if not (200 <= resp.status_code < 300):
        raise EdifabricError(resp.status_code, _safe_body(resp))
    return resp.json()


def validate_edi(edi_bytes: bytes, *, api_key: str | None = None) -> dict:
    """Two-step convenience: read → validate. Returns the OperationResult.

    This is what ``cyclone validate-837 <file>`` and the pre-upload
    gate in ``resubmit-rejected-claims`` call. Raises
    :class:`EdifabricError` on transport / non-2xx errors. The
    OperationResult is returned verbatim so the caller can inspect
    ``Status`` and ``Details`` themselves.
    """
    x12 = read_interchange(edi_bytes, api_key=api_key)
    return validate_interchange(x12, api_key=api_key)


# --- Internal helpers --------------------------------------------------


def _safe_body(resp: httpx.Response) -> Any:
    """Return the response body, preferring JSON when possible."""
    try:
        return resp.json()
    except Exception:  # noqa: BLE001
        text = resp.text
        return text if text else f"<empty {resp.status_code} response>"