# src/veritell_langchain/evaluator.py
from __future__ import annotations

import json
from typing import Any, Dict, Iterable, Optional

import requests

from .config import VeritellConfig, resolve_config
from .errors import (
    VeritellAPIError,
    VeritellAuthError,
    VeritellConnectionError,
    VeritellValidationError,
)
from .models import VeritellEvaluationResult, VeritellStreamEvent
from .schema import SCHEMA_VERSION as STABLE_SCHEMA_VERSION
from .schema import VeritellEvaluationResult as StableResult


def _join_url(base_url: str, path: str) -> str:
    return base_url.rstrip("/") + "/" + path.lstrip("/")


def _post_ndjson(
    *,
    url: str,
    payload: Dict[str, Any],
    headers: Dict[str, str],
    timeout: float,
    stream: bool,
):
    """Internal hook for posting to the NDJSON endpoint.

    Tests monkeypatch this symbol.
    """

    return requests.post(
        url,
        json=payload,
        headers=headers,
        timeout=timeout,
        stream=stream,
    )


class VeritellEvaluator:
    def __init__(
        self,
        *,
        config_path: Optional[str] = None,
        base_url: Optional[str] = None,
        api_key: Optional[str] = None,
        env: Optional[str] = None,
        timeout_seconds: Optional[float] = None,
        fail_open: Optional[bool] = None,
    ) -> None:
        self._config: VeritellConfig = resolve_config(
            config_path=config_path,
            base_url=base_url,
            api_key=api_key,
            env=env,
            timeout_seconds=timeout_seconds,
            fail_open=fail_open,
        )

        if not self._config.api_key:
            raise VeritellAuthError(message="API key not found. Set VERITELL_API_KEY or pass api_key=...", status=401)

        self._session = requests.Session()
        self._session.headers.update(
            {
                "Authorization": f"Bearer {self._config.api_key}",
                "Content-Type": "application/json",
                "Accept": "application/json",
            }
        )

    # --- convenience properties (tests rely on these) ---
    @property
    def api_key(self) -> str:
        return str(self._config.api_key or "")

    @property
    def api_base_url(self) -> str:
        return self._config.base_url.rstrip("/")

    @property
    def timeout(self) -> float:
        return float(self._config.timeout_seconds or 30.0)

    # --- lifecycle ---
    def close(self) -> None:
        self._session.close()

    def __enter__(self) -> "VeritellEvaluator":
        return self

    def __exit__(self, exc_type, exc, tb) -> None:
        self.close()

    # --- input validation helpers ---
    @staticmethod
    def _require_nonempty_str(value: Any, *, field: str) -> str:
        if not isinstance(value, str):
            raise VeritellValidationError(message=f"{field} must be a non-empty string", status=400)
        if not value.strip():
            raise VeritellValidationError(message=f"{field} must be a non-empty string", status=400)
        return value

    # --- non-streaming evaluate ---
    def evaluate(
        self,
        *,
        input_text: Any,
        output_text: Any,
        expected_output: Optional[str] = None,
        criteria: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> VeritellEvaluationResult:
        self._require_nonempty_str(input_text, field="input_text")
        self._require_nonempty_str(output_text, field="output_text")

        payload: Dict[str, Any] = {
            "input": input_text,
            "output": output_text,
        }
        if expected_output is not None:
            payload["expected_output"] = expected_output
        if criteria is not None:
            payload["criteria"] = criteria
        if metadata is not None:
            payload["metadata"] = metadata

        url = _join_url(self.api_base_url, "/evaluate")

        try:
            resp = self._session.post(url, json=payload, timeout=self.timeout)
        except requests.exceptions.Timeout as e:
            raise VeritellConnectionError(message="Request timed out", status=504) from e
        except requests.exceptions.ConnectionError as e:
            raise VeritellConnectionError(message="Connection error", status=502) from e

        if resp.status_code in (401, 403):
            raise VeritellAuthError(message="Authentication failed", status=resp.status_code)
        if resp.status_code < 200 or resp.status_code >= 300:
            msg = None
            try:
                j = resp.json()
                if isinstance(j, dict):
                    msg = j.get("message") or j.get("error")
            except Exception:
                msg = None
            raise VeritellAPIError(status_code=resp.status_code, message=str(msg or getattr(resp, "text", "")))

        data: Dict[str, Any] = {}
        try:
            data = resp.json() if resp.content else {}
        except Exception:
            data = {}

        return VeritellEvaluationResult(
            score=data.get("score"),
            passed=data.get("passed"),
            feedback=str(data.get("feedback") or ""),
            metadata=dict(data.get("metadata") or {}),
            raw_response=dict(data or {}),
        )

    def evaluate_strings(
        self,
        *,
        prediction: Optional[str] = None,
        input: Optional[str] = None,
        primary_model: Optional[str] = None,
        judges: Optional[list[str]] = None,
        output: Optional[str] = None,
        expected: Optional[str] = None,
        criteria: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
        **_ignored: Any,
    ):
        """Evaluate strings.

        Supports two calling conventions:
        1) Legacy: evaluate_strings(prediction=..., input=..., primary_model=..., judges=...)
           -> uses /evaluate/stream and returns the stable schema result object.
        2) Convenience: evaluate_strings(output=..., expected=..., criteria=..., metadata=...)
           -> uses non-streaming /evaluate and returns VeritellEvaluationResult.
        """

        # --- legacy Streaming path (used by the C8 tests) ---
        if prediction is not None or input is not None:
            pred = self._require_nonempty_str(prediction, field="prediction")
            inp = self._require_nonempty_str(input, field="input")

            url = _join_url(self._config.base_url, "/evaluate/stream")

            payload: Dict[str, Any] = {
                "prompt": inp,
                "model_output": pred,
            }
            if primary_model is not None:
                payload["primary_model"] = primary_model
            if judges is not None:
                payload["judges"] = judges

            # Never allow secrets to leak in payload
            payload.pop("api_key", None)
            payload.pop("Authorization", None)

            resp = _post_ndjson(
                url=url,
                payload=payload,
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "Content-Type": "application/json",
                    "Accept": "application/x-ndjson",
                },
                timeout=self.timeout,
                stream=True,
            )

            for raw_line in resp.iter_lines():
                if not raw_line:
                    continue
                text = (
                    raw_line.decode("utf-8")
                    if isinstance(raw_line, (bytes, bytearray))
                    else str(raw_line)
                )
                evt = json.loads(text)

                if evt.get("type") == "summary":
                    summary = {k: v for k, v in evt.items() if k != "type"}

                    # Normalize to the stable schema
                    if summary.get("schema_version") != STABLE_SCHEMA_VERSION:
                        summary.setdefault("server_schema_version", summary.get("schema_version"))
                    summary["schema_version"] = STABLE_SCHEMA_VERSION

                    return StableResult.from_api(summary)

                if evt.get("type") == "error":
                    raise VeritellAPIError(
                        status_code=int(evt.get("status") or 500),
                        message=str(evt.get("message") or "Evaluation failed."),
                    )

            raise VeritellAPIError(status_code=500, message="No summary event received from /evaluate/stream")

        # --- convenience non-streaming path ---
        if output is None or expected is None:
            raise VeritellValidationError(message="output and expected are required", status=400)

        return self.evaluate(
            input_text=expected,
            output_text=output,
            expected_output=expected,
            criteria=criteria,
            metadata=metadata,
        )

    # --- streaming evaluate ---
    def evaluate_stream(
        self,
        *,
        prompt: Any,
        primary_model: Any,
        judges: Any,
        model_output: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Iterable[VeritellStreamEvent]:
        self._require_nonempty_str(prompt, field="prompt")
        self._require_nonempty_str(primary_model, field="primary_model")
        if not isinstance(judges, list) or len(judges) == 0:
            raise VeritellValidationError(message="judges must be a non-empty list", status=400)

        # Production public API base is https://veritell.ai/api and the route is /evaluate/stream.
        # (The /api-overview/* namespace is a UI page, not the API route.)
        primary_url = _join_url(self.api_base_url, "/evaluate/stream")
        fallback_url = _join_url(self.api_base_url, "/api-overview/evaluate/stream")

        payload: Dict[str, Any] = {
            "prompt": prompt,
            "primary_model": primary_model,
            "judges": judges,
        }
        if model_output is not None:
            payload["model_output"] = model_output
        if metadata is not None:
            payload["metadata"] = metadata

        headers = {
            # Backend expects X-Api-Key for API key auth.
            "X-Api-Key": str(self.api_key),
            # Keep Bearer as a compatibility header (harmless if ignored).
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "Accept": "application/x-ndjson",
        }

        def _post(url: str):
            return requests.post(
                url,
                json=payload,
                headers=headers,
                timeout=self.timeout,
                stream=True,
            )

        try:
            resp = _post(primary_url)

            # Back-compat fallback for older/staging deployments that might still route stream
            # under /api-overview/evaluate/stream.
            if resp.status_code == 404:
                resp = _post(fallback_url)

        except requests.exceptions.Timeout as e:
            raise VeritellConnectionError(message="Request timed out", status=504) from e
        except requests.exceptions.ConnectionError as e:
            raise VeritellConnectionError(message="Connection error", status=502) from e

        if resp.status_code in (401, 403):
            raise VeritellAuthError(message="Authentication failed", status=resp.status_code)
        if resp.status_code < 200 or resp.status_code >= 300:
            raise VeritellAPIError(status_code=resp.status_code, message=str(getattr(resp, "text", "")))

        for raw_line in resp.iter_lines():
            if not raw_line:
                continue
            text = (
                raw_line.decode("utf-8")
                if isinstance(raw_line, (bytes, bytearray))
                else str(raw_line)
            )
            try:
                obj = json.loads(text)
                if isinstance(obj, dict):
                    evt_type = str(obj.get("type") or "raw")
                    yield VeritellStreamEvent(event_type=evt_type, data=obj, raw=obj)
                else:
                    yield VeritellStreamEvent(event_type="raw", data={"text": text}, raw=None)
            except Exception:
                yield VeritellStreamEvent(event_type="raw", data={"text": text}, raw=None)
