//! `HttpService` — the v1.1.4 outbound-HTTP contract.
//!
//! Lives in `picloud-shared` (not `executor-core` or `manager-core`)
//! so the Rhai bridge and the manager-core reqwest-backed impl can both
//! depend on the same trait without dragging `executor-core` into
//! `manager-core`'s dep graph — mirrors [`crate::kv`].
//!
//! Unlike KV/docs, `http::*` has no app-scoped data, so there is no
//! cross-app isolation boundary to enforce here. `cx.app_id` is still
//! forwarded for audit-log attribution and (future, v1.2) per-app rate
//! limits. The load-bearing security mechanism is the SSRF deny-list
//! applied to the *resolved IP* — that lives in the manager-core impl,
//! not in this contract.
//!
//! Body encoding + per-method dispatch happen in the Rhai bridge before
//! the request reaches this trait: the service receives an already-
//! encoded body plus a `content_type`, so the impl stays a thin
//! transport layer.

use std::collections::BTreeMap;

use async_trait::async_trait;
use thiserror::Error;

use crate::SdkCallCx;

/// A fully-resolved outbound request. The bridge builds this from the
/// script-facing `(url, body, opts)` arguments; the service backend
/// turns it into a real network call.
#[derive(Debug, Clone)]
pub struct HttpRequest {
    /// Uppercased HTTP method (`GET`, `POST`, …). The escape-hatch
    /// `http::request(method, …)` lets scripts pass arbitrary methods,
    /// so the impl validates this rather than the bridge.
    pub method: String,
    pub url: String,
    /// Caller-supplied headers, merged into the request. Header names
    /// are case-insensitive on the wire; stored verbatim here.
    pub headers: BTreeMap<String, String>,
    /// Already-encoded body. `None` means no body (GET/HEAD, or an
    /// explicit `()` body).
    pub body: Option<Vec<u8>>,
    /// Content-Type the bridge chose for `body` (e.g.
    /// `application/json`). Ignored when the caller set their own
    /// `Content-Type` header. `None` when there is no body.
    pub content_type: Option<String>,
    /// Total request budget in ms (already clamped to the 60s ceiling
    /// by the bridge).
    pub timeout_ms: u32,
    pub follow_redirects: bool,
    /// Max redirects to follow (already clamped to 10 by the bridge).
    pub max_redirects: u32,
    /// Script id for the default `User-Agent` and audit attribution.
    /// `None` when unavailable (the bridge always sets it from
    /// `cx`-adjacent context, but the field stays optional so the
    /// trait isn't coupled to how the id is sourced).
    pub script_id: Option<String>,
}

/// The response shape the bridge turns into a Rhai map. JSON parsing of
/// `body_raw` happens in the bridge (it needs the Rhai value types), so
/// the service returns only the raw string + lowercased headers.
#[derive(Debug, Clone)]
pub struct HttpResponse {
    pub status: u16,
    /// Header names lowercased (per the documented response shape).
    pub headers: BTreeMap<String, String>,
    pub body_raw: String,
}

/// Failure modes surfaced to the Rhai bridge. The bridge prefixes each
/// `Display` string with `"http: "`. **None of these may leak the
/// resolved IP** — the SSRF reason is a CIDR-category label only.
#[derive(Debug, Error)]
pub enum HttpError {
    /// Caller principal lacked `AppHttpRequest`. Only raised when
    /// `cx.principal.is_some()`; public-HTTP scripts skip the check.
    #[error("forbidden")]
    Forbidden,

    /// URL failed to parse, or carried no host.
    #[error("invalid url: {0}")]
    InvalidUrl(String),

    /// Scheme other than http/https (file, ftp, gopher, …).
    #[error("scheme not allowed: {0}")]
    BlockedScheme(String),

    /// Destination port is on the explicit block list (22, 25, 465, 587).
    #[error("port not allowed: {0}")]
    BlockedPort(u16),

    /// Resolved IP hit the SSRF deny-list. `reason` is a CIDR-category
    /// label (e.g. "loopback", "private", "link-local") — never the IP.
    #[error("blocked by SSRF policy: {0}")]
    Ssrf(String),

    /// The request exceeded the wall-clock budget.
    #[error("request timed out")]
    Timeout,

    /// Request or response body exceeded the configured size cap.
    /// `which` is `"request"` or `"response"`.
    #[error("{0} body exceeds size limit")]
    BodyTooLarge(&'static str),

    /// DNS / connect / TLS failure. The message is generic and MUST NOT
    /// contain the resolved IP.
    #[error("{0}")]
    Network(String),

    /// F-Q-008: user-supplied input failed validation (bad HTTP method
    /// name, malformed header name/value). Distinct from `Backend`
    /// because operators may safely retry a `Backend` error but should
    /// never retry an `InvalidArgs` failure — the input itself is wrong.
    #[error("{0}")]
    InvalidArgs(String),

    /// Anything else the impl wants to surface (still safe to show a
    /// script).
    #[error("{0}")]
    Backend(String),
}

/// Stub used by the executor-core test harness so engine integration
/// tests (which don't make real network calls) can construct a
/// `Services` bundle. Every call errors so accidental use surfaces.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopHttpService;

#[async_trait]
impl HttpService for NoopHttpService {
    async fn request(&self, _cx: &SdkCallCx, _req: HttpRequest) -> Result<HttpResponse, HttpError> {
        Err(HttpError::Network("http is not wired in".into()))
    }
}

/// Outbound-HTTP contract. A single generic `request` method funnels
/// every verb (`get`/`post`/…/`request`); the bridge maps the
/// script-facing surface onto it.
#[async_trait]
pub trait HttpService: Send + Sync {
    async fn request(&self, cx: &SdkCallCx, req: HttpRequest) -> Result<HttpResponse, HttpError>;
}
