///|
/// Wraps an ordered array of message parts. Text extraction and structural
/// inspection are separate: extracting text does not imply text-only content.
pub(all) struct Content(Array[ContentPart]) derive(Debug, Eq)

///|
pub extend Content with Debug::{to_repr}

///|
pub extend Content with Eq::{equal, not_equal}

///|
/// Preserve the string format for one text part so older readers can still
/// read plain-text records. Other content retains its ordered array of parts.
pub impl ToJson for Content with fn to_json(self) {
  match self.0 {
    [Text(text)] => Json(text)
    parts => Json(parts)
  }
}

///|
/// Read current content arrays and legacy text-only session records.
pub impl FromJson for Content with fn from_json(json, path) {
  match json {
    String(text) => Content([Text(text)])
    Array(_) => Content(@json.from_json(json, path~))
    _ => raise JsonDecodeError((path, "expected content string or array"))
  }
}

///|
/// Concatenates every text part in order, without adding separators. Images
/// contribute no text. Empty and image-only content yield "" because
/// they contain zero text characters; this method does not describe images.
pub fn Content::text(self : Content) -> String {
  self.0
  .iter()
  .filter_map(part => {
    match part {
      Text(text) => Some(text)
      Image(_) => None
    }
  })
  .join("")
}

///|
/// Search the concatenated text, including matches spanning text parts.
/// Images are excluded, just as in `text()`.
pub fn Content::contains(self : Content, pattern : StringView) -> Bool {
  self.text().contains(pattern)
}

///|
/// Whether every part is text. Empty content is text-only. Use this alongside
/// `text()` when a comparison must not discard images.
pub fn Content::is_text_only(self : Content) -> Bool {
  self.0.all(part => part is Text(_))
}

///|
/// Visits all text and image parts in their original order.
pub fn Content::iter(self : Content) -> Iter[ContentPart] {
  self.0.iter()
}

///|
/// Durable content is independent of provider upload IDs.
pub(all) enum ContentPart {
  Text(String)
  Image(Image)
} derive(Debug, Eq)

///|
impl ToJson for ContentPart with fn to_json(self) {
  match self {
    Text(text) => { "type": "text", "text": text }
    Image(image) =>
      {
        "type": "image",
        "media_type": image.media_type,
        "data": @base64.encode(image.bytes),
      }
  }
}

///|
pub impl FromJson for ContentPart with fn from_json(json, path) {
  guard json is Object(fields) else {
    raise JsonDecodeError((path, "expected content part object"))
  }
  match fields.get("type") {
    Some(String("text")) =>
      Text(
        @json.from_json(
          fields.get("text").unwrap_or(Json::null()),
          path=path.add_key("text"),
        ),
      )
    Some(String("image")) => {
      let media_type : String = @json.from_json(
        fields.get("media_type").unwrap_or(Json::null()),
        path=path.add_key("media_type"),
      )
      let data : String = @json.from_json(
        fields.get("data").unwrap_or(Json::null()),
        path=path.add_key("data"),
      )
      let image = Image::from_base64(media_type~, data) catch {
        error => raise JsonDecodeError((path, "invalid image: \{error}"))
      }
      Image(image)
    }
    _ =>
      raise JsonDecodeError((path.add_key("type"), "unknown content part type"))
  }
}

///|
pub extend Content with ToJson::{to_json}

///|
pub extend Content with FromJson::{from_json}

///|
pub extend ContentPart with Debug::{to_repr}

///|
pub extend ContentPart with Eq::{equal, not_equal}

///|
pub extend ContentPart with FromJson::{from_json}