from __future__ import annotations

import base64
import json
import os
from pathlib import Path
from typing import Any, Sequence
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen

from log_utils import LOG_EVENT_TYPE, build_log_event
from models import (
    AlertRecord,
    BatchDiagnosticRecord,
    EventRecord,
    HealthScoreRecord,
    LayerSummaryRecord,
    MetricRecord,
    ParameterDistributionRecord,
    ParameterSummaryRecord,
    PhaseRecord,
    RunContext,
    SystemTelemetryRecord,
)
from persistence.serialization import (
    alert_from_payload,
    analysis_outputs_from_payload,
    artifact_from_payload,
    artifact_payload_from_payload,
    batch_diagnostic_from_payload,
    encode_status,
    event_from_payload,
    health_score_from_payload,
    layer_summary_from_payload,
    metric_from_payload,
    parameter_distribution_from_payload,
    parameter_summary_from_payload,
    phase_from_payload,
    record_list_from_payload,
    run_context_from_payload,
    sequence_from_payload,
    telemetry_from_payload,
    to_api_payload,
)
from schemas import validate_record

_DEFAULT_BASE_URL = "http://127.0.0.1:8765"
_API_PREFIX = "/api/v1"
_DEFAULT_TIMEOUT_SECONDS = 15.0
_USER_KEY_ENV_VAR = "QUARK_USER_KEY"
_SUPPORTED_AUTH_MODES = {"header", "bearer"}


class SessionStoreError(RuntimeError):
    """Raised when the persistence service cannot satisfy an SDK request."""

    def __init__(
        self,
        message: str,
        *,
        status_code: int | None = None,
        error_code: str | None = None,
        error_type: str | None = None,
    ) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.error_code = error_code
        self.error_type = error_type


class RunNotFoundError(SessionStoreError):
    """Raised when the requested run does not exist in the persistence service."""


class InvalidRunStateError(SessionStoreError):
    """Raised when a lifecycle transition is not allowed by the persistence service."""


class LocalSessionStore:
    """Compatibility facade that persists SDK data through the REST service."""

    def __init__(
        self,
        storage_dir: str | Path,
        *,
        base_url: str | None = None,
        user_key: str | None = None,
        auth_mode: str | None = None,
        timeout_seconds: float | None = None,
    ) -> None:
        self.storage_dir = str(Path(storage_dir).resolve())
        self.base_url = _normalize_base_url(base_url or os.getenv("QUARK_PERSISTENCE_URL") or _DEFAULT_BASE_URL)
        self.user_key = _resolve_user_key(user_key)
        self.auth_mode = _resolve_auth_mode(auth_mode or os.getenv("QUARK_PERSISTENCE_AUTH_MODE"))
        self.timeout_seconds = _resolve_timeout_seconds(timeout_seconds)
        self._allocated_sequence_by_key: dict[tuple[str, str], int] = {}

    def create_run(
        self,
        *,
        project_name: str,
        config: dict[str, Any] | None = None,
        tags: Sequence[str] | None = None,
        parent_run_id: str | None = None,
        environment: dict[str, Any] | None = None,
        git_commit: str | None = None,
        strict_mode: bool = False,
    ) -> RunContext:
        payload: dict[str, Any] = {
            "project_name": project_name,
            "strict_mode": bool(strict_mode),
        }
        if config is not None:
            payload["config"] = to_api_payload(config)
        if tags is not None:
            payload["tags"] = [str(tag) for tag in tags]
        if parent_run_id is not None:
            payload["parent_run_id"] = parent_run_id
        if environment is not None:
            payload["environment"] = to_api_payload(environment)
        if git_commit is not None:
            payload["git_commit"] = git_commit
        response = self._request("POST", f"{_API_PREFIX}/runs", payload=payload)
        context = run_context_from_payload(response, storage_dir=self.storage_dir)
        self._sync_sequence_cache(context.run_id, "event")
        return context

    def load_run(self, run_id: str) -> RunContext:
        response = self._request("GET", f"{_API_PREFIX}/runs/{_quote(run_id)}")
        return run_context_from_payload(response, storage_dir=self.storage_dir)

    def resume_run(self, run_id: str) -> RunContext:
        response = self._request("POST", f"{_API_PREFIX}/runs/{_quote(run_id)}/resume")
        context = run_context_from_payload(response, storage_dir=self.storage_dir)
        self._sync_sequence_cache(context.run_id, "event")
        return context

    def end_run(self, run_id: str, status: str) -> RunContext:
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/end",
            payload={"status": encode_status(status)},
        )
        context = run_context_from_payload(response, storage_dir=self.storage_dir)
        self._sync_sequence_cache(context.run_id, "event")
        return context

    def add_tag(self, run_id: str, tag: str) -> RunContext:
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/tags",
            payload={"tag": tag},
        )
        return run_context_from_payload(response, storage_dir=self.storage_dir)

    def add_log(
        self,
        run_id: str,
        message: str,
        *,
        identifier: str | None = "Info",
        step: int | None = None,
        epoch: int | None = None,
        status: str | None = None,
        payload: dict[str, Any] | None = None,
        timestamp: str | None = None,
        event_type: str = LOG_EVENT_TYPE,
    ) -> EventRecord:
        record = build_log_event(
            run_id=run_id,
            sequence_id=self.allocate_event_sequence(run_id),
            message=message,
            identifier=identifier,
            event_type=event_type,
            step=step,
            epoch=epoch,
            status=status,
            payload=payload,
            timestamp=timestamp,
        )
        persisted = self.write_events(run_id, (record,))
        return persisted[0] if persisted else record

    def allocate_event_sequence(self, run_id: str) -> int:
        return self._allocate_sequence(run_id, "event")

    def allocate_metric_sequence(self, run_id: str) -> int:
        return self._allocate_sequence(run_id, "metric")

    def allocate_system_telemetry_sequence(self, run_id: str) -> int:
        return self._allocate_sequence(run_id, "telemetry")

    def get_next_layer_summary_sequence(self, run_id: str) -> int:
        return self._read_sequence(run_id, "layer-summary")

    def get_next_parameter_summary_sequence(self, run_id: str) -> int:
        return self._read_sequence(run_id, "parameter-summary")

    def get_next_parameter_distribution_sequence(self, run_id: str) -> int:
        return self._read_sequence(run_id, "parameter-distribution")

    def get_next_batch_diagnostic_sequence(self, run_id: str) -> int:
        return self._read_sequence(run_id, "batch-diagnostic")

    def get_next_artifact_sequence(self, run_id: str) -> int:
        return self._read_sequence(run_id, "artifact")

    def write_events(
        self,
        run_id: str,
        events: Sequence[EventRecord],
    ) -> tuple[EventRecord, ...]:
        if not events:
            return ()
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/events",
            payload={"events": self._record_payloads(events)},
        )
        persisted = record_list_from_payload(response, event_from_payload)
        self._observe_sequences(run_id, "event", persisted)
        return persisted

    def read_events(
        self,
        run_id: str,
        *,
        event_type: str | None = None,
        event_types: Sequence[str] | None = None,
        severity: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        limit: int | None = None,
    ) -> tuple[EventRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/events",
            query=_query(
                event_type=event_type,
                event_types=event_types,
                severity=severity,
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, event_from_payload)

    def write_scalar_metrics(
        self,
        run_id: str,
        metrics: Sequence[MetricRecord],
    ) -> tuple[MetricRecord, ...]:
        if not metrics:
            return ()
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/metrics",
            payload={"metrics": self._record_payloads(metrics)},
        )
        persisted = record_list_from_payload(response, metric_from_payload)
        self._observe_sequences(run_id, "metric", persisted)
        return persisted

    def read_scalar_metrics(
        self,
        run_id: str,
        *,
        metric_key: str | None = None,
        namespace: str | None = None,
        metric_name: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        limit: int | None = None,
    ) -> tuple[MetricRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/metrics",
            query=_query(
                metric_key=metric_key,
                namespace=namespace,
                metric_name=metric_name,
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, metric_from_payload)

    def write_system_telemetry(
        self,
        run_id: str,
        telemetry: Sequence[SystemTelemetryRecord],
        *,
        scalar_metrics: Sequence[MetricRecord] = (),
    ) -> tuple[SystemTelemetryRecord, ...]:
        if not telemetry and not scalar_metrics:
            return ()
        payload: dict[str, Any] = {}
        if telemetry:
            payload["telemetry"] = self._record_payloads(telemetry)
        if scalar_metrics:
            payload["scalar_metrics"] = self._record_payloads(scalar_metrics)
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/telemetry",
            payload=payload,
        )
        persisted = record_list_from_payload(response, telemetry_from_payload)
        self._observe_sequences(run_id, "telemetry", persisted)
        if scalar_metrics:
            self._observe_sequences(run_id, "metric", scalar_metrics)
        return persisted

    def read_system_telemetry(
        self,
        run_id: str,
        *,
        boundary: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        limit: int | None = None,
    ) -> tuple[SystemTelemetryRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/telemetry",
            query=_query(
                boundary=boundary,
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, telemetry_from_payload)

    def write_layer_summaries(
        self,
        run_id: str,
        summaries: Sequence[LayerSummaryRecord],
    ) -> tuple[LayerSummaryRecord, ...]:
        if not summaries:
            return ()
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/layer-summaries",
            payload={"summaries": self._record_payloads(summaries)},
        )
        return record_list_from_payload(response, layer_summary_from_payload)

    def read_layer_summaries(
        self,
        run_id: str,
        *,
        module_name: str | None = None,
        module_type: str | None = None,
        summary_type: str | None = None,
        hook_direction: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        limit: int | None = None,
    ) -> tuple[LayerSummaryRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/layer-summaries",
            query=_query(
                module_name=module_name,
                module_type=module_type,
                summary_type=summary_type,
                hook_direction=hook_direction,
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, layer_summary_from_payload)

    def write_parameter_summaries(
        self,
        run_id: str,
        summaries: Sequence[ParameterSummaryRecord],
    ) -> tuple[ParameterSummaryRecord, ...]:
        if not summaries:
            return ()
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/parameter-summaries",
            payload={"summaries": self._record_payloads(summaries)},
        )
        return record_list_from_payload(response, parameter_summary_from_payload)

    def read_parameter_summaries(
        self,
        run_id: str,
        *,
        module_name: str | None = None,
        module_type: str | None = None,
        parameter_name: str | None = None,
        parameter_key: str | None = None,
        parameter_role: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        latest_only: bool | None = None,
        limit: int | None = None,
    ) -> tuple[ParameterSummaryRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/parameter-summaries",
            query=_query(
                module_name=module_name,
                module_type=module_type,
                parameter_name=parameter_name,
                parameter_key=parameter_key,
                parameter_role=parameter_role,
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                latest_only=latest_only,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, parameter_summary_from_payload)

    def write_parameter_distributions(
        self,
        run_id: str,
        distributions: Sequence[ParameterDistributionRecord],
    ) -> tuple[ParameterDistributionRecord, ...]:
        if not distributions:
            return ()
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/parameter-distributions",
            payload={"distributions": self._record_payloads(distributions)},
        )
        return record_list_from_payload(response, parameter_distribution_from_payload)

    def read_parameter_distributions(
        self,
        run_id: str,
        *,
        module_name: str | None = None,
        module_type: str | None = None,
        parameter_name: str | None = None,
        parameter_key: str | None = None,
        parameter_role: str | None = None,
        distribution_type: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        latest_only: bool | None = None,
        limit: int | None = None,
    ) -> tuple[ParameterDistributionRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/parameter-distributions",
            query=_query(
                module_name=module_name,
                module_type=module_type,
                parameter_name=parameter_name,
                parameter_key=parameter_key,
                parameter_role=parameter_role,
                distribution_type=distribution_type,
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                latest_only=latest_only,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, parameter_distribution_from_payload)

    def write_batch_diagnostics(
        self,
        run_id: str,
        diagnostics: Sequence[BatchDiagnosticRecord],
    ) -> tuple[BatchDiagnosticRecord, ...]:
        if not diagnostics:
            return ()
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/batch-diagnostics",
            payload={"diagnostics": self._record_payloads(diagnostics)},
        )
        return record_list_from_payload(response, batch_diagnostic_from_payload)

    def read_batch_diagnostics(
        self,
        run_id: str,
        *,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        limit: int | None = None,
    ) -> tuple[BatchDiagnosticRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/batch-diagnostics",
            query=_query(
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, batch_diagnostic_from_payload)

    def persist_artifact(
        self,
        run_id: str,
        *,
        artifact_type: str,
        namespace: str,
        artifact_name: str,
        artifact_key: str,
        payload: Any,
        payload_format: str,
        metadata: dict[str, Any] | None,
        step: int | None,
        epoch: int | None,
        timestamp: str,
    ):
        request_payload: dict[str, Any] = {
            "artifact_type": artifact_type,
            "artifact_name": artifact_name,
            "artifact_key": artifact_key,
            "payload_format": payload_format,
            "timestamp": timestamp,
        }
        if namespace:
            request_payload["namespace"] = namespace
        if metadata is not None:
            request_payload["metadata"] = to_api_payload(metadata)
        if step is not None:
            request_payload["step"] = step
        if epoch is not None:
            request_payload["epoch"] = epoch
        if payload_format == "bytes":
            request_payload["payload_base64"] = base64.b64encode(bytes(payload)).decode("utf-8")
        else:
            request_payload["payload"] = to_api_payload(payload)

        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/artifacts/payloads",
            payload=request_payload,
        )
        return artifact_from_payload(response)

    def read_artifacts(
        self,
        run_id: str,
        *,
        artifact_id: str | None = None,
        artifact_type: str | None = None,
        artifact_types: Sequence[str] | None = None,
        namespace: str | None = None,
        artifact_name: str | None = None,
        artifact_key: str | None = None,
        payload_format: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        epoch: int | None = None,
        epoch_min: int | None = None,
        epoch_max: int | None = None,
        limit: int | None = None,
    ):
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/artifacts",
            query=_query(
                artifact_id=artifact_id,
                artifact_type=artifact_type,
                artifact_types=artifact_types,
                namespace=namespace,
                artifact_name=artifact_name,
                artifact_key=artifact_key,
                payload_format=payload_format,
                step=step,
                step_min=step_min,
                step_max=step_max,
                epoch=epoch,
                epoch_min=epoch_min,
                epoch_max=epoch_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, artifact_from_payload)

    def load_artifact_payload(self, run_id: str, artifact_id: str) -> Any:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/artifacts/{_quote(artifact_id)}/payload",
        )
        return artifact_payload_from_payload(response)

    def write_analysis_outputs(
        self,
        run_id: str,
        *,
        alerts: Sequence[AlertRecord],
        health_scores: Sequence[HealthScoreRecord],
        replace_existing: bool = True,
    ) -> tuple[tuple[AlertRecord, ...], tuple[HealthScoreRecord, ...]]:
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/analysis",
            payload={
                "alerts": self._record_payloads(alerts),
                "health_scores": self._record_payloads(health_scores),
                "replace_existing": bool(replace_existing),
            },
        )
        return analysis_outputs_from_payload(response)

    def read_alerts(
        self,
        run_id: str,
        *,
        rule_id: str | None = None,
        category: str | None = None,
        severity: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        limit: int | None = None,
    ) -> tuple[AlertRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/alerts",
            query=_query(
                rule_id=rule_id,
                category=category,
                severity=severity,
                step=step,
                step_min=step_min,
                step_max=step_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, alert_from_payload)

    def read_health_scores(
        self,
        run_id: str,
        *,
        score_key: str | None = None,
        category: str | None = None,
        limit: int | None = None,
    ) -> tuple[HealthScoreRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/health-scores",
            query=_query(
                score_key=score_key,
                category=category,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, health_score_from_payload)

    def write_phases(
        self,
        run_id: str,
        phases: Sequence[PhaseRecord],
        *,
        replace_existing: bool = True,
    ) -> tuple[PhaseRecord, ...]:
        response = self._request(
            "POST",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/phases",
            payload={
                "phases": self._record_payloads(phases),
                "replace_existing": bool(replace_existing),
            },
        )
        return record_list_from_payload(response, phase_from_payload)

    def read_phases(
        self,
        run_id: str,
        *,
        phase_name: str | None = None,
        step: int | None = None,
        step_min: int | None = None,
        step_max: int | None = None,
        limit: int | None = None,
    ) -> tuple[PhaseRecord, ...]:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/phases",
            query=_query(
                phase_name=phase_name,
                step=step,
                step_min=step_min,
                step_max=step_max,
                limit=limit,
            ),
        )
        return record_list_from_payload(response, phase_from_payload)

    def _allocate_sequence(self, run_id: str, kind: str) -> int:
        cache_key = (run_id, kind)
        next_sequence = self._allocated_sequence_by_key.get(cache_key)
        if next_sequence is None:
            # Seed a local counter from the current service floor so buffered
            # writers do not need an extra round-trip for every record.
            next_sequence = self._read_sequence(run_id, kind)
        self._allocated_sequence_by_key[cache_key] = next_sequence + 1
        return next_sequence

    def _observe_sequences(self, run_id: str, kind: str, records: Sequence[Any]) -> None:
        if not records:
            return
        max_sequence_id = max(int(record.sequence_id) for record in records)
        self._allocated_sequence_by_key[(run_id, kind)] = max_sequence_id + 1

    def _sync_sequence_cache(self, run_id: str, kind: str) -> int:
        next_sequence = self._read_sequence(run_id, kind)
        self._allocated_sequence_by_key[(run_id, kind)] = next_sequence
        return next_sequence

    @staticmethod
    def _record_payloads(records: Sequence[Any]) -> list[dict[str, Any]]:
        payloads: list[dict[str, Any]] = []
        for record in records:
            validate_record(record)
            payload = to_api_payload(record)
            if not isinstance(payload, dict):
                raise TypeError("Validated record payloads must serialize to dictionaries.")
            payloads.append(payload)
        return payloads

    def _read_sequence(self, run_id: str, kind: str) -> int:
        response = self._request(
            "GET",
            f"{_API_PREFIX}/runs/{_quote(run_id)}/sequences/{_quote(kind)}",
        )
        return sequence_from_payload(response)

    def _request(
        self,
        method: str,
        path: str,
        *,
        payload: Any | None = None,
        query: dict[str, Any] | None = None,
        require_auth: bool = True,
    ) -> Any:
        headers = {
            "Accept": "application/json",
            "X-Quark-Storage-Dir": self.storage_dir,
        }
        if require_auth:
            self._apply_auth(headers)
        body = None
        if payload is not None:
            headers["Content-Type"] = "application/json"
            body = json.dumps(payload).encode("utf-8")

        url = self._build_url(path, query=query)
        request = Request(url, data=body, headers=headers, method=method.upper())
        try:
            with urlopen(request, timeout=self.timeout_seconds) as response:
                raw_payload = response.read()
        except HTTPError as exc:
            self._raise_http_error(exc)
        except URLError as exc:
            raise SessionStoreError(
                f"Could not reach the Quark persistence service at '{self.base_url}'.",
            ) from exc

        if not raw_payload:
            return None
        try:
            return json.loads(raw_payload.decode("utf-8"))
        except json.JSONDecodeError as exc:
            raise SessionStoreError("Persistence service returned a non-JSON response.") from exc

    def _build_url(self, path: str, *, query: dict[str, Any] | None) -> str:
        base = self.base_url.rstrip("/")
        url = f"{base}{path}"
        if not query:
            return url
        return f"{url}?{urlencode(query, doseq=True)}"

    def _apply_auth(self, headers: dict[str, str]) -> None:
        if not self.user_key:
            raise SessionStoreError(
                "Protected persistence requests require a user key. "
                "Set QUARK_USER_KEY.",
            )
        if self.auth_mode == "bearer":
            headers["Authorization"] = f"Bearer {self.user_key}"
        else:
            headers["X-Quark-API-Key"] = self.user_key

    def _raise_http_error(self, exc: HTTPError) -> None:
        payload: Any = None
        error_code: str | None = None
        error_type: str | None = None
        message = exc.reason or f"HTTP {exc.code}"
        try:
            raw_payload = exc.read()
            if raw_payload:
                payload = json.loads(raw_payload.decode("utf-8"))
        except Exception:
            payload = None

        if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
            error_payload = payload["error"]
            error_code = _optional_string(error_payload.get("code"))
            error_type = _optional_string(error_payload.get("type"))
            message = _optional_string(error_payload.get("message")) or message

        error_class: type[SessionStoreError] = SessionStoreError
        if error_code in {"run_not_found", "not_found"} or exc.code == 404:
            error_class = RunNotFoundError
        elif error_code == "invalid_run_state":
            error_class = InvalidRunStateError

        raise error_class(
            message,
            status_code=exc.code,
            error_code=error_code,
            error_type=error_type,
        ) from exc


def _normalize_base_url(base_url: str) -> str:
    candidate = base_url.strip().rstrip("/")
    if candidate.endswith(_API_PREFIX):
        candidate = candidate[: -len(_API_PREFIX)]
    if not candidate:
        raise ValueError("base_url must not be empty.")
    return candidate


def _resolve_user_key(user_key: str | None) -> str | None:
    if user_key is not None:
        candidate = user_key.strip()
        return candidate or None
    env_value = os.getenv(_USER_KEY_ENV_VAR)
    if env_value and env_value.strip():
        return env_value.strip()
    return None


def _resolve_auth_mode(auth_mode: str | None) -> str:
    if auth_mode is None:
        return "header"
    candidate = auth_mode.strip().lower()
    if candidate not in _SUPPORTED_AUTH_MODES:
        raise ValueError(
            f"Unsupported auth_mode '{auth_mode}'. Expected one of {sorted(_SUPPORTED_AUTH_MODES)}."
        )
    return candidate


def _resolve_timeout_seconds(timeout_seconds: float | None) -> float:
    if timeout_seconds is not None:
        if timeout_seconds <= 0:
            raise ValueError("timeout_seconds must be greater than zero.")
        return float(timeout_seconds)
    env_value = os.getenv("QUARK_PERSISTENCE_TIMEOUT_SECONDS")
    if env_value:
        try:
            resolved = float(env_value)
        except ValueError as exc:
            raise ValueError("QUARK_PERSISTENCE_TIMEOUT_SECONDS must be numeric.") from exc
        if resolved <= 0:
            raise ValueError("QUARK_PERSISTENCE_TIMEOUT_SECONDS must be greater than zero.")
        return resolved
    return _DEFAULT_TIMEOUT_SECONDS


def _query(**values: Any) -> dict[str, Any]:
    payload: dict[str, Any] = {}
    for key, value in values.items():
        if value is None:
            continue
        if isinstance(value, (tuple, list)):
            if not value:
                continue
            payload[key] = list(value)
            continue
        if isinstance(value, bool):
            payload[key] = "true" if value else "false"
            continue
        payload[key] = value
    return payload


def _quote(value: str) -> str:
    return quote(value, safe="")


def _optional_string(value: Any) -> str | None:
    if value is None:
        return None
    if not isinstance(value, str):
        return str(value)
    candidate = value.strip()
    return candidate or None
