sota 0.9.1

API crate for Summits on the Air
Documentation
//! SOTA's raison d'ĂȘtre.

use std::{
    fmt::{self, Display, Formatter},
    result::Result,
    str::FromStr,
    sync::LazyLock,
};

use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, NoneAsEmptyString};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use time::OffsetDateTime;

use crate::{assoc::HasBounds, client::APIError, Callsign, Client, ParseError};

/// A summit registered by a SOTA member association.
#[serde_as]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Summit {
    /// Name of the summit.
    pub name: String,
    /// Association that registered the summit, e.g. "W6".
    pub association_code: String,
    /// Name of the association, e.g. "USA".
    pub association_name: String,
    /// Full code of the summit, e.g. "W6/CC-063".
    pub summit_code: String,
    /// Region code of the summit, e.g. "CC".
    pub region_code: String,
    /// Name of the region containing the summit, e.g. "Coastal Ranges".
    pub region_name: String,
    /// Altitude in meters.
    pub alt_m: u16,
    /// Altitude in feet.
    pub alt_ft: u16,
    /// Longitude. Further meaning unknown.
    pub grid_ref_1: String,
    /// Longitude.
    pub longitude: f32,
    /// Latitude. Further meaning unknown.
    pub grid_ref_2: String,
    /// Latitude.
    pub latitude: f32,
    /// Maidenhead, e.g. "CM87qw".
    pub locator: String,
    #[serde_as(as = "NoneAsEmptyString")]
    /// Notes about the summit.
    pub notes: Option<String>,
    /// When this summit's definition took effect.
    #[serde(with = "time::serde::rfc3339")]
    pub valid_from: OffsetDateTime,
    /// When this summit's definition lost effect.
    #[serde(with = "time::serde::rfc3339")]
    pub valid_to: OffsetDateTime,
    /// Number of times the summit has been activated.
    pub activation_count: Option<u32>,
    /// Number of times the summit has been activated by the authenticated user.
    pub my_activations: Option<u32>,
    /// Number of times the summit has been chased by the authenticated user.
    pub my_chases: Option<u32>,
    /// When the summit was last activated.
    #[serde(with = "time::serde::rfc3339::option")]
    pub activation_date: Option<OffsetDateTime>,
    /// The callsign of the last activator.
    pub activation_call: Option<Callsign>,
    /// Points earned for activating or chasing the summit.
    pub points: u8,
    /// Whether the summit is currently valid.
    pub valid: bool,
    /// Restrictions currently active on the summit.
    pub restriction_list: Option<Vec<SummitRestriction>>,
    /// (This field is undocumented upstream.)
    pub restriction_mask: bool,
}

/// An item that stores a full summit code.
pub trait HasSummit {
    /// Return the full summit code, e.g. "W6/CC-063".
    fn summit_code(&self) -> Result<SummitCode, ParseError>;
}

/// The code for a summit.
///
/// As with callsigns, this library [isn't in the place] of enforcing validity. For example, SOTA
/// alerts can contain [wildcard summit codes] such as `W4V/SH-???` ("somewhere in Shenandoah
/// Park") or `F/??-???` ("somewhere in France").
///
/// Therefore, [valid inputs](`VALID`) are a _superset_ of [canonical summit
/// codes](`STRICT`).
///
/// [`SummitCode::from_str`] still features mild error correction: letters are
/// converted to uppercase, and the summit number is zero-padded to three digits.
///
/// [isn't in the place]: https://en.wikipedia.org/wiki/Robustness_principle
/// [wildcard summit codes]: http://www.grizzlyguy.tv/RBNGate.htm#:~:text=using%20question%20marks
///
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub struct SummitCode {
    /// Alphanumeric string, e.g. "W6". Must not contain question marks.
    pub association: String,
    /// Alphabetic string, e.g. "CC". May contain question marks.
    pub region: String,
    /// Numeric string zero-padded to three digits, e.g. "063". May contain question marks.
    pub summit: String,
}

/// A valid summit code.
///
/// - The association contains letters or numbers,
/// - the region contains letters or question marks, and
/// - the summit contains numbers or question marks.
pub static VALID: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new("(?<asc>[A-Za-z0-9]+)/(?<rgn>[A-Za-z?]+)-(?<smt>[0-9?]+)").unwrap()
});

/// A summit code correctly identifying one summit.
///
/// - The association is an uppercase alphanumeric string 1-3 characters long,
/// - the region is an uppercase alphabetic string 2 characters long, and
/// - the summit is a numeric string 3 characters long.
pub static STRICT: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new("^(?<asc>[A-Z0-9]{1,3})/(?<rgn>[A-Z]{2})-(?<smt>[0-9]{3})$").unwrap()
});

impl SummitCode {
    /// See [`SummitCode`] for parsing format.
    fn parse(s: &str) -> Result<(String, String, String), ParseError> {
        let err = || ParseError("summit".into(), s.into());

        let captures = VALID.captures(s).ok_or_else(err)?;
        let get = |s: &str| {
            captures
                .name(s)
                .as_ref()
                .ok_or_else(err)
                .map(regex::Match::as_str)
                .map(From::from)
        };

        Ok((get("asc")?, get("rgn")?, get("smt")?))
    }

    /// Returns the summit's short code, e.g., "CC-063".
    pub fn short_code(&self) -> String {
        format!("{}-{}", self.region, self.summit)
    }

    /// Returns whether the summit code is canonical (see [`SummitCode`]).
    pub fn is_strictly_valid(&self) -> bool {
        STRICT.find(self.to_string().as_str()).is_some()
    }

    /// Returns whether the full summit code is defined (i.e., doesn't contain "?").
    pub fn is_known(&self) -> bool {
        self.has_known_summit() && self.has_known_region()
    }

    /// Returns whether the summit is defined (i.e., doesn't contain "?").
    pub fn has_known_summit(&self) -> bool {
        !self.summit.contains("?")
    }

    /// Returns whether the region is defined (i.e., doesn't contain "?").
    pub fn has_known_region(&self) -> bool {
        !self.region.contains("?")
    }

    /// Queries the API for the Maidenhead locator of this summit code (or ranges, if
    /// the code is partially-defined).
    ///
    /// ```no_run
    /// # use std::str::FromStr;
    /// # use sota::{summit::SummitCode, Client};
    /// # #[tokio::main]
    /// # async fn main (){
    /// # let user_agent = "do fill this in with something useful";
    /// # let client = Client::new(user_agent);
    /// assert_eq!(
    ///     SummitCode::from_str("W6/CC-063").unwrap().maidenhead(&client)
    ///       .await.unwrap(),
    ///     "CM87qw"
    /// );
    /// assert_eq!(
    ///     SummitCode::from_str("W6/CC-???").unwrap().maidenhead(&client)
    ///       .await.unwrap(),
    ///     "Region covers CM72-DN11"
    /// );
    /// assert_eq!(
    ///     SummitCode::from_str("W6/??-???").unwrap().maidenhead(&client)
    ///         .await.unwrap(),
    ///     "Association covers CM72-DN22"
    /// );
    /// # }
    /// ```
    pub async fn maidenhead(&self, sota: &Client) -> Result<String, APIError> {
        if self.is_known() {
            return Ok(sota.summit(self).await?.locator);
        };

        let (sw, ne) = match self.has_known_region() {
            true => sota
                .region(&self.association, &self.region)
                .await?
                .maidenhead(4),
            false => sota.association(&self.association).await?.maidenhead(4),
        }?;

        let unit = match self.has_known_region() {
            true => "Region",
            false => "Association",
        };

        Ok(match sw != ne {
            true => format!("{unit} covers {sw}-{ne}"),
            false => format!("{unit} is in {sw}"),
        })
    }
}

impl Display for SummitCode {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{}-{}", self.association, self.region, self.summit)
    }
}

impl FromStr for SummitCode {
    type Err = ParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self::parse(&s.to_uppercase())?.into())
    }
}

impl From<(String, String, String)> for SummitCode {
    fn from(value: (String, String, String)) -> Self {
        Self {
            association: value.0,
            region: value.1,
            summit: match value.2.parse::<u8>() {
                // The only values needing zero-padding, 0-99, fit into a `u8`.
                Ok(n) => format!("{n:03}"),
                Err(_) => value.2,
            },
        }
    }
}

#[allow(missing_docs)] // Undocumented upstream.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct SummitRestriction {
    pub code: u16,
    pub r#type: String,
}

impl HasSummit for Summit {
    fn summit_code(&self) -> Result<SummitCode, ParseError> {
        self.summit_code.parse()
    }
}

#[cfg(test)]
mod test {
    use super::*;
    use httpmock::MockServer;

    fn convert<'a>(v: &[&'a str]) -> impl Iterator<Item = SummitCode> + use<'a> {
        v.to_owned()
            .into_iter()
            .map(SummitCode::from_str)
            .map(Result::unwrap)
    }

    const CANONICAL_SUMMITS: &[&str] = &["W6/CC-300", "PY2/SE-011"];
    const NON_CANONICAL_SUMMITS: &[&str] = &["W6/CC-99999", "AAAAAAAA/AA-11111"];
    const UNKNOWN_SUMMITS: &[&str] = &["W6/CC-?", "PY2/SE-???", "AAAAAAAA/AA-11?11"];
    const UNKNOWN_REGIONS: &[&str] = &["W6/?-?", "PY2/??-???", "W6/???-???", "AAAAAAAA/A?-1????"];
    const INVALID_CODES: &[&str] = &["?/?-?", "W6/66-666", "W6/NC-XXX", "???", "AAAAAAAAAA"];

    #[test]
    fn test_summit_codes() {
        convert(CANONICAL_SUMMITS).for_each(|sc| {
            assert!(sc.is_strictly_valid());
            assert!(sc.has_known_region());
            assert!(sc.has_known_summit())
        });
        convert(NON_CANONICAL_SUMMITS).for_each(|sc| {
            assert!(!sc.is_strictly_valid());
            assert!(sc.has_known_region());
            assert!(sc.has_known_summit())
        });
        convert(UNKNOWN_SUMMITS).for_each(|sc| {
            assert!(!sc.is_strictly_valid());
            assert!(sc.has_known_region());
            assert!(!sc.has_known_summit())
        });
        convert(UNKNOWN_REGIONS).for_each(|sc| {
            assert!(!sc.is_strictly_valid());
            assert!(!sc.has_known_region());
            assert!(!sc.has_known_summit())
        });

        assert_eq!(
            SummitCode::from_str("w6/cc-1").unwrap(),
            SummitCode::from_str("W6/CC-001").unwrap(),
        );
        assert_eq!(
            SummitCode::from_str("W6/CC-00000000000001").unwrap(),
            SummitCode::from_str("W6/CC-001").unwrap(),
        );

        assert!(INVALID_CODES
            .to_owned()
            .into_iter()
            .map(SummitCode::from_str)
            .all(|result| result.is_err()));
    }

    async fn assert_maidenhead(client: &Client, case: &str, expected: &str) {
        assert_eq!(
            SummitCode::from_str(case)
                .unwrap()
                .maidenhead(&client)
                .await
                .unwrap(),
            expected
        );
    }

    #[tokio::test]
    async fn test_summit_code_maidenhead() {
        let server = MockServer::start();
        let c = Client::new_with_base(&server.base_url(), "");

        let summit = server.mock(|when, then| {
            when.path("/summits/W6/CC-063");
            then.status(200)
                .header("content-type", "application/json")
                .body(include_str!("client/test/summit.json"));
        });
        let region = server.mock(|when, then| {
            when.path("/regions/W6/CC");
            then.status(200)
                .header("content-type", "application/json")
                .body(include_str!("client/test/region.json"));
        });
        let assoc = server.mock(|when, then| {
            when.path("/associations/W6");
            then.status(200)
                .header("content-type", "application/json")
                .body(include_str!("client/test/assoc.json"));
        });

        assert_maidenhead(&c, "W6/CC-063", "CM87qw").await;
        summit.assert();
        assert_maidenhead(&c, "W6/CC-???", "Region covers CM72-DN11").await;
        region.assert();
        assert_maidenhead(&c, "W6/??-???", "Association covers CM72-DN22").await;
        assoc.assert();
    }
}