"""
Synchronous HTTP client for the lf_orchestration service.

All methods are blocking and intended to be called from a QThread worker,
never from the Qt UI thread.
"""
from __future__ import annotations

from src.clients import extract_error_text


class OrchestratorError(Exception):
    def __init__(self, message: str, status_code: int | None = None) -> None:
        super().__init__(message)
        self.status_code = status_code


class OrchestratorClient:
    """Thin wrapper around the orchestrator REST API (port 1717 by default)."""

    def __init__(self, base_url: str = "http://localhost:1717") -> None:
        self.base_url = base_url.rstrip("/")

    # -- internals ---------------------------------------------------------

    def _get(self, path: str) -> dict:
        import httpx
        try:
            r = httpx.get(
                f"{self.base_url}{path}",
                timeout=httpx.Timeout(connect=3.0, read=120.0, write=10.0, pool=5.0),
            )
        except httpx.ConnectError as exc:
            raise OrchestratorError(f"Cannot connect to orchestrator at {self.base_url}") from exc
        except httpx.TimeoutException as exc:
            raise OrchestratorError("Orchestrator request timed out") from exc
        if not r.is_success:
            raise OrchestratorError(extract_error_text(r), status_code=r.status_code)
        return r.json()

    def _post(self, path: str, json: dict | None = None) -> dict:
        import httpx
        try:
            r = httpx.post(
                f"{self.base_url}{path}",
                json=json,
                timeout=httpx.Timeout(connect=3.0, read=120.0, write=10.0, pool=5.0),
            )
        except httpx.ConnectError as exc:
            raise OrchestratorError(f"Cannot connect to orchestrator at {self.base_url}") from exc
        except httpx.TimeoutException as exc:
            raise OrchestratorError("Orchestrator request timed out") from exc
        if not r.is_success:
            raise OrchestratorError(extract_error_text(r), status_code=r.status_code)
        return r.json()

    # -- public API ---------------------------------------------------------

    def healthcheck(self) -> bool:
        """Return True if the orchestrator responds on /health."""
        try:
            self._get("/health")
            return True
        except OrchestratorError:
            return False

    def spectrometer_healthcheck(self) -> bool:
        """Return True if the spectrometer service is reachable (via orchestrator proxy)."""
        try:
            self._get("/spectrometer/health")
            return True
        except OrchestratorError:
            return False

    def list_scenarios(self) -> list[str]:
        """Return list of available scenario IDs."""
        data = self._get("/scenario/list")
        return data.get("scenarios", [])

    def load_scenario(
        self,
        scenario_id: str,
        param_overrides: dict | None = None,
    ) -> str:
        """
        Load a scenario template and return the job_id.

        param_overrides: optional dict {step_name: {param_key: value}}
        e.g. {"start_measurement": {"info": {...}}}
        """
        body = {"param_overrides": param_overrides} if param_overrides else None
        data = self._post(f"/scenario/load/{scenario_id}", json=body)
        return data["job_id"]

    def run_all(self, job_id: str) -> dict:
        """Execute all remaining steps and return the final step list."""
        return self._post(f"/scenario/{job_id}/run_all")

    def next_step(self, job_id: str) -> dict:
        """Execute the next single step and return its result."""
        return self._post(f"/scenario/{job_id}/next")

    def get_status(self, job_id: str) -> dict:
        """Return the current scenario state (steps + their statuses)."""
        return self._get(f"/scenario/{job_id}")

    def scan(
        self,
        seq_file_path: str | None = None,
        seq_info: dict | None = None,
        scenario_id: str = "full_pipeline",
        protocol: str = "",
    ) -> str:
        """
        Submit a scan job to the orchestrator and return the job_id immediately.

        Provide either:
          - seq_file_path: path to a .seq file → orchestrator interprets it via seq-interp
          - seq_info: already-interpreted parameters dict

        Use poll_scan(job_id) to wait for completion.
        """
        import json as _json
        import httpx

        data: dict[str, str] = {
            "scenario_id": scenario_id,
            "protocol": protocol,
        }
        files = None

        if seq_file_path:
            import os as _os
            with open(seq_file_path, "rb") as fh:
                file_bytes = fh.read()
            files = {
                "file": (_os.path.basename(seq_file_path), file_bytes, "application/octet-stream")
            }
        elif seq_info:
            data["seq_info_json"] = _json.dumps(seq_info)

        try:
            r = httpx.post(
                f"{self.base_url}/scan/",
                data=data,
                files=files,
                timeout=httpx.Timeout(connect=5.0, read=60.0, write=60.0, pool=5.0),
            )
        except httpx.ConnectError as exc:
            raise OrchestratorError(f"Cannot connect to orchestrator at {self.base_url}") from exc
        except httpx.TimeoutException as exc:
            raise OrchestratorError("Orchestrator request timed out") from exc

        if not r.is_success:
            raise OrchestratorError(extract_error_text(r), status_code=r.status_code)
        return r.json()["job_id"]

    def get_task_registry(self) -> list[str]:
        """Return names of all registered task functions."""
        data = self._get("/tasks")
        return data.get("tasks", [])

    def create_scenario(self, steps: list[dict]) -> str:
        """
        Create a job from an inline step list (no template required).

        steps: [{"name": "start_measurement", "params": {...}}, ...]
        Returns job_id.
        """
        data = self._post("/scenario/create", json={"steps": steps})
        return data["job_id"]

    def get_mode(self) -> str:
        """Return the current orchestrator mode ('real' or 'plug')."""
        data = self._get("/mode")
        return data.get("mode", "plug")

    def set_mode(self, mode: str) -> str:
        """
        Switch orchestrator mode to 'real' or 'plug'.
        Returns the mode that is now active.
        """
        data = self._post("/mode", json={"mode": mode})
        return data.get("mode", mode)

    def poll_scan(
        self,
        job_id: str,
        timeout: float = 300.0,
        poll_interval: float = 2.0,
        progress_cb=None,
        interrupted_fn=None,
    ) -> dict:
        """
        Block until the scan job finishes (status == 'done' or starts with 'failed').
        Returns the final scenario dict from GET /scenario/{job_id}.
        Raises OrchestratorError on timeout or failure.

        interrupted_fn: optional callable() -> bool; if it returns True the loop
        exits immediately with OrchestratorError("cancelled").
        """
        import time
        deadline = time.monotonic() + timeout
        last_status = ""
        step = 0.25  # sleep granularity for responsive cancellation

        while time.monotonic() < deadline:
            # Sleep in small steps so a cancellation request is noticed quickly
            elapsed = 0.0
            while elapsed < poll_interval:
                if interrupted_fn and interrupted_fn():
                    raise OrchestratorError("cancelled")
                time.sleep(step)
                elapsed += step
            doc = self.get_status(job_id)
            status = doc.get("status", "")

            if status != last_status:
                last_status = status
                if progress_cb:
                    progress_cb(status)

            if status == "done":
                return doc
            if status.startswith("failed"):
                raise OrchestratorError(f"Scan job failed: {status}")

        raise OrchestratorError(f"Scan job timed out after {timeout}s (job_id={job_id})")
