#pragma once
// agentty::http — HTTP/2 client over OpenSSL, tuned for long-lived SSE streams.
//
// Mirrors Zed's layering (reqwest_client → hyper → h2 → rustls) but in C++:
// tls_context (OpenSSL SSL_CTX with platform-root verification) is opened once,
// and a process-wide Client keeps a pool of HTTP/2 connections keyed by
// (host, port). Each request grabs an *idle* connection from that pool, runs
// a single stream over it end-to-end, then returns the connection. Despite
// the h2 wire format, we don't multiplex — at most one stream ID is open on
// any given connection at any given time. Concurrency lives in the pool
// (parallel requests dial / pick distinct connections), not in the h2
// session. See the StreamCtx comment in http.cpp for the trade-off: agentty's
// request shape is sequential by design, so we run h2 essentially as
// h1.1 + keepalive + HPACK + PING.
//
// Cancellation is cooperative: every request accepts a CancelToken that the
// I/O loop polls between nghttp2 frame boundaries. Tripping the token closes
// the stream with RST_STREAM and the blocking send()/stream() call returns
// promptly, so a Ctrl-C from the UI terminates the turn cleanly.

#include <atomic>
#include <chrono>
#include <cstdint>
#include <expected>
#include <functional>
#include <memory>
#include <string>
#include <string_view>
#include <vector>

namespace agentty::http {

// ---------------------------------------------------------------------------
// CancelToken — cooperative abort signal shared between caller and I/O loop.
// ---------------------------------------------------------------------------
// Safe to flip from any thread. The I/O loop polls it at every nghttp2 frame
// boundary and between poll() waits, so a trip lands within a few ms on a
// live connection and immediately on an idle one (the socket is shut down,
// which wakes poll()).
class CancelToken {
public:
    void cancel() noexcept { flag_.store(true, std::memory_order_release); }
    [[nodiscard]] bool is_cancelled() const noexcept {
        return flag_.load(std::memory_order_acquire);
    }
private:
    std::atomic<bool> flag_{false};
};
using CancelTokenPtr = std::shared_ptr<CancelToken>;

// ---------------------------------------------------------------------------
// Requests / responses.
// ---------------------------------------------------------------------------
struct Header {
    std::string name;   // lowercase by convention; nghttp2 enforces for HTTP/2.
    std::string value;
};
using Headers = std::vector<Header>;

// Strongly-typed HTTP method. The wire spelling lives in one place
// (`wire_name`) and the runtime never sees a free-form string at this
// seam — "GET" vs "Get" vs "get" can't diverge between call sites.
enum class HttpMethod : std::uint8_t { Get, Post, Head };

[[nodiscard]] constexpr std::string_view wire_name(HttpMethod m) noexcept {
    switch (m) {
        case HttpMethod::Get:  return "GET";
        case HttpMethod::Post: return "POST";
        case HttpMethod::Head: return "HEAD";
    }
    return "GET";
}

struct Request {
    HttpMethod  method = HttpMethod::Get;
    std::string host;     // SNI + cert verification + Host header (e.g. "api.anthropic.com")
    uint16_t    port = 443;
    // TCP-target override.  Empty / 0 → use `host` / `port`.  When set, the
    // socket dials this address but TLS is still pinned on `host`, so a
    // tampering tunnel endpoint can't MITM you.  Use case: SSH reverse
    // tunnel from an air-gapped host through a laptop with internet.
    std::string dial_host;
    uint16_t    dial_port = 0;
    std::string path;     // "/v1/messages"
    Headers     headers;
    std::string body;     // empty for GET; utf-8 bytes for POST.

    // Hard cap on the buffered (unary, non-streaming) response body. The
    // call returns a typed `body_too_large` HttpError once exceeded and
    // RST_STREAMs the stream so we don't keep reading. Default 16 MiB is
    // generous for an HTTP API call but bounded enough that a runaway
    // upstream (test harness, misconfigured proxy, body-replay loop)
    // can't OOM the process before anyone notices.
    //
    // Callers with known-tiny responses should tighten this:
    //   • auth (OAuth token exchange / refresh): ~1 KB JSON  → 1 MiB
    //   • Anthropic /v1/models: ~30 KB JSON                  → 1 MiB
    //   • web_fetch tool: arbitrary user-pasted URL          → leave default
    //
    // Streaming responses (Client::stream) bypass this entirely — each
    // SSE chunk flows through the on_chunk callback without buffering.
    std::size_t max_body_bytes = 16ull * 1024 * 1024;
};

// Parsed env-var-driven dial override.  Format: "host" or "host:port" —
// port defaults to 443.  Empty when the env var is unset or ill-formed.
// Cached at first call (process lifetime); env vars never change in
// practice and re-reading getenv per request is wasteful.  Apply by
// copying `host` / `port` into `Request::dial_host` / `Request::dial_port`
// (and the same pair on `Client::prewarm`) — TLS / cert / Host-header all
// stay pinned on the real upstream so a tampering tunnel endpoint can't
// MITM you.
struct DialOverride {
    std::string host;       // empty → no override
    uint16_t    port = 0;
    [[nodiscard]] bool active() const noexcept { return !host.empty(); }
};

// Override for requests to `api.anthropic.com` (chat completions, model
// listing).  Driven by `AGENTTY_API_HOST`.
[[nodiscard]] const DialOverride& agentty_api_host_override();

// Override for requests to the OAuth host (`platform.claude.com`) — token
// exchange and refresh.  Driven by `AGENTTY_OAUTH_HOST`.  An air-gapped host
// needs both this and `agentty_api_host_override` to keep working past a
// token expiry: refresh is a separate upstream and would otherwise fail
// on getaddrinfo.
[[nodiscard]] const DialOverride& agentty_oauth_host_override();

// SOCKS5 proxy.  Driven by `AGENTTY_SOCKS_PROXY`; same `host[:port]` syntax
// as the named overrides (port defaults to 443 — but most SSH-side SOCKS
// setups want an explicit port like `localhost:1080`).
//
// When set, every outbound TCP dial is routed through this SOCKS5 proxy
// instead of `getaddrinfo`-ing the destination directly.  The proxy
// receives the destination as a domain name (ATYP=DOMAIN), so DNS happens
// on the proxy side — which is exactly right for an air-gapped host
// whose own DNS can't see the public internet.  TLS still negotiates
// end-to-end with the *real* upstream over the SOCKS-tunnelled socket;
// the proxy can't MITM you.
//
// Wins over AGENTTY_API_HOST / AGENTTY_OAUTH_HOST: a single tunnel forwards
// every destination — chat, OAuth refresh, web_fetch, web_search — so
// you don't have to enumerate hosts.  `ssh -R 1080` (port-only form, no
// host:port) makes OpenSSH itself the SOCKS server on the remote, so
// no extra daemon is needed.
[[nodiscard]] const DialOverride& agentty_socks_proxy();

struct Response {
    int     status = 0;
    Headers headers;
    std::string body;
};

// ---------------------------------------------------------------------------
// HttpError — the typed failure value returned by Client::send / stream.
// ---------------------------------------------------------------------------
// Replaces `std::expected<T, std::string>` so downstream callers (notably
// `provider::error_class::classify`) can dispatch on `kind` instead of
// substring-matching free-form messages. Adding a new failure mode is one
// new enum entry + one new switch arm in `to_string` / `is_transient` —
// the compiler tells you everywhere that needs to change.
//
// `http_status` is meaningful only when kind == Status; otherwise 0.
// `detail` is for human reading (logs, error banners) — never for
// programmatic dispatch.
enum class HttpErrorKind : std::uint8_t {
    Cancelled,    // CancelToken tripped — user-initiated (Esc) or watchdog
    Resolve,      // DNS / getaddrinfo failure
    Connect,      // TCP connect refused / network unreachable
    Tls,          // TLS handshake failure (cert, ALPN, etc.)
    Protocol,     // HTTP/2 protocol error (nghttp2 frame violation)
    SocketHangup, // POLLHUP / POLLERR / EPIPE mid-request
    Timeout,      // connect, total, or idle deadline expired
    PeerClosed,   // peer half-closed before completing the response
    Status,       // response received with status >= 400
    Body,         // body parse failure or size limit exceeded
    Unknown,      // unexpected — should never happen
};

struct HttpError {
    HttpErrorKind kind        = HttpErrorKind::Unknown;
    int           http_status = 0;       // valid iff kind == Status
    std::string   detail;                // human-readable

    // "[h2 timeout] no bytes for 45s" — the UI's default stringification
    // when it doesn't care to branch on kind. Layered errors call this
    // when wrapping into their own typed errors.
    [[nodiscard]] std::string render() const;

    // Factories for one-line return-site idioms.
    [[nodiscard]] static HttpError cancelled(std::string d = "cancelled")
        noexcept { return {HttpErrorKind::Cancelled, 0, std::move(d)}; }
    [[nodiscard]] static HttpError resolve(std::string d)
        noexcept { return {HttpErrorKind::Resolve, 0, std::move(d)}; }
    [[nodiscard]] static HttpError connect(std::string d)
        noexcept { return {HttpErrorKind::Connect, 0, std::move(d)}; }
    [[nodiscard]] static HttpError tls(std::string d)
        noexcept { return {HttpErrorKind::Tls, 0, std::move(d)}; }
    [[nodiscard]] static HttpError protocol(std::string d)
        noexcept { return {HttpErrorKind::Protocol, 0, std::move(d)}; }
    [[nodiscard]] static HttpError socket_hangup(std::string d)
        noexcept { return {HttpErrorKind::SocketHangup, 0, std::move(d)}; }
    [[nodiscard]] static HttpError timeout(std::string d)
        noexcept { return {HttpErrorKind::Timeout, 0, std::move(d)}; }
    [[nodiscard]] static HttpError peer_closed(std::string d)
        noexcept { return {HttpErrorKind::PeerClosed, 0, std::move(d)}; }
    [[nodiscard]] static HttpError status(int s, std::string d)
        noexcept { return {HttpErrorKind::Status, s, std::move(d)}; }
    [[nodiscard]] static HttpError body(std::string d)
        noexcept { return {HttpErrorKind::Body, 0, std::move(d)}; }
    [[nodiscard]] static HttpError unknown(std::string d)
        noexcept { return {HttpErrorKind::Unknown, 0, std::move(d)}; }

    // True if a retry might succeed (transient transport conditions). Used
    // by callers' retry policies. Cancellation, status 4xx (except 408,
    // 429), and Body errors are considered terminal.
    [[nodiscard]] bool is_transient() const noexcept;
};

[[nodiscard]] std::string_view to_string(HttpErrorKind k) noexcept;

// Public typed result aliases — these are what callers pattern-match on.
using HttpResult       = std::expected<Response, HttpError>;
using HttpStreamResult = std::expected<void,     HttpError>;

// Callbacks for a streaming request. on_headers fires once when the :status
// frame arrives; on_chunk fires for every DATA frame slab. Returning false
// from on_chunk aborts the stream (equivalent to cancelling the token).
struct StreamHandler {
    std::function<void(int status, const Headers&)>     on_headers;
    std::function<bool(std::string_view body_chunk)>    on_chunk;
};

// ---------------------------------------------------------------------------
// Timeouts.
// ---------------------------------------------------------------------------
// `connect` / `total` are absolute per-operation caps. `idle` and `ping` are
// liveness guardrails for long-lived streams, because a silent peer (half-
// dead TCP, proxy stall) produces no frames and no error — poll() just
// returns 0 forever. We need an inter-event idle clock + HTTP/2 PINGs to
// detect that case and fail fast so the caller can retry or surface an error
// rather than hang.
//
//   connect  strict — time to complete TCP + TLS + h2 preamble.
//   total    absolute cap for the whole request; 0 = unbounded (streams).
//   idle     error out if no bytes received for this long; 0 = disabled.
//   ping     send an HTTP/2 PING after this long without inbound bytes, to
//            probe the peer and coax a reply (PING ACK resets the idle
//            clock if the connection is still alive). 0 = disabled.
//
// For unary requests we leave idle/ping at 0 and rely on `total`. For
// streams we set both so a silent peer trips `idle` within a known bound.
struct Timeouts {
    std::chrono::milliseconds connect{10'000};
    std::chrono::milliseconds total  {0};
    std::chrono::milliseconds idle   {0};
    std::chrono::milliseconds ping   {0};
};

// ---------------------------------------------------------------------------
// Client. One instance = one process-wide HTTP/2 connection pool.
// ---------------------------------------------------------------------------

// Build-time version, baked from CMakeLists.txt's PROJECT_VERSION via
// -DAGENTTY_VERSION. The fallback exists only for hand-invoked compiler
// runs that bypass our CMake — keep the binary self-describing.
#ifndef AGENTTY_VERSION
#define AGENTTY_VERSION "0.0.0-dev"
#endif

class Client {
public:
    struct Config {
        // UA includes the build version; override for tests.
        std::string user_agent = "agentty/" AGENTTY_VERSION;
        // When set, skip TLS chain verification — wire matches `-k` in curl.
        // Honors AGENTTY_INSECURE=1 env automatically in the ctor path.
        bool insecure = false;
    };

    Client();
    explicit Client(Config cfg);
    ~Client();

    Client(const Client&)            = delete;
    Client& operator=(const Client&) = delete;

    // Blocking unary request. The response body is fully buffered.
    // `cancel` may be null; non-null tokens are polled between I/O waits and
    // while frames are in-flight. Returns a typed `HttpError` on failure —
    // callers dispatch on `kind` rather than substring-matching the detail.
    [[nodiscard]] HttpResult
    send(const Request& req,
         Timeouts timeouts = {},
         CancelTokenPtr cancel = {});

    // Blocking streaming request. Returns when the peer closes the stream,
    // cancel is tripped, or on_chunk returns false. Body chunks arrive on
    // the calling thread (no internal threads — this is meant to run inside
    // a worker that owns the turn lifetime).
    [[nodiscard]] HttpStreamResult
    stream(const Request& req,
           StreamHandler handler,
           Timeouts timeouts = {},
           CancelTokenPtr cancel = {});

    // Fire-and-forget: prewarm a TLS connection to (host,port) on a detached
    // thread so the first real request skips the handshake. Safe to call
    // multiple times; idempotent after first success.  `dial_host`/`dial_port`
    // override the TCP target while TLS stays pinned on `host` (mirrors the
    // Request fields — see the SSH-tunnel use case there).
    void prewarm(std::string host, uint16_t port = 443,
                 std::string dial_host = "", uint16_t dial_port = 0);

private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

// Process-wide default client — lazy, constructed on first access, shared
// across all call sites. Equivalent to Zed's `GlobalHttpClient`.
[[nodiscard]] Client& default_client();

} // namespace agentty::http
