//! # nemweb
//!
//! Rust client for [AEMO NEMWeb](https://nemweb.com.au) — Australian National
//! Electricity Market data.
//!
//! ## Data types
//!
//! | Query | Record | Interval | Description |
//! |-------|--------|----------|-------------|
//! | [`dispatch()`] | [`DispatchPrice`] | 5-min | Dispatch interval prices |
//! | [`trading()`] | [`TradingPrice`] | 30-min | Settlement prices |
//! | [`demand()`] | [`RegionDemand`] | 5-min | Demand & generation |
//! | [`interconnector()`] | [`InterconnectorFlow`] | 5-min | Cross-region flows |
//! | [`predispatch()`] | [`PredispatchPrice`] | 30-min | Price forecasts |
//! | [`scada()`] | [`UnitScada`] | 5-min | Generator SCADA output |
//!
//! ## Quick start
//!
//! Add to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! nemweb = "0.1"
//! tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
//! ```
//!
//! ```no_run
//! # async fn example() -> nemweb::Result<()> {
//! // Real-time dispatch prices (latest 5-min interval)
//! let prices = nemweb::dispatch().fetch().await?;
//!
//! // Filter by region
//! let nsw = nemweb::dispatch()
//!     .region(nemweb::Region::NSW1)
//!     .fetch().await?;
//!
//! // Historical data — automatically routes to MMSDM archive
//! let history = nemweb::dispatch()
//!     .from("2024-01-01")?
//!     .to("2024-03-31")?
//!     .fetch().await?;
//! # Ok(())
//! # }
//! ```
//!
//! ## All query types
//!
//! ```no_run
//! # async fn example() -> nemweb::Result<()> {
//! let prices    = nemweb::dispatch().fetch().await?;       // Vec<DispatchPrice>
//! let trading   = nemweb::trading().fetch().await?;        // Vec<TradingPrice>
//! let demand    = nemweb::demand().fetch().await?;         // Vec<RegionDemand>
//! let flows     = nemweb::interconnector().fetch().await?; // Vec<InterconnectorFlow>
//! let forecasts = nemweb::predispatch().fetch().await?;    // Vec<PredispatchPrice>
//! let scada     = nemweb::scada().fetch().await?;          // Vec<UnitScada>
//! # Ok(())
//! # }
//! ```
//!
//! ## Filters
//!
//! ```no_run
//! # async fn example() -> nemweb::Result<()> {
//! // By NEM region (dispatch, trading, demand, predispatch)
//! nemweb::dispatch().region(nemweb::Region::SA1).fetch().await?;
//!
//! // By interconnector ID
//! nemweb::interconnector().id("VIC1-NSW1").fetch().await?;
//!
//! // By generating unit DUID
//! nemweb::scada().duid("BASTYAN").fetch().await?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Historical queries (MMSDM)
//!
//! Adding `.from()` to any query (except `predispatch`) fetches from the
//! MMSDM monthly archive instead of the real-time feed:
//!
//! ```no_run
//! # async fn example() -> nemweb::Result<()> {
//! // Full month
//! let jan = nemweb::dispatch()
//!     .from("2024-01-01")?
//!     .to("2024-01-31")?
//!     .fetch().await?;
//!
//! // Omit .to() — defaults to today
//! let recent = nemweb::dispatch()
//!     .from("2024-12-01")?
//!     .fetch().await?;
//!
//! // Combine with filters
//! let vic = nemweb::dispatch()
//!     .region(nemweb::Region::VIC1)
//!     .from("2024-01-01")?
//!     .to("2024-01-07")?
//!     .fetch().await?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Serde support
//!
//! All record types implement `Serialize` and `Deserialize` (enabled by
//! default, disable with `default-features = false`):
//!
//! ```no_run
//! # async fn example() -> nemweb::Result<()> {
//! let prices = nemweb::dispatch().fetch().await?;
//! let json = serde_json::to_string_pretty(&prices).unwrap();
//! println!("{json}");
//! # Ok(())
//! # }
//! ```

#![warn(missing_docs)]

mod client;
mod error;
mod parser;
mod types;

pub use error::{Error, Result};
pub use types::{
    DispatchPrice, InterconnectorFlow, ParseRegionError, PredispatchPrice, Region, RegionDemand,
    TradingPrice, UnitScada,
};

/// Re-export of [`chrono::NaiveDateTime`] used in all record types.
///
/// This saves users from adding `chrono` as a direct dependency just
/// to work with timestamps returned by this library.
pub use chrono::NaiveDateTime;

use chrono::{Datelike, NaiveDate, NaiveTime};
use std::collections::HashSet;

fn parse_date(date: &str) -> Result<NaiveDate> {
    NaiveDate::parse_from_str(date, "%Y-%m-%d")
        .map_err(|_| Error::InvalidDate { value: date.to_string() })
}

// ── Helpers ────────────────────────────────────────────────────────

fn build_client() -> Result<reqwest::Client> {
    Ok(reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()?)
}

fn today() -> NaiveDate {
    chrono::Utc::now().date_naive()
}

fn advance_month(date: NaiveDate) -> NaiveDate {
    if date.month() == 12 {
        NaiveDate::from_ymd_opt(date.year() + 1, 1, 1).unwrap()
    } else {
        NaiveDate::from_ymd_opt(date.year(), date.month() + 1, 1).unwrap()
    }
}

/// Trading day for an interval-ending timestamp.
///
/// AEMO publishes timestamps at the **end** of each dispatch/trading
/// interval. That means the last interval of a day has a timestamp at
/// 00:00:00 of the *next* calendar day (e.g. the 23:55–00:00 interval of
/// 1 Jan 2024 is recorded as `2024-01-02 00:00:00`). Naively calling
/// `.date()` on that timestamp puts it on 2 Jan, losing the final
/// interval of each day from range queries.
///
/// This helper shifts midnight timestamps back one day so the interval
/// is attributed to the day its activity actually occurred on.
fn trading_day(dt: NaiveDateTime) -> NaiveDate {
    if dt.time() == NaiveTime::from_hms_opt(0, 0, 0).unwrap() {
        dt.date().pred_opt().unwrap_or_else(|| dt.date())
    } else {
        dt.date()
    }
}

/// Shared logic for fetching historical data from MMSDM archives.
async fn fetch_historical<T>(
    http: &reqwest::Client,
    from: NaiveDate,
    to: NaiveDate,
    dataset: client::MmsdmDataset,
    parse: fn(&str) -> Vec<T>,
    get_datetime: fn(&T) -> NaiveDateTime,
) -> Result<Vec<T>> {
    let mut months = Vec::new();
    let mut cursor = NaiveDate::from_ymd_opt(from.year(), from.month(), 1).unwrap();
    let end_month = NaiveDate::from_ymd_opt(to.year(), to.month(), 1).unwrap();
    while cursor <= end_month {
        months.push((cursor.year() as u32, cursor.month()));
        cursor = advance_month(cursor);
    }

    let mut available = HashSet::new();
    let years: HashSet<u32> = months.iter().map(|(y, _)| *y).collect();
    for year in years {
        match client::list_mmsdm_months(http, year).await {
            Ok(ms) => {
                for m in ms {
                    available.insert((year, m));
                }
            }
            Err(e) => {
                // MMSDM archive only goes back to July 2009. Years earlier
                // than that return 404 here, leaving `available` empty and
                // the query to return an empty result. Log so users know
                // why their historical query came up empty.
                log::warn!(
                    "MMSDM year {year} not available ({e}); data before \
                     2009-07 is not in the archive"
                );
            }
        }
    }

    let mut all = Vec::new();
    let mut months_fetched = 0usize;
    for (year, month) in &months {
        if !available.contains(&(*year, *month)) {
            continue;
        }
        match client::fetch_mmsdm_csv(http, *year, *month, dataset).await {
            Ok(csv) => {
                let before = all.len();
                all.extend(parse(&csv));
                // If we successfully downloaded and extracted a ZIP but the
                // parser found zero rows in it, the AEMO CSV schema has
                // probably changed (new column names, new table/subtype
                // identifier, etc). Warn loudly — this is the failure mode
                // that hid the 2024-08 filename change for months.
                if all.len() == before {
                    log::warn!(
                        "MMSDM {year}-{month:02}: downloaded CSV but parser \
                         returned zero rows — the AEMO schema may have \
                         changed, please report this"
                    );
                }
                months_fetched += 1;
            }
            Err(Error::NotFound(msg)) => {
                log::warn!("skipping {year}-{month:02}: {msg}");
                continue;
            }
            Err(e) => return Err(e),
        }
    }

    all.retain(|r| {
        let d = trading_day(get_datetime(r));
        d >= from && d <= to
    });

    // If we iterated months but never successfully downloaded a single one,
    // the directory-listing regex is probably not matching any filenames
    // in the current AEMO format (this is the exact bug we hit with the
    // 2024-08 `PUBLIC_ARCHIVE#...#` rename). Warn so the next user sees it.
    if months_fetched == 0 && !months.is_empty() && !available.is_empty() {
        log::warn!(
            "historical query spanned {} month(s) with {} listed in MMSDM, \
             but zero zip files matched the dataset pattern — AEMO may have \
             changed the filename convention, please report this",
            months.len(),
            available.len()
        );
    }

    Ok(all)
}

// Public API 

/// Create a dispatch price query (5-minute intervals).
///
/// Returns the Regional Reference Price (RRP) for each NEM region at
/// each 5-minute dispatch interval.
///
/// # Real-time
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// // Latest interval — returns one record per region (5 total)
/// let prices = nemweb::dispatch().fetch().await?;
///
/// // Single region
/// let nsw = nemweb::dispatch()
///     .region(nemweb::Region::NSW1)
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
///
/// # Historical (MMSDM)
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// // Full month — ~44,600 records (288 intervals × 31 days × 5 regions)
/// let jan = nemweb::dispatch()
///     .from("2024-01-01")?
///     .to("2024-01-31")?
///     .fetch().await?;
///
/// // One region, one week — ~2,000 records
/// let vic = nemweb::dispatch()
///     .region(nemweb::Region::VIC1)
///     .from("2024-01-01")?
///     .to("2024-01-07")?
///     .fetch().await?;
///
/// // Omit .to() — defaults to today
/// let recent = nemweb::dispatch()
///     .from("2024-12-01")?
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
pub fn dispatch() -> DispatchQuery {
    DispatchQuery {
        region: None,
        from: None,
        to: None,
    }
}

/// Create a trading price query (30-minute settlement intervals).
///
/// The trading price is the time-weighted average of the six 5-minute
/// dispatch prices within each 30-minute trading interval. This is the
/// price used for financial settlement in the NEM.
///
/// # Real-time
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// let prices = nemweb::trading().fetch().await?;
///
/// let qld = nemweb::trading()
///     .region(nemweb::Region::QLD1)
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
///
/// # Historical (MMSDM)
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// let prices = nemweb::trading()
///     .region(nemweb::Region::SA1)
///     .from("2024-01-01")?
///     .to("2024-01-07")?
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
pub fn trading() -> TradingQuery {
    TradingQuery {
        region: None,
        from: None,
        to: None,
    }
}

/// Create a region demand query (5-minute intervals).
///
/// Returns total demand, available generation, and net interchange
/// for each NEM region per dispatch interval.
///
/// # Real-time
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// let demand = nemweb::demand().fetch().await?;
/// for d in &demand {
///     println!(
///         "{}: demand={:.0}MW gen={:.0}MW",
///         d.region, d.total_demand, d.available_generation
///     );
/// }
///
/// let sa = nemweb::demand()
///     .region(nemweb::Region::SA1)
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
///
/// # Historical (MMSDM)
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// let demand = nemweb::demand()
///     .region(nemweb::Region::NSW1)
///     .from("2024-01-01")?
///     .to("2024-01-07")?
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
pub fn demand() -> DemandQuery {
    DemandQuery {
        region: None,
        from: None,
        to: None,
    }
}

/// Create an interconnector flow query (5-minute intervals).
///
/// Returns MW flow and losses for each NEM interconnector.
/// Filter by interconnector ID using [`.id()`](InterconnectorQuery::id).
///
/// Common IDs: `"NSW1-QLD1"`, `"VIC1-NSW1"`, `"V-SA"`, `"V-S-MNSP1"`,
/// `"T-V-MNSP1"`, `"N-Q-MNSP1"`.
///
/// # Real-time
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// // All interconnectors
/// let flows = nemweb::interconnector().fetch().await?;
///
/// // Single interconnector
/// let vic_nsw = nemweb::interconnector()
///     .id("VIC1-NSW1")
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
///
/// # Historical (MMSDM)
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// let flows = nemweb::interconnector()
///     .id("VIC1-NSW1")
///     .from("2024-01-01")?
///     .to("2024-01-07")?
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
pub fn interconnector() -> InterconnectorQuery {
    InterconnectorQuery {
        id: None,
        from: None,
        to: None,
    }
}

/// Create a pre-dispatch price forecast query.
///
/// Returns forecast prices for each region at 30-minute intervals,
/// looking ahead ~40 hours from the current time.
///
/// Pre-dispatch is real-time only (no MMSDM historical support).
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// // All regions, all forecast periods (~300 records)
/// let forecasts = nemweb::predispatch().fetch().await?;
///
/// // Single region
/// let tas = nemweb::predispatch()
///     .region(nemweb::Region::TAS1)
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
pub fn predispatch() -> PredispatchQuery {
    PredispatchQuery { region: None }
}

/// Create a unit SCADA query (5-minute intervals).
///
/// Returns the actual MW output from each generating unit's SCADA
/// telemetry. Filter by unit using [`.duid()`](ScadaQuery::duid).
///
/// # Real-time
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// // All units (~500 records)
/// let scada = nemweb::scada().fetch().await?;
///
/// // Single unit
/// let unit = nemweb::scada()
///     .duid("BASTYAN")
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
///
/// # Historical (MMSDM)
///
/// ```no_run
/// # async fn example() -> nemweb::Result<()> {
/// let scada = nemweb::scada()
///     .duid("BASTYAN")
///     .from("2024-01-01")?
///     .to("2024-01-01")?
///     .fetch().await?;
/// # Ok(())
/// # }
/// ```
pub fn scada() -> ScadaQuery {
    ScadaQuery {
        duid: None,
        from: None,
        to: None,
    }
}

// Dispatch price query

/// Builder for dispatch price queries.
///
/// Created by [`dispatch()`]. See that function's docs for full examples.
pub struct DispatchQuery {
    region: Option<Region>,
    from: Option<NaiveDate>,
    to: Option<NaiveDate>,
}

impl DispatchQuery {
    /// Filter results to a single NEM region.
    pub fn region(mut self, region: Region) -> Self {
        self.region = Some(region);
        self
    }

    /// Start date for historical query (inclusive). Format: `"YYYY-MM-DD"`.
    ///
    /// When set, the query fetches from the MMSDM monthly archive
    /// instead of the real-time feed.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn from(mut self, date: &str) -> Result<Self> {
        self.from = Some(parse_date(date)?);
        Ok(self)
    }

    /// End date for historical query (inclusive). Format: `"YYYY-MM-DD"`.
    ///
    /// Defaults to today if omitted.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn to(mut self, date: &str) -> Result<Self> {
        self.to = Some(parse_date(date)?);
        Ok(self)
    }

    /// Execute the query and return dispatch price records.
    pub async fn fetch(&self) -> Result<Vec<DispatchPrice>> {
        let http = build_client()?;
        let mut records = if let Some(from) = self.from {
            fetch_historical(
                &http,
                from,
                self.to.unwrap_or_else(today),
                client::MmsdmDataset::DispatchPrice,
                parser::parse_dispatch_prices,
                |r| r.settlement_date,
            )
            .await?
        } else {
            let csv = client::fetch_realtime_csv(&http, client::RealtimeReport::Dispatch).await?;
            parser::parse_dispatch_prices(&csv)
        };
        if let Some(region) = self.region {
            records.retain(|r| r.region == region);
        }
        Ok(records)
    }
}

// ── Trading price query ────────────────────────────────────────────

/// Builder for trading price queries.
///
/// Created by [`trading()`]. See that function's docs for full examples.
pub struct TradingQuery {
    region: Option<Region>,
    from: Option<NaiveDate>,
    to: Option<NaiveDate>,
}

impl TradingQuery {
    /// Filter results to a single NEM region.
    pub fn region(mut self, region: Region) -> Self {
        self.region = Some(region);
        self
    }

    /// Start date for historical query (inclusive). Format: `"YYYY-MM-DD"`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn from(mut self, date: &str) -> Result<Self> {
        self.from = Some(parse_date(date)?);
        Ok(self)
    }

    /// End date for historical query (inclusive). Defaults to today.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn to(mut self, date: &str) -> Result<Self> {
        self.to = Some(parse_date(date)?);
        Ok(self)
    }

    /// Execute the query and return trading price records.
    pub async fn fetch(&self) -> Result<Vec<TradingPrice>> {
        let http = build_client()?;
        let mut records = if let Some(from) = self.from {
            fetch_historical(
                &http,
                from,
                self.to.unwrap_or_else(today),
                client::MmsdmDataset::TradingPrice,
                parser::parse_trading_prices,
                |r| r.settlement_date,
            )
            .await?
        } else {
            let csv = client::fetch_realtime_csv(&http, client::RealtimeReport::Trading).await?;
            parser::parse_trading_prices(&csv)
        };
        if let Some(region) = self.region {
            records.retain(|r| r.region == region);
        }
        Ok(records)
    }
}

// Region demand query

/// Builder for region demand queries.
///
/// Created by [`demand()`]. See that function's docs for full examples.
pub struct DemandQuery {
    region: Option<Region>,
    from: Option<NaiveDate>,
    to: Option<NaiveDate>,
}

impl DemandQuery {
    /// Filter results to a single NEM region.
    pub fn region(mut self, region: Region) -> Self {
        self.region = Some(region);
        self
    }

    /// Start date for historical query (inclusive). Format: `"YYYY-MM-DD"`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn from(mut self, date: &str) -> Result<Self> {
        self.from = Some(parse_date(date)?);
        Ok(self)
    }

    /// End date for historical query (inclusive). Defaults to today.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn to(mut self, date: &str) -> Result<Self> {
        self.to = Some(parse_date(date)?);
        Ok(self)
    }

    /// Execute the query and return region demand records.
    pub async fn fetch(&self) -> Result<Vec<RegionDemand>> {
        let http = build_client()?;
        let mut records = if let Some(from) = self.from {
            fetch_historical(
                &http,
                from,
                self.to.unwrap_or_else(today),
                client::MmsdmDataset::DispatchRegionSum,
                parser::parse_region_demand,
                |r| r.settlement_date,
            )
            .await?
        } else {
            let csv = client::fetch_realtime_csv(&http, client::RealtimeReport::Dispatch).await?;
            parser::parse_region_demand(&csv)
        };
        if let Some(region) = self.region {
            records.retain(|r| r.region == region);
        }
        Ok(records)
    }
}

// Interconnector flow query

/// Builder for interconnector flow queries.
///
/// Created by [`interconnector()`]. See that function's docs for full examples.
pub struct InterconnectorQuery {
    id: Option<String>,
    from: Option<NaiveDate>,
    to: Option<NaiveDate>,
}

impl InterconnectorQuery {
    /// Filter to a specific interconnector by ID (e.g. `"VIC1-NSW1"`).
    ///
    /// Common IDs: `"NSW1-QLD1"`, `"VIC1-NSW1"`, `"V-SA"`, `"V-S-MNSP1"`,
    /// `"T-V-MNSP1"`, `"N-Q-MNSP1"`.
    pub fn id(mut self, interconnector_id: &str) -> Self {
        self.id = Some(interconnector_id.to_string());
        self
    }

    /// Start date for historical query (inclusive). Format: `"YYYY-MM-DD"`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn from(mut self, date: &str) -> Result<Self> {
        self.from = Some(parse_date(date)?);
        Ok(self)
    }

    /// End date for historical query (inclusive). Defaults to today.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn to(mut self, date: &str) -> Result<Self> {
        self.to = Some(parse_date(date)?);
        Ok(self)
    }

    /// Execute the query and return interconnector flow records.
    pub async fn fetch(&self) -> Result<Vec<InterconnectorFlow>> {
        let http = build_client()?;
        let mut records = if let Some(from) = self.from {
            fetch_historical(
                &http,
                from,
                self.to.unwrap_or_else(today),
                client::MmsdmDataset::DispatchInterconnectorRes,
                parser::parse_interconnector_flows,
                |r| r.settlement_date,
            )
            .await?
        } else {
            let csv = client::fetch_realtime_csv(&http, client::RealtimeReport::Dispatch).await?;
            parser::parse_interconnector_flows(&csv)
        };
        if let Some(id) = &self.id {
            records.retain(|r| r.interconnector_id == *id);
        }
        Ok(records)
    }
}

// Pre-dispatch query

/// Builder for pre-dispatch forecast queries.
///
/// Created by [`predispatch()`]. See that function's docs for full examples.
///
/// Pre-dispatch is real-time only — historical MMSDM support is not available.
pub struct PredispatchQuery {
    region: Option<Region>,
}

impl PredispatchQuery {
    /// Filter results to a single NEM region.
    pub fn region(mut self, region: Region) -> Self {
        self.region = Some(region);
        self
    }

    /// Execute the query and return pre-dispatch forecast records.
    pub async fn fetch(&self) -> Result<Vec<PredispatchPrice>> {
        let http = build_client()?;
        let csv = client::fetch_realtime_csv(&http, client::RealtimeReport::Predispatch).await?;
        let mut records = parser::parse_predispatch_prices(&csv);
        if let Some(region) = self.region {
            records.retain(|r| r.region == region);
        }
        Ok(records)
    }
}

// SCADA query

/// Builder for unit SCADA queries.
///
/// Created by [`scada()`]. See that function's docs for full examples.
pub struct ScadaQuery {
    duid: Option<String>,
    from: Option<NaiveDate>,
    to: Option<NaiveDate>,
}

impl ScadaQuery {
    /// Filter to a specific generating unit by DUID (e.g. `"BASTYAN"`).
    pub fn duid(mut self, duid: &str) -> Self {
        self.duid = Some(duid.to_string());
        self
    }

    /// Start date for historical query (inclusive). Format: `"YYYY-MM-DD"`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn from(mut self, date: &str) -> Result<Self> {
        self.from = Some(parse_date(date)?);
        Ok(self)
    }

    /// End date for historical query (inclusive). Defaults to today.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDate`] if the format is not `YYYY-MM-DD`.
    pub fn to(mut self, date: &str) -> Result<Self> {
        self.to = Some(parse_date(date)?);
        Ok(self)
    }

    /// Execute the query and return unit SCADA records.
    pub async fn fetch(&self) -> Result<Vec<UnitScada>> {
        let http = build_client()?;
        let mut records = if let Some(from) = self.from {
            fetch_historical(
                &http,
                from,
                self.to.unwrap_or_else(today),
                client::MmsdmDataset::DispatchUnitScada,
                parser::parse_unit_scada,
                |r| r.settlement_date,
            )
            .await?
        } else {
            let csv = client::fetch_realtime_csv(&http, client::RealtimeReport::Scada).await?;
            parser::parse_unit_scada(&csv)
        };
        if let Some(duid) = &self.duid {
            records.retain(|r| r.duid == *duid);
        }
        Ok(records)
    }
}
