//! export_schemas — Manage custom column schemas for CSV exports.
//!
//! A schema has an immutable identity (`id`, `name`, `stream_category`) and
//! a sequence of published versions plus an optional draft. Change content
//! atomically with [`ExportSchemasResource::update`], or iterate via the
//! draft workflow ([`SchemaHandle::create_draft`] →
//! [`SchemaHandle::update_draft`] → [`SchemaHandle::publish_draft`]).
//! Built-in schemas (`is_built_in = true`) cannot be modified or deleted.
//!
//! Exports snapshot the schema at creation time; publishing a new version
//! does not affect existing exports.
//!
//! # Examples
//!
//! ```no_run
//! # async fn example() -> ticksupply::Result<()> {
//! use ticksupply::resources::export_schemas::{
//!     DataType, ExchangeExtractor, MetaExtraction, MetaValue,
//!     SchemaColumn, SchemaContent, StreamCategory, TimestampFormat,
//! };
//! let client = ticksupply::Client::new()?;
//! let content = SchemaContent::builder()
//!     .column(SchemaColumn::meta(
//!         "ts",
//!         MetaExtraction::new(MetaValue::CollectionTimestampNs)
//!             .format(TimestampFormat::Ns),
//!     ))
//!     .column(
//!         SchemaColumn::data("price").exchange(
//!             "binance",
//!             ExchangeExtractor::json("data.p", DataType::Decimal(18)),
//!         ),
//!     )
//!     .build();
//!
//! let schema = client.export_schemas()
//!     .create("my_trades", StreamCategory::Trade, content)
//!     .send().await?;
//! # let _ = schema;
//! # Ok(()) }
//! ```

use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;

use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::client::Client;
use crate::error::Result;
use crate::http::{send, send_empty, RequestOpts};
use crate::timestamp::Timestamp;

// ============================================================================
// MODELS — STREAM CATEGORY / META / TIMESTAMP / DATA TYPE
// ============================================================================

/// Stream category a schema applies to.
///
/// Round-trips unknown wire strings through [`Self::Other`] so a server
/// adding a new category does not break older clients.
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::StreamCategory;
/// assert_eq!(StreamCategory::Trade.as_str(), "trade");
/// assert_eq!(StreamCategory::BookUpdate.as_str(), "book_update");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StreamCategory {
    /// Per-trade events.
    Trade,
    /// Order book snapshots.
    Orderbook,
    /// Incremental order book updates (bid/ask deltas).
    BookUpdate,
    /// Best bid/ask quotes.
    Quote,
    /// Kline / candlestick aggregates.
    Kline,
    /// Rolling 24h / mini-ticker.
    Ticker,
    /// Liquidation events.
    Liquidation,
    /// Forward-compat: any other category string from the server.
    Other(String),
}

impl StreamCategory {
    /// Returns the wire string for this category.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Trade => "trade",
            Self::Orderbook => "orderbook",
            Self::BookUpdate => "book_update",
            Self::Quote => "quote",
            Self::Kline => "kline",
            Self::Ticker => "ticker",
            Self::Liquidation => "liquidation",
            Self::Other(s) => s,
        }
    }
}

impl fmt::Display for StreamCategory {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl Serialize for StreamCategory {
    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
        s.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for StreamCategory {
    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        Ok(match s.as_str() {
            "trade" => Self::Trade,
            "orderbook" => Self::Orderbook,
            "book_update" => Self::BookUpdate,
            "quote" => Self::Quote,
            "kline" => Self::Kline,
            "ticker" => Self::Ticker,
            "liquidation" => Self::Liquidation,
            _ => Self::Other(s),
        })
    }
}

/// System metadata value extracted into a column.
///
/// Round-trips unknown wire strings through [`Self::Other`].
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::MetaValue;
/// assert_eq!(MetaValue::CollectionTimestampNs.as_str(), "collection_timestamp_ns");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum MetaValue {
    /// Collector ingestion timestamp, in nanoseconds since epoch.
    CollectionTimestampNs,
    /// Forward-compat: any other meta value name from the server.
    Other(String),
}

impl MetaValue {
    /// Returns the wire string for this meta value.
    pub fn as_str(&self) -> &str {
        match self {
            Self::CollectionTimestampNs => "collection_timestamp_ns",
            Self::Other(s) => s,
        }
    }
}

impl Serialize for MetaValue {
    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
        s.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for MetaValue {
    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        Ok(match s.as_str() {
            "collection_timestamp_ns" => Self::CollectionTimestampNs,
            _ => Self::Other(s),
        })
    }
}

/// Output format for timestamp meta values.
///
/// Round-trips unknown wire strings through [`Self::Other`].
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::TimestampFormat;
/// assert_eq!(TimestampFormat::Iso8601.as_str(), "iso8601");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TimestampFormat {
    /// Nanoseconds since Unix epoch.
    Ns,
    /// Microseconds since Unix epoch.
    Us,
    /// Milliseconds since Unix epoch.
    Ms,
    /// Seconds since Unix epoch.
    S,
    /// RFC 3339 / ISO 8601 string.
    Iso8601,
    /// Forward-compat: any other format string from the server.
    Other(String),
}

impl TimestampFormat {
    /// Returns the wire string for this format.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Ns => "ns",
            Self::Us => "us",
            Self::Ms => "ms",
            Self::S => "s",
            Self::Iso8601 => "iso8601",
            Self::Other(s) => s,
        }
    }
}

impl Serialize for TimestampFormat {
    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
        s.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for TimestampFormat {
    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        Ok(match s.as_str() {
            "ns" => Self::Ns,
            "us" => Self::Us,
            "ms" => Self::Ms,
            "s" => Self::S,
            "iso8601" => Self::Iso8601,
            _ => Self::Other(s),
        })
    }
}

/// Data type for a JSON-extracted column value.
///
/// Wire strings: `"decimal(N)"`, `"f64"`, `"i64"`, `"bool"`, `"string"`,
/// or any other server-defined token (preserved as [`Self::Other`]).
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::DataType;
/// assert_eq!(DataType::Decimal(18).as_str(), "decimal(18)");
/// assert_eq!(DataType::Str.as_str(), "string");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DataType {
    /// Fixed-point decimal with `N` digits of precision.
    Decimal(u8),
    /// 64-bit signed integer.
    I64,
    /// 64-bit floating point.
    F64,
    /// Boolean.
    Bool,
    /// UTF-8 string (wire form: `"string"`).
    Str,
    /// Forward-compat: any other type string from the server.
    Other(String),
}

impl DataType {
    /// Returns the wire string for this type.
    pub fn as_str(&self) -> Cow<'_, str> {
        match self {
            Self::Decimal(n) => Cow::Owned(format!("decimal({n})")),
            Self::I64 => Cow::Borrowed("i64"),
            Self::F64 => Cow::Borrowed("f64"),
            Self::Bool => Cow::Borrowed("bool"),
            Self::Str => Cow::Borrowed("string"),
            Self::Other(s) => Cow::Borrowed(s),
        }
    }
}

impl Serialize for DataType {
    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
        s.serialize_str(&self.as_str())
    }
}

impl<'de> Deserialize<'de> for DataType {
    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        if let Some(rest) = s.strip_prefix("decimal(").and_then(|r| r.strip_suffix(')')) {
            if let Ok(n) = rest.parse::<u8>() {
                return Ok(Self::Decimal(n));
            }
        }
        Ok(match s.as_str() {
            "i64" => Self::I64,
            "f64" => Self::F64,
            "bool" => Self::Bool,
            "string" => Self::Str,
            _ => Self::Other(s),
        })
    }
}

// ============================================================================
// MODELS — EXTRACTION
// ============================================================================

/// Extraction of a system metadata value (e.g. ingestion timestamp).
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{MetaExtraction, MetaValue, TimestampFormat};
/// let m = MetaExtraction::new(MetaValue::CollectionTimestampNs).format(TimestampFormat::Iso8601);
/// assert_eq!(m.value, MetaValue::CollectionTimestampNs);
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaExtraction {
    /// System metadata value to extract.
    pub value: MetaValue,
    /// Output format. Defaults server-side to `ns` when omitted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub format: Option<TimestampFormat>,
}

impl MetaExtraction {
    /// Constructs a meta extraction with no explicit format.
    pub fn new(value: MetaValue) -> Self {
        Self {
            value,
            format: None,
        }
    }

    /// Sets the output format.
    #[must_use]
    pub fn format(mut self, format: TimestampFormat) -> Self {
        self.format = Some(format);
        self
    }
}

/// Extraction of a value from the raw exchange JSON payload.
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{DataType, JsonExtraction};
/// let j = JsonExtraction::new("data.p", DataType::Decimal(18));
/// assert_eq!(j.path, "data.p");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonExtraction {
    /// Dot-notation path into the raw JSON message (e.g. `"data.p"`).
    pub path: String,
    /// Output data type for the extracted value.
    #[serde(rename = "type")]
    pub data_type: DataType,
}

impl JsonExtraction {
    /// Constructs a JSON extraction.
    pub fn new(path: impl Into<String>, data_type: DataType) -> Self {
        Self {
            path: path.into(),
            data_type,
        }
    }
}

/// Per-exchange extraction rule.
///
/// Currently only JSON extraction is supported; binary wire formats (SBE,
/// proto) may appear as additional optional fields in future server
/// versions.
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{DataType, ExchangeExtractor};
/// let e = ExchangeExtractor::json("data.p", DataType::Decimal(18))
///     .transform("toFloat64({v}) * 1000");
/// assert!(e.transform.is_some());
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExchangeExtractor {
    /// JSON extraction rule for this exchange.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub json: Option<JsonExtraction>,
    /// SQL expression with `{v}` placeholder applied after extraction.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub transform: Option<String>,
}

impl ExchangeExtractor {
    /// Constructs an extractor with a single JSON extraction.
    pub fn json(path: impl Into<String>, data_type: DataType) -> Self {
        Self {
            json: Some(JsonExtraction::new(path, data_type)),
            transform: None,
        }
    }

    /// Sets the post-extraction SQL transform.
    #[must_use]
    pub fn transform(mut self, expr: impl Into<String>) -> Self {
        self.transform = Some(expr.into());
        self
    }
}

// ============================================================================
// MODELS — COLUMN
// ============================================================================

/// Source of a column's value: either system metadata or per-exchange data.
///
/// Wire shape: a single tagged field `meta` or `data` flattened into the
/// parent column. Constructing this enum directly is supported, but the
/// constructors on [`SchemaColumn`] are usually more ergonomic.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ColumnSource {
    /// System metadata extraction (e.g. ingestion timestamp).
    Meta(MetaExtraction),
    /// Per-exchange data extraction map keyed by exchange code.
    Data(HashMap<String, ExchangeExtractor>),
}

/// A single column in an export schema.
///
/// Each column extracts either system metadata (via [`Self::meta`]) or
/// per-exchange data (via [`Self::data`]). Build per-exchange columns by
/// chaining [`DataColumn::exchange`] and either calling
/// [`DataColumn::build`] or passing the [`DataColumn`] directly to
/// [`SchemaContentBuilder::column`].
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{
///     DataType, ExchangeExtractor, MetaExtraction, MetaValue, SchemaColumn,
/// };
/// let ts = SchemaColumn::meta("ts", MetaExtraction::new(MetaValue::CollectionTimestampNs));
/// let price: SchemaColumn = SchemaColumn::data("price")
///     .exchange("binance", ExchangeExtractor::json("data.p", DataType::Decimal(18)))
///     .into();
/// assert_eq!(ts.output_column, "ts");
/// assert_eq!(price.output_column, "price");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaColumn {
    /// Column name in the exported file.
    pub output_column: String,
    /// Where this column's value comes from.
    #[serde(flatten)]
    pub source: ColumnSource,
}

impl SchemaColumn {
    /// Constructs a column that extracts a system metadata value.
    ///
    /// # Examples
    ///
    /// ```
    /// use ticksupply::resources::export_schemas::{MetaExtraction, MetaValue, SchemaColumn};
    /// let col = SchemaColumn::meta("ts", MetaExtraction::new(MetaValue::CollectionTimestampNs));
    /// assert_eq!(col.output_column, "ts");
    /// ```
    pub fn meta(output_column: impl Into<String>, extraction: MetaExtraction) -> Self {
        Self {
            output_column: output_column.into(),
            source: ColumnSource::Meta(extraction),
        }
    }

    /// Begins a per-exchange data column.
    ///
    /// Add per-exchange extractors with [`DataColumn::exchange`].
    ///
    /// # Examples
    ///
    /// ```
    /// use ticksupply::resources::export_schemas::{DataType, ExchangeExtractor, SchemaColumn};
    /// let col: SchemaColumn = SchemaColumn::data("price")
    ///     .exchange("binance", ExchangeExtractor::json("data.p", DataType::Decimal(18)))
    ///     .into();
    /// assert_eq!(col.output_column, "price");
    /// ```
    pub fn data(output_column: impl Into<String>) -> DataColumn {
        DataColumn {
            output_column: output_column.into(),
            extractors: HashMap::new(),
        }
    }
}

/// Builder for a per-exchange data column.
///
/// Convert to [`SchemaColumn`] with [`Self::build`] / `.into()`, or pass
/// directly to [`SchemaContentBuilder::column`] which accepts any type
/// implementing `Into<SchemaColumn>`.
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{DataType, ExchangeExtractor, SchemaColumn};
/// let col = SchemaColumn::data("price")
///     .exchange("binance", ExchangeExtractor::json("data.p", DataType::Decimal(18)))
///     .exchange("bybit",   ExchangeExtractor::json("data.price", DataType::Decimal(18)))
///     .build();
/// assert_eq!(col.output_column, "price");
/// ```
pub struct DataColumn {
    output_column: String,
    extractors: HashMap<String, ExchangeExtractor>,
}

impl DataColumn {
    /// Adds (or replaces) the extractor for `exchange`.
    #[must_use]
    pub fn exchange(mut self, exchange: impl Into<String>, extractor: ExchangeExtractor) -> Self {
        self.extractors.insert(exchange.into(), extractor);
        self
    }

    /// Finalizes the data column.
    pub fn build(self) -> SchemaColumn {
        SchemaColumn {
            output_column: self.output_column,
            source: ColumnSource::Data(self.extractors),
        }
    }
}

impl From<DataColumn> for SchemaColumn {
    fn from(d: DataColumn) -> Self {
        d.build()
    }
}

// ============================================================================
// MODELS — UNFOLD / DERIVE
// ============================================================================

/// Configuration for unfolding a JSON array into one row per element.
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::UnfoldConfig;
/// let u = UnfoldConfig::new("data.events");
/// assert_eq!(u.path, "data.events");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnfoldConfig {
    /// Dot-notation path to the JSON array within the raw message.
    pub path: String,
}

impl UnfoldConfig {
    /// Constructs an unfold config for `path`.
    pub fn new(path: impl Into<String>) -> Self {
        Self { path: path.into() }
    }
}

/// One array source for a `tagged_concat` derive op.
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::TaggedArray;
/// let a = TaggedArray::new("data.bids", "bid");
/// assert_eq!(a.tag, "bid");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaggedArray {
    /// Dot-notation path into the raw message pointing at an array.
    pub path: String,
    /// Tag injected into each element from this array.
    pub tag: String,
}

impl TaggedArray {
    /// Constructs a tagged array source.
    pub fn new(path: impl Into<String>, tag: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            tag: tag.into(),
        }
    }
}

/// Operation that produces a derived field from the raw message.
///
/// Currently only [`Self::TaggedConcat`] is supported. Marked
/// `#[non_exhaustive]` so future ops can be added without breaking
/// downstream `match`es; deserialization of an unknown `op` will fail
/// loudly, prompting a client upgrade.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DeriveOp {
    /// Merges multiple arrays into one, injecting a tag into each element.
    TaggedConcat {
        /// Arrays to merge, each with its tag label.
        arrays: Vec<TaggedArray>,
        /// Field name holding the injected tag in each merged element.
        tag_field: String,
        /// Field name wrapping the original element value.
        value_field: String,
    },
}

/// A derived (computed) field synthesized from the raw payload.
///
/// Derived fields can be referenced by extractor paths
/// (`data.{field}.{...}`) and by [`UnfoldConfig::path`].
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{DeriveField, DeriveOp, TaggedArray};
/// let d = DeriveField {
///     field: "events".into(),
///     op: DeriveOp::TaggedConcat {
///         arrays: vec![
///             TaggedArray::new("data.b", "bid"),
///             TaggedArray::new("data.a", "ask"),
///         ],
///         tag_field: "side".into(),
///         value_field: "level".into(),
///     },
/// };
/// assert_eq!(d.field, "events");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeriveField {
    /// Name of the derived field.
    pub field: String,
    /// Operation that produces this field.
    #[serde(flatten)]
    pub op: DeriveOp,
}

// ============================================================================
// MODELS — SCHEMA CONTENT
// ============================================================================

/// Reusable bundle of `columns` + optional `unfold` / `derive` rules.
///
/// `SchemaContent` is the input for every mutating endpoint
/// ([`ExportSchemasResource::create`], [`ExportSchemasResource::update`],
/// [`SchemaHandle::update_draft`]). Build it once with
/// [`SchemaContent::builder`] (or as a struct literal — all fields are
/// `pub`) and pass it wherever schema content is required.
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{
///     DataType, ExchangeExtractor, MetaExtraction, MetaValue,
///     SchemaColumn, SchemaContent, UnfoldConfig,
/// };
/// let content = SchemaContent::builder()
///     .column(SchemaColumn::meta("ts", MetaExtraction::new(MetaValue::CollectionTimestampNs)))
///     .column(SchemaColumn::data("price").exchange(
///         "binance",
///         ExchangeExtractor::json("data.p", DataType::Decimal(18)),
///     ))
///     .unfold("binance", UnfoldConfig::new("data.events"))
///     .build();
/// assert_eq!(content.columns.len(), 2);
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaContent {
    /// Column definitions in output order.
    pub columns: Vec<SchemaColumn>,
    /// Per-exchange unfold rules.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub unfold: Option<HashMap<String, UnfoldConfig>>,
    /// Per-exchange derived-field rules.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub derive: Option<HashMap<String, Vec<DeriveField>>>,
}

impl SchemaContent {
    /// Returns a new [`SchemaContentBuilder`].
    pub fn builder() -> SchemaContentBuilder {
        SchemaContentBuilder::default()
    }
}

/// Fluent builder for [`SchemaContent`].
///
/// # Examples
///
/// ```
/// use ticksupply::resources::export_schemas::{
///     MetaExtraction, MetaValue, SchemaColumn, SchemaContent,
/// };
/// let content = SchemaContent::builder()
///     .column(SchemaColumn::meta(
///         "ts",
///         MetaExtraction::new(MetaValue::CollectionTimestampNs),
///     ))
///     .build();
/// assert_eq!(content.columns.len(), 1);
/// ```
#[derive(Debug, Clone, Default)]
pub struct SchemaContentBuilder {
    columns: Vec<SchemaColumn>,
    unfold: HashMap<String, UnfoldConfig>,
    derive: HashMap<String, Vec<DeriveField>>,
}

impl SchemaContentBuilder {
    /// Appends a column. Accepts either a [`SchemaColumn`] or a
    /// [`DataColumn`] (which converts via [`From`]).
    #[must_use]
    pub fn column(mut self, column: impl Into<SchemaColumn>) -> Self {
        self.columns.push(column.into());
        self
    }

    /// Appends multiple columns from any iterator yielding items
    /// convertible into [`SchemaColumn`].
    #[must_use]
    pub fn columns<I, C>(mut self, columns: I) -> Self
    where
        I: IntoIterator<Item = C>,
        C: Into<SchemaColumn>,
    {
        self.columns.extend(columns.into_iter().map(Into::into));
        self
    }

    /// Sets (or replaces) the unfold rule for `exchange`.
    #[must_use]
    pub fn unfold(mut self, exchange: impl Into<String>, config: UnfoldConfig) -> Self {
        self.unfold.insert(exchange.into(), config);
        self
    }

    /// Sets (or replaces) the derive rules for `exchange`.
    #[must_use]
    pub fn derive(
        mut self,
        exchange: impl Into<String>,
        fields: impl IntoIterator<Item = DeriveField>,
    ) -> Self {
        self.derive
            .insert(exchange.into(), fields.into_iter().collect());
        self
    }

    /// Finalizes the builder.
    pub fn build(self) -> SchemaContent {
        SchemaContent {
            columns: self.columns,
            unfold: if self.unfold.is_empty() {
                None
            } else {
                Some(self.unfold)
            },
            derive: if self.derive.is_empty() {
                None
            } else {
                Some(self.derive)
            },
        }
    }
}

// ============================================================================
// MODELS — RESPONSE TYPES
// ============================================================================

/// Slim schema metadata, returned by [`ExportSchemasResource::list`].
///
/// Column bodies are not included — fetch a single schema with
/// [`ExportSchemasResource::get`] for full content.
#[derive(Debug, Clone, Deserialize)]
pub struct ExportSchemaListItem {
    /// Schema ID (`sch_…`).
    pub id: String,
    /// Human-friendly name.
    pub name: String,
    /// Stream category this schema applies to.
    pub stream_category: StreamCategory,
    /// `true` for built-in schemas (e.g. `"raw"`, `"normalized"`).
    pub is_built_in: bool,
    /// Creation timestamp (RFC 3339 / ISO 8601).
    pub created_at: Timestamp,
    /// Latest published version number (`0` if no version has been
    /// published yet).
    pub version: i32,
    /// `true` if a draft is currently in progress.
    pub has_draft: bool,
}

/// Detailed schema with the content of one specific version.
///
/// Returned by [`ExportSchemasResource::get`], [`ExportSchemasResource::create`],
/// [`ExportSchemasResource::update`], and the draft endpoints. Exports
/// snapshot the schema at creation time — publishing a new version does
/// not affect existing exports.
#[derive(Debug, Clone, Deserialize)]
pub struct ExportSchemaWithVersion {
    /// Schema ID (`sch_…`).
    pub id: String,
    /// Human-friendly name.
    pub name: String,
    /// Stream category.
    pub stream_category: StreamCategory,
    /// `true` for built-in schemas.
    pub is_built_in: bool,
    /// Creation timestamp (RFC 3339 / ISO 8601).
    pub created_at: Timestamp,
    /// Published version reflected in the [`Self::columns`] / [`Self::unfold`]
    /// / [`Self::derive`] payload (`0` for a fresh schema with only a draft).
    pub version: i32,
    /// `true` if a draft is currently in progress.
    pub has_draft: bool,
    /// Column definitions in output order.
    pub columns: Vec<SchemaColumn>,
    /// Per-exchange unfold rules.
    #[serde(default)]
    pub unfold: Option<HashMap<String, UnfoldConfig>>,
    /// Per-exchange derived-field rules.
    #[serde(default)]
    pub derive: Option<HashMap<String, Vec<DeriveField>>>,
}

// ============================================================================
// RESOURCE
// ============================================================================

/// Accessor for `/export-schemas` endpoints.
pub struct ExportSchemasResource<'a> {
    pub(crate) client: &'a Client,
}

impl<'a> ExportSchemasResource<'a> {
    /// Returns a [`SchemaHandle`] bound to `id`, exposing the same draft
    /// and update operations without repeating the id on each call.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// let h = client.export_schemas().for_id("sch_abc");
    /// let _schema = h.get().await?;
    /// h.discard_draft().send().await?;
    /// # Ok(()) }
    /// ```
    pub fn for_id(&self, id: impl Into<String>) -> SchemaHandle<'a> {
        SchemaHandle {
            client: self.client,
            id: id.into(),
        }
    }

    /// Lists export schemas as a flat array — this endpoint is not paginated.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// for schema in client.export_schemas().list().await? {
    ///     println!("{}: {} (v{})", schema.id, schema.name, schema.version);
    /// }
    /// # Ok(()) }
    /// ```
    pub async fn list(&self) -> Result<Vec<ExportSchemaListItem>> {
        send::<_, ()>(
            self.client,
            reqwest::Method::GET,
            "/export-schemas",
            None,
            None,
            RequestOpts::default(),
        )
        .await
    }

    /// Retrieves a schema by ID, including its latest published content.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no schema has this ID.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// let schema = client.export_schemas().get("sch_abc").await?;
    /// println!("{} columns", schema.columns.len());
    /// # Ok(()) }
    /// ```
    pub async fn get(&self, id: &str) -> Result<ExportSchemaWithVersion> {
        let path = format!("/export-schemas/{id}");
        send::<_, ()>(
            self.client,
            reqwest::Method::GET,
            &path,
            None,
            None,
            RequestOpts::default(),
        )
        .await
    }

    /// Returns a builder for creating a custom export schema at version 1.
    ///
    /// The `name` must be unique per `(account, stream_category)` and must
    /// not collide with a built-in schema name.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// use ticksupply::resources::export_schemas::{
    ///     MetaExtraction, MetaValue, SchemaColumn, SchemaContent, StreamCategory,
    /// };
    /// let client = ticksupply::Client::new()?;
    /// let content = SchemaContent::builder()
    ///     .column(SchemaColumn::meta(
    ///         "ts",
    ///         MetaExtraction::new(MetaValue::CollectionTimestampNs),
    ///     ))
    ///     .build();
    /// let schema = client.export_schemas()
    ///     .create("my_trades", StreamCategory::Trade, content)
    ///     .send().await?;
    /// # let _ = schema;
    /// # Ok(()) }
    /// ```
    pub fn create(
        &self,
        name: impl Into<String>,
        stream_category: StreamCategory,
        content: SchemaContent,
    ) -> CreateExportSchemaRequest<'a> {
        CreateExportSchemaRequest {
            client: self.client,
            name: name.into(),
            stream_category,
            content,
            idempotency_key: None,
        }
    }

    /// Returns a builder for atomically replacing a schema's content with a
    /// new published version (skipping the draft workflow). Increments
    /// `version` by 1 and discards any in-progress draft.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// # use ticksupply::resources::export_schemas::SchemaContent;
    /// # let content = SchemaContent::builder().build();
    /// let client = ticksupply::Client::new()?;
    /// let updated = client.export_schemas()
    ///     .update("sch_abc", content)
    ///     .send().await?;
    /// # let _ = updated;
    /// # Ok(()) }
    /// ```
    pub fn update(
        &self,
        id: impl Into<String>,
        content: SchemaContent,
    ) -> UpdateExportSchemaRequest<'a> {
        UpdateExportSchemaRequest {
            client: self.client,
            id: id.into(),
            content,
            idempotency_key: None,
        }
    }

    /// Returns a builder for deleting a user-owned schema.
    ///
    /// Built-in schemas cannot be deleted; attempting to delete one
    /// surfaces as [`crate::Error::NotFound`] since the account does not
    /// own it.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().delete("sch_abc").send().await?;
    /// # Ok(()) }
    /// ```
    pub fn delete(&self, id: impl Into<String>) -> DeleteExportSchemaRequest<'a> {
        DeleteExportSchemaRequest {
            client: self.client,
            id: id.into(),
            idempotency_key: None,
        }
    }

    /// Returns a builder for creating (or replacing) the draft on a schema.
    ///
    /// By default the server copies the latest published version into the
    /// new draft. Override the content with [`CreateDraftRequest::content`].
    /// Replaces any existing draft.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// // Branch from the latest published version:
    /// client.export_schemas().create_draft("sch_abc").send().await?;
    /// # Ok(()) }
    /// ```
    pub fn create_draft(&self, id: impl Into<String>) -> CreateDraftRequest<'a> {
        CreateDraftRequest {
            client: self.client,
            id: id.into(),
            content: None,
            idempotency_key: None,
        }
    }

    /// Retrieves the current draft for a schema.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no draft exists.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// let draft = client.export_schemas().get_draft("sch_abc").await?;
    /// println!("draft has {} columns", draft.columns.len());
    /// # Ok(()) }
    /// ```
    pub async fn get_draft(&self, id: &str) -> Result<ExportSchemaWithVersion> {
        let path = format!("/export-schemas/{id}/draft");
        send::<_, ()>(
            self.client,
            reqwest::Method::GET,
            &path,
            None,
            None,
            RequestOpts::default(),
        )
        .await
    }

    /// Returns a builder for replacing the contents of an existing draft.
    ///
    /// Returns [`crate::Error::NotFound`] from [`UpdateDraftRequest::send`]
    /// if no draft exists — call [`Self::create_draft`] first.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// # use ticksupply::resources::export_schemas::SchemaContent;
    /// # let content = SchemaContent::builder().build();
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().update_draft("sch_abc", content).send().await?;
    /// # Ok(()) }
    /// ```
    pub fn update_draft(
        &self,
        id: impl Into<String>,
        content: SchemaContent,
    ) -> UpdateDraftRequest<'a> {
        UpdateDraftRequest {
            client: self.client,
            id: id.into(),
            content,
        }
    }

    /// Returns a builder for discarding the draft on a schema without
    /// publishing.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().discard_draft("sch_abc").send().await?;
    /// # Ok(()) }
    /// ```
    pub fn discard_draft(&self, id: impl Into<String>) -> DiscardDraftRequest<'a> {
        DiscardDraftRequest {
            client: self.client,
            id: id.into(),
            idempotency_key: None,
        }
    }

    /// Returns a builder for promoting the current draft to the next
    /// published version.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// let published = client.export_schemas()
    ///     .publish_draft("sch_abc")
    ///     .send().await?;
    /// # let _ = published;
    /// # Ok(()) }
    /// ```
    pub fn publish_draft(&self, id: impl Into<String>) -> PublishDraftRequest<'a> {
        PublishDraftRequest {
            client: self.client,
            id: id.into(),
            idempotency_key: None,
        }
    }
}

// ============================================================================
// SCHEMA HANDLE
// ============================================================================

/// Convenience handle bound to a single schema id.
///
/// Hosts every schema-relative operation so callers don't repeat the id on
/// each call. Equivalent to calling the corresponding method on
/// [`ExportSchemasResource`] with `id` directly.
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> ticksupply::Result<()> {
/// # use ticksupply::resources::export_schemas::SchemaContent;
/// # let content = SchemaContent::builder().build();
/// let client = ticksupply::Client::new()?;
/// let h = client.export_schemas().for_id("sch_abc");
/// h.create_draft().send().await?;
/// h.update_draft(content).send().await?;
/// h.publish_draft().send().await?;
/// # Ok(()) }
/// ```
pub struct SchemaHandle<'a> {
    client: &'a Client,
    id: String,
}

impl<'a> SchemaHandle<'a> {
    /// Returns the schema id this handle is bound to.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Retrieves this schema, including its latest published content.
    ///
    /// # Errors
    ///
    /// See [`ExportSchemasResource::get`].
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// let schema = client.export_schemas().for_id("sch_abc").get().await?;
    /// # let _ = schema;
    /// # Ok(()) }
    /// ```
    pub async fn get(&self) -> Result<ExportSchemaWithVersion> {
        ExportSchemasResource {
            client: self.client,
        }
        .get(&self.id)
        .await
    }

    /// Returns a builder for atomically updating this schema's content.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// # use ticksupply::resources::export_schemas::SchemaContent;
    /// # let content = SchemaContent::builder().build();
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().for_id("sch_abc").update(content).send().await?;
    /// # Ok(()) }
    /// ```
    pub fn update(&self, content: SchemaContent) -> UpdateExportSchemaRequest<'a> {
        UpdateExportSchemaRequest {
            client: self.client,
            id: self.id.clone(),
            content,
            idempotency_key: None,
        }
    }

    /// Returns a builder for deleting this schema.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().for_id("sch_abc").delete().send().await?;
    /// # Ok(()) }
    /// ```
    pub fn delete(&self) -> DeleteExportSchemaRequest<'a> {
        DeleteExportSchemaRequest {
            client: self.client,
            id: self.id.clone(),
            idempotency_key: None,
        }
    }

    /// Retrieves the current draft for this schema.
    ///
    /// # Errors
    ///
    /// See [`ExportSchemasResource::get_draft`].
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// let draft = client.export_schemas().for_id("sch_abc").get_draft().await?;
    /// # let _ = draft;
    /// # Ok(()) }
    /// ```
    pub async fn get_draft(&self) -> Result<ExportSchemaWithVersion> {
        ExportSchemasResource {
            client: self.client,
        }
        .get_draft(&self.id)
        .await
    }

    /// Returns a builder for creating (or replacing) the draft.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().for_id("sch_abc").create_draft().send().await?;
    /// # Ok(()) }
    /// ```
    pub fn create_draft(&self) -> CreateDraftRequest<'a> {
        CreateDraftRequest {
            client: self.client,
            id: self.id.clone(),
            content: None,
            idempotency_key: None,
        }
    }

    /// Returns a builder for replacing this schema's draft contents.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// # use ticksupply::resources::export_schemas::SchemaContent;
    /// # let content = SchemaContent::builder().build();
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().for_id("sch_abc").update_draft(content).send().await?;
    /// # Ok(()) }
    /// ```
    pub fn update_draft(&self, content: SchemaContent) -> UpdateDraftRequest<'a> {
        UpdateDraftRequest {
            client: self.client,
            id: self.id.clone(),
            content,
        }
    }

    /// Returns a builder for discarding this schema's draft.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().for_id("sch_abc").discard_draft().send().await?;
    /// # Ok(()) }
    /// ```
    pub fn discard_draft(&self) -> DiscardDraftRequest<'a> {
        DiscardDraftRequest {
            client: self.client,
            id: self.id.clone(),
            idempotency_key: None,
        }
    }

    /// Returns a builder for publishing this schema's draft.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().for_id("sch_abc").publish_draft().send().await?;
    /// # Ok(()) }
    /// ```
    pub fn publish_draft(&self) -> PublishDraftRequest<'a> {
        PublishDraftRequest {
            client: self.client,
            id: self.id.clone(),
            idempotency_key: None,
        }
    }
}

// ============================================================================
// REQUEST BUILDERS
// ============================================================================

#[derive(Serialize)]
struct CreateBody<'b> {
    name: &'b str,
    stream_category: &'b StreamCategory,
    #[serde(flatten)]
    content: &'b SchemaContent,
}

/// Builder for `POST /v1/export-schemas`.
pub struct CreateExportSchemaRequest<'a> {
    client: &'a Client,
    name: String,
    stream_category: StreamCategory,
    content: SchemaContent,
    idempotency_key: Option<String>,
}

impl<'a> CreateExportSchemaRequest<'a> {
    /// Sets the idempotency key for this request (any UUID format, ≤128 chars).
    #[must_use]
    pub fn idempotency_key(mut self, k: impl Into<String>) -> Self {
        self.idempotency_key = Some(k.into());
        self
    }

    /// Sends the create request.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::Validation`] if the column set is empty, exceeds
    ///   the per-account limit, contains a duplicate `output_column`, or
    ///   has an empty unfold path.
    /// - [`crate::Error::AlreadyExists`] / `409` if the name conflicts
    ///   with an existing or built-in schema.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// use ticksupply::resources::export_schemas::{
    ///     MetaExtraction, MetaValue, SchemaColumn, SchemaContent, StreamCategory,
    /// };
    /// let content = SchemaContent::builder()
    ///     .column(SchemaColumn::meta("ts", MetaExtraction::new(MetaValue::CollectionTimestampNs)))
    ///     .build();
    /// let client = ticksupply::Client::new()?;
    /// let schema = client.export_schemas()
    ///     .create("my_trades", StreamCategory::Trade, content)
    ///     .send().await?;
    /// # let _ = schema;
    /// # Ok(()) }
    /// ```
    pub async fn send(self) -> Result<ExportSchemaWithVersion> {
        let body = CreateBody {
            name: &self.name,
            stream_category: &self.stream_category,
            content: &self.content,
        };
        send(
            self.client,
            reqwest::Method::POST,
            "/export-schemas",
            None,
            Some(&body),
            RequestOpts {
                idempotency_key: self.idempotency_key.as_deref(),
            },
        )
        .await
    }
}

/// Builder for `PUT /v1/export-schemas/{id}`.
pub struct UpdateExportSchemaRequest<'a> {
    client: &'a Client,
    id: String,
    content: SchemaContent,
    idempotency_key: Option<String>,
}

impl<'a> UpdateExportSchemaRequest<'a> {
    /// Sets the idempotency key for this request (any UUID format, ≤128 chars).
    #[must_use]
    pub fn idempotency_key(mut self, k: impl Into<String>) -> Self {
        self.idempotency_key = Some(k.into());
        self
    }

    /// Sends the update request.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no schema has this ID or it is built-in.
    /// - [`crate::Error::Validation`] if column constraints are violated.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// # use ticksupply::resources::export_schemas::SchemaContent;
    /// # let content = SchemaContent::builder().build();
    /// let client = ticksupply::Client::new()?;
    /// let v2 = client.export_schemas()
    ///     .update("sch_abc", content)
    ///     .send().await?;
    /// # let _ = v2;
    /// # Ok(()) }
    /// ```
    pub async fn send(self) -> Result<ExportSchemaWithVersion> {
        let path = format!("/export-schemas/{}", self.id);
        send(
            self.client,
            reqwest::Method::PUT,
            &path,
            None,
            Some(&self.content),
            RequestOpts {
                idempotency_key: self.idempotency_key.as_deref(),
            },
        )
        .await
    }
}

/// Builder for `DELETE /v1/export-schemas/{id}`.
pub struct DeleteExportSchemaRequest<'a> {
    client: &'a Client,
    id: String,
    idempotency_key: Option<String>,
}

impl<'a> DeleteExportSchemaRequest<'a> {
    /// Sets the idempotency key for this request (any UUID format, ≤128 chars).
    #[must_use]
    pub fn idempotency_key(mut self, k: impl Into<String>) -> Self {
        self.idempotency_key = Some(k.into());
        self
    }

    /// Sends the delete request.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no schema has this ID or it is built-in.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().delete("sch_abc").send().await?;
    /// # Ok(()) }
    /// ```
    pub async fn send(self) -> Result<()> {
        let path = format!("/export-schemas/{}", self.id);
        send_empty::<()>(
            self.client,
            reqwest::Method::DELETE,
            &path,
            None,
            None,
            RequestOpts {
                idempotency_key: self.idempotency_key.as_deref(),
            },
        )
        .await
    }
}

/// Builder for `POST /v1/export-schemas/{id}/draft`.
pub struct CreateDraftRequest<'a> {
    client: &'a Client,
    id: String,
    content: Option<SchemaContent>,
    idempotency_key: Option<String>,
}

impl<'a> CreateDraftRequest<'a> {
    /// Supplies explicit content for the draft.
    ///
    /// Without this call, the server copies the latest published version
    /// into the new draft.
    #[must_use]
    pub fn content(mut self, content: SchemaContent) -> Self {
        self.content = Some(content);
        self
    }

    /// Sets the idempotency key for this request (any UUID format, ≤128 chars).
    #[must_use]
    pub fn idempotency_key(mut self, k: impl Into<String>) -> Self {
        self.idempotency_key = Some(k.into());
        self
    }

    /// Sends the create-draft request.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no schema has this ID, the schema
    ///   is built-in, or no published version exists to copy from.
    /// - [`crate::Error::Validation`] if the supplied content is invalid.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// # use ticksupply::resources::export_schemas::SchemaContent;
    /// # let content = SchemaContent::builder().build();
    /// let client = ticksupply::Client::new()?;
    /// // Branch from the latest published version:
    /// client.export_schemas().create_draft("sch_abc").send().await?;
    /// // …or supply explicit content:
    /// client.export_schemas().create_draft("sch_abc").content(content).send().await?;
    /// # Ok(()) }
    /// ```
    pub async fn send(self) -> Result<ExportSchemaWithVersion> {
        let path = format!("/export-schemas/{}/draft", self.id);
        match self.content {
            Some(c) => {
                send(
                    self.client,
                    reqwest::Method::POST,
                    &path,
                    None,
                    Some(&c),
                    RequestOpts {
                        idempotency_key: self.idempotency_key.as_deref(),
                    },
                )
                .await
            }
            None => {
                send::<_, ()>(
                    self.client,
                    reqwest::Method::POST,
                    &path,
                    None,
                    None,
                    RequestOpts {
                        idempotency_key: self.idempotency_key.as_deref(),
                    },
                )
                .await
            }
        }
    }
}

/// Builder for `PUT /v1/export-schemas/{id}/draft`.
pub struct UpdateDraftRequest<'a> {
    client: &'a Client,
    id: String,
    content: SchemaContent,
}

impl<'a> UpdateDraftRequest<'a> {
    /// Sends the update-draft request.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no draft exists.
    /// - [`crate::Error::Validation`] if column constraints are violated.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// # use ticksupply::resources::export_schemas::SchemaContent;
    /// # let content = SchemaContent::builder().build();
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().update_draft("sch_abc", content).send().await?;
    /// # Ok(()) }
    /// ```
    pub async fn send(self) -> Result<ExportSchemaWithVersion> {
        let path = format!("/export-schemas/{}/draft", self.id);
        send(
            self.client,
            reqwest::Method::PUT,
            &path,
            None,
            Some(&self.content),
            // PUT is not retried automatically; idempotency keys for draft
            // updates are not exposed by the server, so none is sent.
            RequestOpts::default(),
        )
        .await
    }
}

/// Builder for `DELETE /v1/export-schemas/{id}/draft`.
pub struct DiscardDraftRequest<'a> {
    client: &'a Client,
    id: String,
    idempotency_key: Option<String>,
}

impl<'a> DiscardDraftRequest<'a> {
    /// Sets the idempotency key for this request (any UUID format, ≤128 chars).
    #[must_use]
    pub fn idempotency_key(mut self, k: impl Into<String>) -> Self {
        self.idempotency_key = Some(k.into());
        self
    }

    /// Sends the discard-draft request.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no draft exists.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// client.export_schemas().discard_draft("sch_abc").send().await?;
    /// # Ok(()) }
    /// ```
    pub async fn send(self) -> Result<()> {
        let path = format!("/export-schemas/{}/draft", self.id);
        send_empty::<()>(
            self.client,
            reqwest::Method::DELETE,
            &path,
            None,
            None,
            RequestOpts {
                idempotency_key: self.idempotency_key.as_deref(),
            },
        )
        .await
    }
}

/// Builder for `POST /v1/export-schemas/{id}/publish`.
pub struct PublishDraftRequest<'a> {
    client: &'a Client,
    id: String,
    idempotency_key: Option<String>,
}

impl<'a> PublishDraftRequest<'a> {
    /// Sets the idempotency key for this request (any UUID format, ≤128 chars).
    #[must_use]
    pub fn idempotency_key(mut self, k: impl Into<String>) -> Self {
        self.idempotency_key = Some(k.into());
        self
    }

    /// Sends the publish-draft request.
    ///
    /// # Errors
    ///
    /// - [`crate::Error::NotFound`] if no draft exists.
    /// - [`crate::Error::Authentication`] on invalid credentials.
    /// - [`crate::Error::Network`] on transport failure.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn example() -> ticksupply::Result<()> {
    /// let client = ticksupply::Client::new()?;
    /// let published = client.export_schemas()
    ///     .publish_draft("sch_abc")
    ///     .send().await?;
    /// # let _ = published;
    /// # Ok(()) }
    /// ```
    pub async fn send(self) -> Result<ExportSchemaWithVersion> {
        let path = format!("/export-schemas/{}/publish", self.id);
        send::<_, ()>(
            self.client,
            reqwest::Method::POST,
            &path,
            None,
            None,
            RequestOpts {
                idempotency_key: self.idempotency_key.as_deref(),
            },
        )
        .await
    }
}
