//! Embedded audio metadata tags — FFmpeg / Vorbis-Comment / iTunes
//! atom-style key-value side data (artist, album, year, genre, …).

use smol_str::SmolStr;

use crate::lang::Language;

/// Embedded media-metadata tags carried alongside an audio stream.
///
/// Read from FFmpeg `AVFormatContext.metadata` /
/// `AVStream.metadata` / Vorbis Comments / ID3v2 frames / MP4 `udta`
/// atoms (`©nam`, `©ART`, `©alb`, `aART`, `trkn`, `disk`, …) /
/// FLAC tags. Field names mirror the FFmpeg metadata-key convention
/// (lowercase ASCII).
///
/// **Absent-vs-present convention** — every field carries its own
/// "absent" sentinel; there is no per-field `Option` wrapper except for
/// `language` (which has no natural zero value):
/// - **String** fields use `SmolStr`; the empty string `""` means absent.
/// - **Numeric** fields use `u16`; `0` means absent. Track / disc numbers
///   are 1-based and a release year is never `0`, so `0` is unambiguous —
///   and it matches the proto3 zero-elision the buffa wire codec applies
///   to these fields (a `0` is not written and decodes back to `0`).
/// - **`language`** is `Option<Language>`: `None` = no language tag,
///   `Some(Language)` = a parsed BCP-47 tag (which may itself be the
///   `und` "undetermined" value — distinct from "tag absent").
///
/// **`language` normalization** — [`Language`] models the BCP-47
/// *language / script / region* subtags only; a tag carrying a *variant*
/// (`de-CH-1901`) or *extension* (`-u-…`) is accepted but normalized to
/// its language/script/region core, and an unparseable wire value decodes
/// to `und`. Embedded-metadata language tags are language/script/region in
/// practice, so this is lossless for real input; callers needing the
/// verbatim tag should not route it through `Tags`.
// `serde(default)` keeps sparse / older-schema JSON deserializable: missing
// fields fall back to the type-level `Default` impl (`Tags::new()` — all
// fields absent), matching the absent-vs-present convention above.
#[cfg_attr(
  feature = "serde",
  derive(serde::Serialize, serde::Deserialize),
  serde(default)
)]
#[cfg_attr(
  feature = "quickcheck",
  derive(::quickcheck_richderive::Arbitrary),
  quickcheck(arbitrary = "crate::quickcheck_helpers::composite::tags")
)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Tags {
  title: SmolStr,
  artist: SmolStr,
  album_artist: SmolStr,
  album: SmolStr,
  composer: SmolStr,
  genre: SmolStr,
  comment: SmolStr,
  /// Release year; `0` means absent.
  year: u16,
  /// 1-based track number; `0` means absent.
  track_number: u16,
  /// Total tracks on the release; `0` means absent.
  track_total: u16,
  /// 1-based disc number; `0` means absent.
  disc_number: u16,
  /// Total discs in the release; `0` means absent.
  disc_total: u16,
  /// Parsed BCP-47 language tag; `None` means no language tag present.
  // golden-rule §9: an `Option` serde field skip-serializes when `None` —
  // absent language is an omitted key, never `"language":null`. The
  // container `serde(default)` restores it on the way back.
  #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
  language: Option<Language>,
}

impl Default for Tags {
  /// Delegates to [`Tags::new`] — every field absent.
  #[cfg_attr(not(tarpaulin), inline(always))]
  fn default() -> Self {
    Self::new()
  }
}

impl Tags {
  /// Constructs a fresh `Tags` with every field absent.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn new() -> Self {
    Self {
      title: SmolStr::new_inline(""),
      artist: SmolStr::new_inline(""),
      album_artist: SmolStr::new_inline(""),
      album: SmolStr::new_inline(""),
      composer: SmolStr::new_inline(""),
      genre: SmolStr::new_inline(""),
      comment: SmolStr::new_inline(""),
      year: 0,
      track_number: 0,
      track_total: 0,
      disc_number: 0,
      disc_total: 0,
      language: None,
    }
  }

  /// Track title (`""` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn title(&self) -> &str {
    self.title.as_str()
  }
  /// Track artist (`""` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn artist(&self) -> &str {
    self.artist.as_str()
  }
  /// Album artist — distinct from per-track `artist` for
  /// compilations / split-credit releases (`""` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn album_artist(&self) -> &str {
    self.album_artist.as_str()
  }
  /// Album title (`""` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn album(&self) -> &str {
    self.album.as_str()
  }
  /// Composer (`""` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn composer(&self) -> &str {
    self.composer.as_str()
  }
  /// Genre (`""` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn genre(&self) -> &str {
    self.genre.as_str()
  }
  /// Free-form comment (`""` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn comment(&self) -> &str {
    self.comment.as_str()
  }
  /// Release year (`0` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn year(&self) -> u16 {
    self.year
  }
  /// 1-based track number (`0` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn track_number(&self) -> u16 {
    self.track_number
  }
  /// Total number of tracks on the release (`0` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn track_total(&self) -> u16 {
    self.track_total
  }
  /// 1-based disc number (`0` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn disc_number(&self) -> u16 {
    self.disc_number
  }
  /// Total number of discs in the release (`0` if absent).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn disc_total(&self) -> u16 {
    self.disc_total
  }
  /// Parsed BCP-47 language tag (`None` if no language tag is present).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn language(&self) -> Option<Language> {
    self.language
  }

  /// Sets the title (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn with_title(mut self, v: impl Into<SmolStr>) -> Self {
    self.title = v.into();
    self
  }
  /// Sets the artist (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn with_artist(mut self, v: impl Into<SmolStr>) -> Self {
    self.artist = v.into();
    self
  }
  /// Sets the album artist (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn with_album_artist(mut self, v: impl Into<SmolStr>) -> Self {
    self.album_artist = v.into();
    self
  }
  /// Sets the album (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn with_album(mut self, v: impl Into<SmolStr>) -> Self {
    self.album = v.into();
    self
  }
  /// Sets the composer (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn with_composer(mut self, v: impl Into<SmolStr>) -> Self {
    self.composer = v.into();
    self
  }
  /// Sets the genre (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn with_genre(mut self, v: impl Into<SmolStr>) -> Self {
    self.genre = v.into();
    self
  }
  /// Sets the comment (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn with_comment(mut self, v: impl Into<SmolStr>) -> Self {
    self.comment = v.into();
    self
  }
  /// Sets the release year (consuming builder); `0` clears it.
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn with_year(mut self, v: u16) -> Self {
    self.year = v;
    self
  }
  /// Sets the track number (consuming builder); `0` clears it.
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn with_track_number(mut self, v: u16) -> Self {
    self.track_number = v;
    self
  }
  /// Sets the track total (consuming builder); `0` clears it.
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn with_track_total(mut self, v: u16) -> Self {
    self.track_total = v;
    self
  }
  /// Sets the disc number (consuming builder); `0` clears it.
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn with_disc_number(mut self, v: u16) -> Self {
    self.disc_number = v;
    self
  }
  /// Sets the disc total (consuming builder); `0` clears it.
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn with_disc_total(mut self, v: u16) -> Self {
    self.disc_total = v;
    self
  }
  /// Sets the language tag to `Some(v)` (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn with_language(mut self, v: Language) -> Self {
    self.language = Some(v);
    self
  }
  /// Assigns the raw language wrapper (consuming builder).
  #[must_use]
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn maybe_language(mut self, v: Option<Language>) -> Self {
    self.language = v;
    self
  }

  /// Sets the title in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn set_title(&mut self, v: impl Into<SmolStr>) -> &mut Self {
    self.title = v.into();
    self
  }
  /// Sets the artist in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn set_artist(&mut self, v: impl Into<SmolStr>) -> &mut Self {
    self.artist = v.into();
    self
  }
  /// Sets the album artist in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn set_album_artist(&mut self, v: impl Into<SmolStr>) -> &mut Self {
    self.album_artist = v.into();
    self
  }
  /// Sets the album in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn set_album(&mut self, v: impl Into<SmolStr>) -> &mut Self {
    self.album = v.into();
    self
  }
  /// Sets the composer in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn set_composer(&mut self, v: impl Into<SmolStr>) -> &mut Self {
    self.composer = v.into();
    self
  }
  /// Sets the genre in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn set_genre(&mut self, v: impl Into<SmolStr>) -> &mut Self {
    self.genre = v.into();
    self
  }
  /// Sets the comment in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub fn set_comment(&mut self, v: impl Into<SmolStr>) -> &mut Self {
    self.comment = v.into();
    self
  }
  /// Sets the release year in place; `0` clears it.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn set_year(&mut self, v: u16) -> &mut Self {
    self.year = v;
    self
  }
  /// Sets the track number in place; `0` clears it.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn set_track_number(&mut self, v: u16) -> &mut Self {
    self.track_number = v;
    self
  }
  /// Sets the track total in place; `0` clears it.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn set_track_total(&mut self, v: u16) -> &mut Self {
    self.track_total = v;
    self
  }
  /// Sets the disc number in place; `0` clears it.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn set_disc_number(&mut self, v: u16) -> &mut Self {
    self.disc_number = v;
    self
  }
  /// Sets the disc total in place; `0` clears it.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn set_disc_total(&mut self, v: u16) -> &mut Self {
    self.disc_total = v;
    self
  }
  /// Sets the language tag to `Some(v)` in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn set_language(&mut self, v: Language) -> &mut Self {
    self.language = Some(v);
    self
  }
  /// Assigns the raw language wrapper in place.
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn update_language(&mut self, v: Option<Language>) -> &mut Self {
    self.language = v;
    self
  }
  /// Clears the language tag (`None`).
  #[cfg_attr(not(tarpaulin), inline(always))]
  pub const fn clear_language(&mut self) -> &mut Self {
    self.language = None;
    self
  }
}

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

  fn en_us() -> Language {
    Language::from_bcp47("en-US").unwrap()
  }

  #[test]
  fn default_is_all_absent() {
    let t = Tags::default();
    assert_eq!(t.title(), "");
    assert_eq!(t.artist(), "");
    assert_eq!(t.album_artist(), "");
    assert_eq!(t.album(), "");
    assert_eq!(t.composer(), "");
    assert_eq!(t.genre(), "");
    assert_eq!(t.comment(), "");
    assert_eq!(t.year(), 0);
    assert_eq!(t.track_number(), 0);
    assert_eq!(t.track_total(), 0);
    assert_eq!(t.disc_number(), 0);
    assert_eq!(t.disc_total(), 0);
    assert_eq!(t.language(), None);
  }

  #[test]
  fn new_matches_default() {
    assert_eq!(Tags::new(), Tags::default());
  }

  #[test]
  fn with_builders_roundtrip_every_field() {
    let t = Tags::new()
      .with_title("My Track")
      .with_artist("Artist X")
      .with_album_artist("Various Artists")
      .with_album("Best Album")
      .with_composer("Composer Y")
      .with_genre("Electronic")
      .with_comment("ripped 2026")
      .with_year(2026)
      .with_track_number(3)
      .with_track_total(12)
      .with_disc_number(1)
      .with_disc_total(2)
      .with_language(en_us());
    assert_eq!(t.title(), "My Track");
    assert_eq!(t.artist(), "Artist X");
    assert_eq!(t.album_artist(), "Various Artists");
    assert_eq!(t.album(), "Best Album");
    assert_eq!(t.composer(), "Composer Y");
    assert_eq!(t.genre(), "Electronic");
    assert_eq!(t.comment(), "ripped 2026");
    assert_eq!(t.year(), 2026);
    assert_eq!(t.track_number(), 3);
    assert_eq!(t.track_total(), 12);
    assert_eq!(t.disc_number(), 1);
    assert_eq!(t.disc_total(), 2);
    assert_eq!(t.language(), Some(en_us()));
  }

  #[test]
  fn setters_mutate_in_place() {
    let mut t = Tags::new();
    t.set_title("Foo").set_artist("Bar").set_year(1999);
    assert_eq!(t.title(), "Foo");
    assert_eq!(t.artist(), "Bar");
    assert_eq!(t.year(), 1999);
  }

  #[test]
  fn numeric_zero_is_the_absent_sentinel() {
    // `0` is "absent" — setting then zeroing is equivalent to never set.
    let mut t = Tags::new().with_year(2026).with_track_number(3);
    assert_eq!(t.year(), 2026);
    t.set_year(0).set_track_number(0);
    assert_eq!(t.year(), 0);
    assert_eq!(t.track_number(), 0);
    assert_eq!(t, Tags::new());
  }

  #[test]
  fn language_vocabulary_covers_set_update_clear() {
    let mut t = Tags::new();
    t.set_language(en_us());
    assert_eq!(t.language(), Some(en_us()));
    let fr = Language::from_bcp47("fr-FR").unwrap();
    t.update_language(Some(fr));
    assert_eq!(t.language(), Some(fr));
    t.clear_language();
    assert_eq!(t.language(), None);
    let t = Tags::new().with_language(en_us()).maybe_language(None);
    assert_eq!(t.language(), None);
  }
}
