/*
SPDX-License-Identifier: MIT OR Apache-2.0
SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
*/

//! Specialized work types: classic works, patents, datasets, standards, software, and events.

use super::common::{
    FieldLanguageMap, HasNumbering, LangID, NormalizeNumbering, Numbering, NumberingType,
    Publisher, RefID, RichText, Title,
};
use crate::reference::WorkRelation;
use crate::reference::contributor::{Contributor, ContributorEntry};
use crate::reference::date::EdtfString;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[cfg(feature = "bindings")]
use specta::Type;
use std::collections::HashMap;
use url::Url;

/// Event metadata for conferences, performances, broadcasts, and recordings.
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
pub struct Event {
    /// Unique identifier for this reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<RefID>,
    /// Event name (e.g., conference title, performance name).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<Title>,
    /// Immediate container (e.g., broadcast program, proceedings volume).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub container: Option<WorkRelation>,
    /// Recurring event series (e.g., annual conference series).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub series: Option<WorkRelation>,
    /// Original work or publication from which this event derives.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original: Option<WorkRelation>,
    /// Event location (city, venue).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub location: Option<String>,
    /// Event date.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub date: Option<EdtfString>,
    /// Date the event became or will become publicly available.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub available_date: Option<EdtfString>,
    /// Event genre (e.g., `"conference"`, `"performance"`, `"broadcast"`, `"talk"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub genre: Option<String>,
    /// Broadcaster, network, or streaming platform.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub network: Option<String>,
    /// Unified contributor list with explicit role tags.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub contributors: Vec<ContributorEntry>,
    /// URL for the event.
    #[serde(alias = "URL", skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    /// Date the URL was accessed.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accessed: Option<EdtfString>,
    /// BCP 47 language of the event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<LangID>,
    /// Per-field language overrides.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub field_languages: FieldLanguageMap,
    /// Freeform note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<RichText>,
    /// Forward-compat: captures unknown keys when an older engine reads a
    /// reference produced by a newer schema. Empty by default; treated as a
    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
    #[serde(
        flatten,
        default,
        skip_serializing_if = "std::collections::BTreeMap::is_empty"
    )]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

/// A classic work (Aristotle, Bible, etc.) with standard citation forms.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(from = "ClassicDeser", rename_all = "kebab-case")]
pub struct Classic {
    /// Unique identifier for this reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<RefID>,
    /// Work title (e.g., "Nicomachean Ethics")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<Title>,
    /// The primary container for this work.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub container: Option<WorkRelation>,
    /// Original work or publication from which this edition derives.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original: Option<WorkRelation>,
    /// Author (e.g., "Aristotle")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<Contributor>,
    /// Editor of this edition
    #[serde(skip_serializing_if = "Option::is_none")]
    pub editor: Option<Contributor>,
    /// Translator of this edition
    #[serde(skip_serializing_if = "Option::is_none")]
    pub translator: Option<Contributor>,
    /// Volume number (shorthand for numbering).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub volume: Option<String>,
    /// Issue number (shorthand for numbering).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issue: Option<String>,
    /// Edition (shorthand for numbering).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub edition: Option<String>,
    /// Generic document number (shorthand for numbering).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub number: Option<String>,
    /// Part number (shorthand for numbering).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub part_number: Option<String>,
    /// Supplement number (shorthand for numbering).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub supplement_number: Option<String>,
    /// Printing number (shorthand for numbering).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub printing_number: Option<String>,
    /// Numbering identifiers (e.g., volume, section, number, chapter in standard reference system).
    /// Flat shorthand fields are accepted on input for authoring ergonomics and normalized
    /// into canonical `numbering` entries during deserialization.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub numbering: Vec<Numbering>,
    /// Creation or origination date of the work.
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub created: EdtfString,
    /// Publication date of this edition (not original)
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub issued: EdtfString,
    /// Publisher of this edition
    #[serde(skip_serializing_if = "Option::is_none")]
    pub publisher: Option<Publisher>,
    /// URL for the work.
    #[serde(alias = "URL", skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    /// Date the URL was accessed.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accessed: Option<EdtfString>,
    /// BCP 47 language of the work.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<LangID>,
    /// Per-field language overrides.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub field_languages: FieldLanguageMap,
    /// Freeform note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<RichText>,
    /// Keywords or subject tags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<Vec<String>>,
    /// Forward-compat: captures unknown keys when an older engine reads a
    /// reference produced by a newer schema. Empty by default; treated as a
    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
    #[serde(
        flatten,
        default,
        skip_serializing_if = "std::collections::BTreeMap::is_empty"
    )]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
struct ClassicDeser {
    id: Option<RefID>,
    title: Option<Title>,
    container: Option<WorkRelation>,
    original: Option<WorkRelation>,
    author: Option<Contributor>,
    editor: Option<Contributor>,
    translator: Option<Contributor>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    volume: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    issue: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    edition: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    number: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    part_number: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    supplement_number: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    printing_number: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    numbering: Vec<Numbering>,
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default)]
    created: EdtfString,
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default)]
    issued: EdtfString,
    publisher: Option<Publisher>,
    #[serde(alias = "URL")]
    url: Option<Url>,
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    accessed: Option<EdtfString>,
    language: Option<LangID>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    field_languages: FieldLanguageMap,
    note: Option<RichText>,
    keywords: Option<Vec<String>>,
    #[serde(flatten, default)]
    unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

impl From<ClassicDeser> for Classic {
    fn from(raw: ClassicDeser) -> Self {
        let mut classic = Self {
            id: raw.id,
            title: raw.title,
            container: raw.container,
            original: raw.original,
            author: raw.author,
            editor: raw.editor,
            translator: raw.translator,
            volume: raw.volume,
            issue: raw.issue,
            edition: raw.edition,
            number: raw.number,
            part_number: raw.part_number,
            supplement_number: raw.supplement_number,
            printing_number: raw.printing_number,
            numbering: raw.numbering,
            created: raw.created,
            issued: raw.issued,
            publisher: raw.publisher,
            url: raw.url,
            accessed: raw.accessed,
            language: raw.language,
            field_languages: raw.field_languages,
            note: raw.note,
            keywords: raw.keywords,
            unknown_fields: raw.unknown_fields,
        };
        classic.normalize_numbering();
        classic
    }
}

impl HasNumbering for Classic {
    fn numbering(&self) -> &[Numbering] {
        &self.numbering
    }
}

impl NormalizeNumbering for Classic {
    fn numbering_mut(&mut self) -> &mut Vec<Numbering> {
        &mut self.numbering
    }

    fn volume_mut(&mut self) -> &mut Option<String> {
        &mut self.volume
    }

    fn issue_mut(&mut self) -> &mut Option<String> {
        &mut self.issue
    }

    fn edition_mut(&mut self) -> &mut Option<String> {
        &mut self.edition
    }

    fn number_mut(&mut self) -> &mut Option<String> {
        &mut self.number
    }

    fn part_number_mut(&mut self) -> &mut Option<String> {
        &mut self.part_number
    }

    fn supplement_number_mut(&mut self) -> &mut Option<String> {
        &mut self.supplement_number
    }

    fn printing_number_mut(&mut self) -> &mut Option<String> {
        &mut self.printing_number
    }
}

/// A patent.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
pub struct Patent {
    /// Unique identifier for this reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<RefID>,
    /// Patent title
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<Title>,
    /// Inventor(s)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<Contributor>,
    /// Assignee (patent holder)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub assignee: Option<Contributor>,
    /// Original work or publication from which this patent derives.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original: Option<WorkRelation>,
    /// Patent number (e.g., "U.S. Patent No. 7,347,809")
    pub patent_number: String,
    /// Application number
    #[serde(skip_serializing_if = "Option::is_none")]
    pub application_number: Option<String>,
    /// Creation or origination date of the patented work.
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub created: EdtfString,
    /// Filing date
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filing_date: Option<EdtfString>,
    /// Issue/grant date
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub issued: EdtfString,
    /// Jurisdiction (e.g., "US", "EP", "JP")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jurisdiction: Option<String>,
    /// Patent office (e.g., "U.S. Patent and Trademark Office")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub authority: Option<String>,
    /// URL for the patent.
    #[serde(alias = "URL", skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    /// Date the URL was accessed.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accessed: Option<EdtfString>,
    /// BCP 47 language of the document.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<LangID>,
    /// Per-field language overrides.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub field_languages: FieldLanguageMap,
    /// Freeform note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<RichText>,
    /// Keywords or subject tags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<Vec<String>>,
    /// Forward-compat: captures unknown keys when an older engine reads a
    /// reference produced by a newer schema. Empty by default; treated as a
    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
    #[serde(
        flatten,
        default,
        skip_serializing_if = "std::collections::BTreeMap::is_empty"
    )]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

/// A research dataset.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
pub struct Dataset {
    /// Unique identifier for this reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<RefID>,
    /// Dataset title
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<Title>,
    /// Dataset author(s)/creator(s)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<Contributor>,
    /// Original work or publication from which this dataset derives.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original: Option<WorkRelation>,
    /// Creation or origination date of the dataset.
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub created: EdtfString,
    /// Publication/release date
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub issued: EdtfString,
    /// Publisher or repository (e.g., "Zenodo", "Dryad")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub publisher: Option<Publisher>,
    /// Version number
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// File format. Prefer IANA media types (e.g., `"text/csv"`) or common
    /// abbreviations (e.g., `"NetCDF"`, `"HDF5"`) where no IANA type exists.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
    /// Dataset size (e.g., "2.4 GB", "150,000 records")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size: Option<String>,
    /// Repository or archive name
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repository: Option<String>,
    /// DOI identifier.
    #[serde(alias = "DOI", skip_serializing_if = "Option::is_none")]
    pub doi: Option<String>,
    /// URL for the dataset.
    #[serde(alias = "URL", skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    /// Date the URL was accessed.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accessed: Option<EdtfString>,
    /// BCP 47 language of the dataset.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<LangID>,
    /// Per-field language overrides.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub field_languages: FieldLanguageMap,
    /// Freeform note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<RichText>,
    /// Keywords or subject tags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<Vec<String>>,
    /// Forward-compat: captures unknown keys when an older engine reads a
    /// reference produced by a newer schema. Empty by default; treated as a
    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
    #[serde(
        flatten,
        default,
        skip_serializing_if = "std::collections::BTreeMap::is_empty"
    )]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

/// A technical standard or specification.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
pub struct Standard {
    /// Unique identifier for this reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<RefID>,
    /// Standard title
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<Title>,
    /// Original work or publication from which this standard derives.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original: Option<WorkRelation>,
    /// Standards organization (e.g., "ISO", "ANSI", "IEEE")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub authority: Option<String>,
    /// Standard number (e.g., "ISO 8601", "IEEE 754-2008")
    pub standard_number: String,
    /// Creation or origination date of the standard.
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub created: EdtfString,
    /// Publication date
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub issued: EdtfString,
    /// Publication status. Canonical controlled-vocabulary values: `"published"`, `"draft"`, `"withdrawn"`.
    /// See `docs/policies/ENUM_VOCABULARY_POLICY.md` for matching rules.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// Publisher (usually same as authority)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub publisher: Option<Publisher>,
    /// URL for the standard.
    #[serde(alias = "URL", skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    /// Date the URL was accessed.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accessed: Option<EdtfString>,
    /// BCP 47 language of the document.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<LangID>,
    /// Per-field language overrides.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub field_languages: FieldLanguageMap,
    /// Freeform note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<RichText>,
    /// Keywords or subject tags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<Vec<String>>,
    /// Forward-compat: captures unknown keys when an older engine reads a
    /// reference produced by a newer schema. Empty by default; treated as a
    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
    #[serde(
        flatten,
        default,
        skip_serializing_if = "std::collections::BTreeMap::is_empty"
    )]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

/// Software or source code.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
pub struct Software {
    /// Unique identifier for this reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<RefID>,
    /// Software title
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<Title>,
    /// Original work or publication from which this software derives.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original: Option<WorkRelation>,
    /// Author(s)/developer(s)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<Contributor>,
    /// Creation or origination date of the software work.
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub created: EdtfString,
    /// Release date
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub issued: EdtfString,
    /// Publisher or repository (e.g., "GitHub", "Zenodo")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub publisher: Option<Publisher>,
    /// Version number (e.g., "4.1.0", "v2.3.1")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// Repository URL
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repository: Option<String>,
    /// SPDX license identifier preferred (e.g., `"MIT"`, `"GPL-3.0-only"`, `"Apache-2.0"`).
    /// See <https://spdx.org/licenses/> for the authoritative identifier list.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    /// Platform (e.g., "Windows", "macOS", "Linux", "cross-platform")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub platform: Option<String>,
    /// DOI identifier.
    #[serde(alias = "DOI", skip_serializing_if = "Option::is_none")]
    pub doi: Option<String>,
    /// URL for the software.
    #[serde(alias = "URL", skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    /// Date the URL was accessed.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accessed: Option<EdtfString>,
    /// BCP 47 language of the software documentation.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<LangID>,
    /// Per-field language overrides.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub field_languages: FieldLanguageMap,
    /// Freeform note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<RichText>,
    /// Keywords or subject tags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<Vec<String>>,
    /// Forward-compat: captures unknown keys when an older engine reads a
    /// reference produced by a newer schema. Empty by default; treated as a
    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
    #[serde(
        flatten,
        default,
        skip_serializing_if = "std::collections::BTreeMap::is_empty"
    )]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

/// Shared fields for all work-level reference classes.
///
/// Composed into `AudioVisualWork` and future work-level classes.
/// Always embedded via `#[serde(flatten)]`, never used directly as a variant.
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
pub struct WorkCore {
    /// Title of the work.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<Title>,
    /// Optional short form of the title.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub short_title: Option<String>,
    /// Unified contributor list with explicit role tags.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub contributors: Vec<ContributorEntry>,
    /// Creation or origination date of the work.
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub created: EdtfString,
    /// Publication or release date of the work.
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default, skip_serializing_if = "EdtfString::is_empty")]
    pub issued: EdtfString,
    /// Original work or publication from which this work derives.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub original: Option<WorkRelation>,
    /// BCP 47 language of the work.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<LangID>,
    /// Abstract genre (e.g., `"feature-film"`, `"documentary"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub genre: Option<String>,
}

/// Discriminates audio-visual subtypes for style-directed formatting.
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum AudioVisualType {
    /// A film or motion picture (default).
    #[default]
    Film,
    /// A single episode of a TV series, podcast, or web series.
    Episode,
    /// A recording (music video, concert, etc.).
    Recording,
    /// A broadcast program.
    Broadcast,
}

/// An audio-visual work: film, TV episode, recording, or broadcast.
///
/// This is a work-like reference class. It represents the creative work
/// when that work is the citee, not a fully modeled release hierarchy.
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(from = "AudioVisualDeser", rename_all = "kebab-case")]
pub struct AudioVisualWork {
    /// Unique identifier for this reference.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<RefID>,
    /// Subtype for style-directed rendering.
    pub r#type: AudioVisualType,
    /// Shared work-level fields (title, contributors, issued, language, genre).
    #[serde(flatten)]
    pub core: WorkCore,
    /// Container series or program (work-to-work relation).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub container: Option<WorkRelation>,
    /// Season and episode numbering.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub numbering: Vec<Numbering>,
    /// Production company, studio, or network.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub publisher: Option<Publisher>,
    /// Physical delivery format or presentation descriptor.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub medium: Option<String>,
    /// Digital distribution host or streaming service.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub platform: Option<String>,
    /// URL or streaming location.
    #[serde(alias = "URL", skip_serializing_if = "Option::is_none")]
    pub url: Option<Url>,
    /// Date accessed.
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accessed: Option<EdtfString>,
    /// Per-field language overrides.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub field_languages: FieldLanguageMap,
    /// Freeform note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<RichText>,
    /// Forward-compat: captures unknown keys when an older engine reads a
    /// reference produced by a newer schema. Empty by default; treated as a
    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
    #[serde(
        flatten,
        default,
        skip_serializing_if = "std::collections::BTreeMap::is_empty"
    )]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

/// Deserialization shim for `AudioVisualWork`.
///
/// Accepts all `WorkCore` fields inline (flattened) plus `AudioVisualWork`-specific fields.
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "bindings", derive(Type))]
#[serde(rename_all = "kebab-case")]
struct AudioVisualDeser {
    id: Option<RefID>,
    #[serde(default)]
    r#type: AudioVisualType,
    // WorkCore fields (flattened in canonical but explicit here for deser)
    title: Option<Title>,
    short_title: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    contributors: Vec<ContributorEntry>,
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default)]
    created: EdtfString,
    #[cfg_attr(feature = "bindings", specta(type = String))]
    #[serde(default)]
    issued: EdtfString,
    original: Option<WorkRelation>,
    language: Option<LangID>,
    genre: Option<String>,
    // AudioVisualWork-specific fields
    container: Option<WorkRelation>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    numbering: Vec<Numbering>,
    /// Catalog or episode number shorthand (normalized into `numbering`).
    #[serde(skip_serializing_if = "Option::is_none")]
    number: Option<String>,
    publisher: Option<Publisher>,
    medium: Option<String>,
    platform: Option<String>,
    #[serde(alias = "URL")]
    url: Option<Url>,
    #[cfg_attr(feature = "bindings", specta(type = Option<String>))]
    accessed: Option<EdtfString>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    field_languages: FieldLanguageMap,
    note: Option<RichText>,
    #[serde(flatten, default)]
    unknown_fields: std::collections::BTreeMap<String, serde_json::Value>,
}

impl HasNumbering for AudioVisualWork {
    fn numbering(&self) -> &[Numbering] {
        &self.numbering
    }
}

impl From<AudioVisualDeser> for AudioVisualWork {
    fn from(raw: AudioVisualDeser) -> Self {
        let mut numbering = raw.numbering;
        if let Some(n) = raw.number
            && !numbering.iter().any(|e| e.r#type == NumberingType::Number)
        {
            numbering.push(Numbering {
                r#type: NumberingType::Number,
                value: n,
            });
        }
        Self {
            id: raw.id,
            r#type: raw.r#type,
            core: WorkCore {
                title: raw.title,
                short_title: raw.short_title,
                contributors: raw.contributors,
                created: raw.created,
                issued: raw.issued,
                original: raw.original,
                language: raw.language,
                genre: raw.genre,
            },
            container: raw.container,
            numbering,
            publisher: raw.publisher,
            medium: raw.medium,
            platform: raw.platform,
            url: raw.url,
            accessed: raw.accessed,
            field_languages: raw.field_languages,
            note: raw.note,
            unknown_fields: raw.unknown_fields,
        }
    }
}
