///|
/// An author with display name and optional email address.
pub struct Author {
  name : String
  email : String?
} derive(Debug, Eq, Compare)

///|
pub fn Author::new(name : String, email? : String) -> Author {
  { name, email }
}

///|
/// Return the author display name.
pub fn Author::name(self : Author) -> String {
  self.name
}

///|
/// Return the author email address.
pub fn Author::email(self : Author) -> String? {
  self.email
}

///|
/// A category with an optional classification scheme URI (the `domain`
/// attribute on the RSS `` element).
pub struct FeedCategory {
  value : String
  domain : String?
} derive(Debug, Eq, Compare)

///|
/// Channel image. Optional `width` / `height` / `description` are omitted
/// from the output when `None`.
pub struct FeedImage {
  url : String
  title : String
  link : String
  width : Int?
  height : Int?
  description : String?
} derive(Debug, Eq, Compare)

///|
pub struct FeedConfig {
  title : String
  description : String
  feed_url : String?
  site_url : String
  author : Author?
  categories : Array[FeedCategory]
  pub_date : FeedDate?
  last_build_date : FeedDate?
  image : FeedImage?
  hub : String?
  docs : String?
  copyright : String?
  language : String?
  managing_editor : String?
  web_master : String?
  ttl : Int?
  generator : String
  custom_namespaces : Array[(String, String)]
  custom_elements : Array[CustomElement]
  items : Array[FeedItem]
} derive(Debug, Eq, Compare)

///|
pub struct FeedItem {
  title : String
  description : String?
  content : String?
  url : String?
  guid : String?
  guid_is_perma_link : Bool?
  categories : Array[FeedCategory]
  author : Author?
  pub_date : FeedDate?
  lat : Double?
  long : Double?
  enclosure : FeedEnclosure?
  custom_elements : Array[CustomElement]
} derive(Debug, Eq, Compare)

///|
pub struct FeedEnclosure {
  url : String
  size : Int64
  mime_type : String?
} derive(Debug, Eq, Compare)

///|
/// An arbitrary XML element to embed in the channel or an item.
/// `Text` is a leaf element with text content; `Element` is a container with
/// attributes and child elements.
pub(all) enum CustomElement {
  Text(String, String)
  Cdata(String, String)
  Element(String, Array[(String, String)], Array[CustomElement])
} derive(Debug, Eq, Compare)

///|
/// Errors produced by feed rendering.
pub suberror FeedError {
  /// A custom element has an invalid XML tag name.
  InvalidTag(String)
  /// A required field (e.g. `title`, `description`) is empty.
  MissingRequiredField(String)
  /// A custom element has an invalid XML attribute name.
  InvalidAttrName(String)
  /// A custom namespace prefix is declared more than once.
  DuplicateNamespacePrefix(String)
  /// A custom namespace prefix is reserved by the renderer.
  ReservedNamespacePrefix(String)
  /// A custom element attribute is declared more than once.
  DuplicateAttrName(String)
  /// A field value is outside the renderer's supported range.
  InvalidValue(String)
  /// An enclosure has invalid data (e.g. empty URL, negative size).
  InvalidEnclosure(String)
  /// A geo coordinate is out of range or not finite.
  InvalidGeo(String)
  /// A feed date has invalid date/time components.
  InvalidDate(String)
} derive(Debug, Eq, Compare)

///|
impl Show for FeedError with fn output(self, logger) {
  match self {
    InvalidTag(tag) => logger.write_string("invalid XML tag: \{tag}")
    MissingRequiredField(field) =>
      logger.write_string("required field is empty: \{field}")
    InvalidAttrName(name) =>
      logger.write_string("invalid XML attribute name: \{name}")
    DuplicateNamespacePrefix(prefix) =>
      logger.write_string("duplicate namespace prefix: \{prefix}")
    ReservedNamespacePrefix(prefix) =>
      logger.write_string("reserved namespace prefix: \{prefix}")
    DuplicateAttrName(name) =>
      logger.write_string("duplicate attribute name: \{name}")
    InvalidValue(msg) => logger.write_string("invalid value: \{msg}")
    InvalidEnclosure(msg) => logger.write_string("invalid enclosure: \{msg}")
    InvalidGeo(msg) => logger.write_string("invalid geo coordinate: \{msg}")
    InvalidDate(msg) => logger.write_string("invalid date: \{msg}")
  }
}

///|
let default_generator : String = "tkancf/feed_gen"

///|
fn validate_optional_date(
  date : FeedDate?,
  field : String,
  errors : Array[FeedError],
) -> Unit {
  match date {
    Some(d) =>
      match d.validation_error(field) {
        Some(e) => errors.push(e)
        None => ()
      }
    None => ()
  }
}

///|
fn validate_geo_value(
  value : Double,
  min : Double,
  max : Double,
  field : String,
  errors : Array[FeedError],
) -> Bool {
  if value.is_nan() || value.is_inf() || value < min || value > max {
    errors.push(InvalidGeo("\{field} out of range: \{value}"))
    false
  } else {
    true
  }
}

///|
fn FeedEnclosure::validate(
  self : FeedEnclosure,
  errors : Array[FeedError],
) -> Unit {
  if self.url == "" {
    errors.push(InvalidEnclosure("URL is empty"))
  }
  if self.size < 0L {
    errors.push(InvalidEnclosure("size is negative"))
  }
  if self.mime_type is Some("") {
    errors.push(InvalidEnclosure("mime type is empty"))
  }
}

///|
/// Create a category. `domain` is the optional classification scheme URI.
pub fn FeedCategory::new(value : String, domain? : String) -> FeedCategory {
  { value, domain }
}

///|
/// Return the category value.
pub fn FeedCategory::value(self : FeedCategory) -> String {
  self.value
}

///|
/// Return the category domain.
pub fn FeedCategory::domain(self : FeedCategory) -> String? {
  self.domain
}

///|
/// Create a channel image. Optional dimensions default to `None`.
pub fn FeedImage::new(url : String, title : String, link : String) -> FeedImage {
  { url, title, link, width: None, height: None, description: None }
}

///|
/// Return the image URL.
pub fn FeedImage::url(self : FeedImage) -> String {
  self.url
}

///|
/// Return the image title.
pub fn FeedImage::title(self : FeedImage) -> String {
  self.title
}

///|
/// Return the image link.
pub fn FeedImage::link(self : FeedImage) -> String {
  self.link
}

///|
/// Set the image width.
pub fn FeedImage::set_width(self : FeedImage, width : Int?) -> FeedImage {
  { ..self, width, }
}

///|
/// Return the image width.
pub fn FeedImage::width(self : FeedImage) -> Int? {
  self.width
}

///|
/// Set the image height.
pub fn FeedImage::set_height(self : FeedImage, height : Int?) -> FeedImage {
  { ..self, height, }
}

///|
/// Return the image height.
pub fn FeedImage::height(self : FeedImage) -> Int? {
  self.height
}

///|
/// Set the image description.
pub fn FeedImage::set_description(
  self : FeedImage,
  description : String?,
) -> FeedImage {
  { ..self, description, }
}

///|
/// Return the image description.
pub fn FeedImage::description(self : FeedImage) -> String? {
  self.description
}

///|
/// Create a feed config with the required `title`, `description`, and
/// `site_url`. The default `generator` is `"tkancf/feed_gen"`.
pub fn FeedConfig::new(
  title : String,
  description : String,
  site_url : String,
  items? : Array[FeedItem] = [],
) -> FeedConfig {
  {
    title,
    description,
    feed_url: None,
    site_url,
    author: None,
    categories: [],
    pub_date: None,
    last_build_date: None,
    image: None,
    hub: None,
    docs: None,
    copyright: None,
    language: None,
    managing_editor: None,
    web_master: None,
    ttl: None,
    generator: default_generator,
    custom_namespaces: [],
    custom_elements: [],
    items,
  }
}

///|
/// Set the feed title.
pub fn FeedConfig::set_title(self : FeedConfig, title : String) -> FeedConfig {
  { ..self, title, }
}

///|
/// Set the feed description.
pub fn FeedConfig::set_description(
  self : FeedConfig,
  description : String,
) -> FeedConfig {
  { ..self, description, }
}

///|
/// Set the site URL.
pub fn FeedConfig::set_site_url(
  self : FeedConfig,
  site_url : String,
) -> FeedConfig {
  { ..self, site_url, }
}

///|
/// Set the feed URL used for RSS `atom:link` and Atom `self` link output.
pub fn FeedConfig::set_feed_url(
  self : FeedConfig,
  feed_url : String?,
) -> FeedConfig {
  { ..self, feed_url, }
}

///|
/// Set the last build date used for feed metadata and Atom `updated`.
pub fn FeedConfig::set_last_build_date(
  self : FeedConfig,
  last_build_date : FeedDate?,
) -> FeedConfig {
  { ..self, last_build_date, }
}

///|
/// Set the feed author.
pub fn FeedConfig::set_author(
  self : FeedConfig,
  author : Author?,
) -> FeedConfig {
  { ..self, author, }
}

///|
/// Append a category to the feed.
pub fn FeedConfig::add_category(
  self : FeedConfig,
  category : FeedCategory,
) -> FeedConfig {
  let categories = self.categories.copy()
  categories.push(category)
  { ..self, categories, }
}

///|
/// Set the feed publication date.
pub fn FeedConfig::set_pub_date(
  self : FeedConfig,
  pub_date : FeedDate?,
) -> FeedConfig {
  { ..self, pub_date, }
}

///|
/// Set the feed image.
pub fn FeedConfig::set_image(
  self : FeedConfig,
  image : FeedImage?,
) -> FeedConfig {
  { ..self, image, }
}

///|
/// Set the PubSubHubbub hub URL.
pub fn FeedConfig::set_hub(self : FeedConfig, hub : String?) -> FeedConfig {
  { ..self, hub, }
}

///|
/// Set the RSS documentation URL.
pub fn FeedConfig::set_docs(self : FeedConfig, docs : String?) -> FeedConfig {
  { ..self, docs, }
}

///|
/// Set the feed copyright text.
pub fn FeedConfig::set_copyright(
  self : FeedConfig,
  copyright : String?,
) -> FeedConfig {
  { ..self, copyright, }
}

///|
/// Set the feed language.
pub fn FeedConfig::set_language(
  self : FeedConfig,
  language : String?,
) -> FeedConfig {
  { ..self, language, }
}

///|
/// Set the RSS managing editor.
pub fn FeedConfig::set_managing_editor(
  self : FeedConfig,
  managing_editor : String?,
) -> FeedConfig {
  { ..self, managing_editor, }
}

///|
/// Set the RSS webmaster.
pub fn FeedConfig::set_web_master(
  self : FeedConfig,
  web_master : String?,
) -> FeedConfig {
  { ..self, web_master, }
}

///|
/// Set the RSS time-to-live value.
pub fn FeedConfig::set_ttl(self : FeedConfig, ttl : Int?) -> FeedConfig {
  { ..self, ttl, }
}

///|
/// Set the generator string.
pub fn FeedConfig::set_generator(
  self : FeedConfig,
  generator : String,
) -> FeedConfig {
  { ..self, generator, }
}

///|
/// Append a custom XML namespace declaration.
pub fn FeedConfig::add_custom_namespace(
  self : FeedConfig,
  prefix : String,
  uri : String,
) -> FeedConfig {
  let namespaces = self.custom_namespaces.copy()
  namespaces.push((prefix, uri))
  { ..self, custom_namespaces: namespaces }
}

///|
/// Append a custom XML element to the channel.
pub fn FeedConfig::add_custom_element(
  self : FeedConfig,
  element : CustomElement,
) -> FeedConfig {
  let elements = self.custom_elements.copy()
  elements.push(element)
  { ..self, custom_elements: elements }
}

///|
/// Create a feed item with the required `title`. `description` is optional
/// and defaults to `None`.
pub fn FeedItem::new(title : String, description? : String) -> FeedItem {
  {
    title,
    description,
    content: None,
    url: None,
    guid: None,
    guid_is_perma_link: None,
    categories: [],
    author: None,
    pub_date: None,
    lat: None,
    long: None,
    enclosure: None,
    custom_elements: [],
  }
}

///|
/// Set the item title.
pub fn FeedItem::set_title(self : FeedItem, title : String) -> FeedItem {
  { ..self, title, }
}

///|
/// Return the item title.
pub fn FeedItem::title(self : FeedItem) -> String {
  self.title
}

///|
/// Set the item description.
pub fn FeedItem::set_description(
  self : FeedItem,
  description : String?,
) -> FeedItem {
  { ..self, description, }
}

///|
/// Return the item description.
pub fn FeedItem::description(self : FeedItem) -> String? {
  self.description
}

///|
/// Set the item content.
pub fn FeedItem::set_content(self : FeedItem, content : String?) -> FeedItem {
  { ..self, content, }
}

///|
/// Return the item content.
pub fn FeedItem::content(self : FeedItem) -> String? {
  self.content
}

///|
/// Set the item URL.
pub fn FeedItem::set_url(self : FeedItem, url : String?) -> FeedItem {
  { ..self, url, }
}

///|
/// Return the item URL.
pub fn FeedItem::url(self : FeedItem) -> String? {
  self.url
}

///|
/// Set the item GUID.
pub fn FeedItem::set_guid(self : FeedItem, guid : String?) -> FeedItem {
  { ..self, guid, }
}

///|
/// Return the item GUID.
pub fn FeedItem::guid(self : FeedItem) -> String? {
  self.guid
}

///|
/// Set whether the GUID should render as a permalink.
pub fn FeedItem::set_guid_is_perma_link(
  self : FeedItem,
  guid_is_perma_link : Bool?,
) -> FeedItem {
  { ..self, guid_is_perma_link, }
}

///|
/// Return whether the item GUID renders as a permalink.
pub fn FeedItem::guid_is_perma_link(self : FeedItem) -> Bool? {
  self.guid_is_perma_link
}

///|
/// Append a category to the item.
pub fn FeedItem::add_category(
  self : FeedItem,
  category : FeedCategory,
) -> FeedItem {
  let categories = self.categories.copy()
  categories.push(category)
  { ..self, categories, }
}

///|
/// Return the item categories.
pub fn FeedItem::categories(self : FeedItem) -> Array[FeedCategory] {
  self.categories.copy()
}

///|
/// Set the item author.
pub fn FeedItem::set_author(self : FeedItem, author : Author?) -> FeedItem {
  { ..self, author, }
}

///|
/// Return the item author.
pub fn FeedItem::author(self : FeedItem) -> Author? {
  self.author
}

///|
/// Set the item publication date.
pub fn FeedItem::set_pub_date(
  self : FeedItem,
  pub_date : FeedDate?,
) -> FeedItem {
  { ..self, pub_date, }
}

///|
/// Return the item publication date.
pub fn FeedItem::pub_date(self : FeedItem) -> FeedDate? {
  self.pub_date
}

///|
/// Set the item latitude.
pub fn FeedItem::set_lat(self : FeedItem, lat : Double?) -> FeedItem {
  { ..self, lat, }
}

///|
/// Return the item latitude.
pub fn FeedItem::lat(self : FeedItem) -> Double? {
  self.lat
}

///|
/// Set the item longitude.
pub fn FeedItem::set_long(self : FeedItem, long : Double?) -> FeedItem {
  { ..self, long, }
}

///|
/// Return the item longitude.
pub fn FeedItem::long(self : FeedItem) -> Double? {
  self.long
}

///|
/// Set the item enclosure.
pub fn FeedItem::set_enclosure(
  self : FeedItem,
  enclosure : FeedEnclosure?,
) -> FeedItem {
  { ..self, enclosure, }
}

///|
/// Return the item enclosure.
pub fn FeedItem::enclosure(self : FeedItem) -> FeedEnclosure? {
  self.enclosure
}

///|
/// Append a custom XML element to the item.
pub fn FeedItem::add_custom_element(
  self : FeedItem,
  element : CustomElement,
) -> FeedItem {
  let elements = self.custom_elements.copy()
  elements.push(element)
  { ..self, custom_elements: elements }
}

///|
/// Return the item custom XML elements.
pub fn FeedItem::custom_elements(self : FeedItem) -> Array[CustomElement] {
  self.custom_elements.copy()
}

///|
/// Create an enclosure. MIME type is detected lazily from the URL extension
/// during rendering. Use `set_mime_type` to override detection.
pub fn FeedEnclosure::new(url : String, size : Int64) -> FeedEnclosure {
  { url, size, mime_type: None }
}

///|
/// Return the enclosure URL.
pub fn FeedEnclosure::url(self : FeedEnclosure) -> String {
  self.url
}

///|
/// Return the enclosure size.
pub fn FeedEnclosure::size(self : FeedEnclosure) -> Int64 {
  self.size
}

///|
/// Override the MIME type used during rendering.
pub fn FeedEnclosure::set_mime_type(
  self : FeedEnclosure,
  mime_type : String?,
) -> FeedEnclosure {
  { ..self, mime_type, }
}

///|
/// Return the explicit MIME type override.
pub fn FeedEnclosure::mime_type(self : FeedEnclosure) -> String? {
  self.mime_type
}

///|
/// Return the effective MIME type, resolved from explicit override or URL.
pub fn FeedEnclosure::effective_mime(self : FeedEnclosure) -> String {
  match self.mime_type {
    Some(m) => m
    None => mime_from_url(self.url)
  }
}

///|
/// Append an item to this config's items list.
/// Returns a new config with the item added, for chaining.
pub fn FeedConfig::add_item(self : FeedConfig, item : FeedItem) -> FeedConfig {
  let new_items = {
    let arr = self.items.copy()
    arr.push(item)
    arr
  }
  { ..self, items: new_items }
}

///|
/// Sort items using the given comparator function.
/// Returns a new config with items sorted.
pub fn FeedConfig::sort_by(
  self : FeedConfig,
  cmp : (FeedItem, FeedItem) -> Int,
) -> FeedConfig {
  let sorted = self.items.copy()
  sorted.sort_by(cmp)
  { ..self, items: sorted }
}

///|
/// Validate fields common to both RSS and Atom rendering: required
/// non-empty title/description/site_url, date validity, and namespace prefix
/// shape. Renderers provide their own namespace reservation policy.
fn validate_common_fields(
  config : FeedConfig,
  errors : Array[FeedError],
  is_reserved_namespace_prefix : (String) -> Bool,
) -> Unit {
  if config.title == "" {
    errors.push(MissingRequiredField("title"))
  }
  if config.description == "" {
    errors.push(MissingRequiredField("description"))
  }
  if config.site_url == "" {
    errors.push(MissingRequiredField("site_url"))
  }
  validate_optional_date(config.pub_date, "pub_date", errors)
  validate_optional_date(config.last_build_date, "last_build_date", errors)
  for item in config.items {
    if item.title == "" {
      errors.push(MissingRequiredField("item.title"))
    }
    match (item.lat, item.long) {
      (Some(_), None) => errors.push(InvalidGeo("latitude without longitude"))
      (None, Some(_)) => errors.push(InvalidGeo("longitude without latitude"))
      _ => ()
    }
    validate_optional_date(item.pub_date, "item.pub_date", errors)
  }
  let seen_namespace_prefixes : Array[String] = []
  for ns in config.custom_namespaces {
    let (prefix, _) = ns
    if is_reserved_namespace_prefix(prefix) {
      errors.push(ReservedNamespacePrefix(prefix))
    }
    if seen_namespace_prefixes.contains(prefix) {
      errors.push(DuplicateNamespacePrefix(prefix))
      continue
    }
    seen_namespace_prefixes.push(prefix)
    try validate_ncname(prefix, fn(n) { InvalidAttrName(n) }) catch {
      e => errors.push(e)
    } noraise {
      _ => ()
    }
  }
}