// Rich text: the structured form of what a person typed in the composer.
//
// https://docs.slack.dev/reference/block-kit/blocks/rich-text-block
//
// This is the only part of Block Kit that is a tree rather than a list, and it
// is by far the most common thing in real message payloads -- Slack rewrites
// every ordinary message into one. Two levels: a `rich_text` block holds
// *sections* (a paragraph, a list, a quote, a code block), and each section
// holds *items* (a run of text, a mention, an emoji, a link).
//
// Both levels get their own `Unknown` variant. A single flat model would have
// to guess which level an unrecognised type belonged to, and the two report
// differently in a strict parse.

///|
/// Character styling on a run of text.
///
/// `highlight`, `client_highlight` and `unlink` only appear on mentions -- they
/// are how Slack marks a mention of you, specifically -- but they live here
/// rather than in a second struct because Slack sends them in the same `style`
/// object.
pub(all) struct RichTextStyle {
  bold : Bool?
  italic : Bool?
  strike : Bool?
  code : Bool?
  highlight : Bool?
  client_highlight : Bool?
  unlink : Bool?
  extra : Map[String, Json]
} derive(Eq, Debug)

///|
pub fn RichTextStyle::from_json(j : Json) -> RichTextStyle? {
  guard j is Object(o) else { return None }
  let rest = fields_of(o)
  Some({
    bold: take_bool(rest, "bold"),
    italic: take_bool(rest, "italic"),
    strike: take_bool(rest, "strike"),
    code: take_bool(rest, "code"),
    highlight: take_bool(rest, "highlight"),
    client_highlight: take_bool(rest, "client_highlight"),
    unlink: take_bool(rest, "unlink"),
    extra: rest,
  })
}

///|
pub fn RichTextStyle::to_json(self : Self) -> Json {
  let o : Map[String, Json] = Map([])
  put_bool(o, "bold", self.bold)
  put_bool(o, "italic", self.italic)
  put_bool(o, "strike", self.strike)
  put_bool(o, "code", self.code)
  put_bool(o, "highlight", self.highlight)
  put_bool(o, "client_highlight", self.client_highlight)
  put_bool(o, "unlink", self.unlink)
  merge_extra(o, self.extra)
}

///|
/// One run inside a rich-text section.
pub(all) enum RichTextItem {
  /// A run of literal text.
  Text(text~ : String?, style~ : RichTextStyle?, extra~ : Map[String, Json])
  /// A link. `unsafe` marks one Slack will not auto-linkify.
  Link(
    url~ : String?,
    text~ : String?,
    /// Spelled `unsafe_` because `unsafe` is a reserved word. Slack sends
    /// `unsafe`; it marks a link the client will not auto-linkify.
    unsafe_~ : Bool?,
    style~ : RichTextStyle?,
    extra~ : Map[String, Json]
  )
  /// `name` is the shortcode without colons. `unicode` is the code point, which
  /// Slack sends for standard emoji and omits for custom ones; `skin_tone` is
  /// what java-slack-sdk's `testRichTextSkinToneEmoji` exists for.
  Emoji(
    name~ : String?,
    unicode~ : String?,
    skin_tone~ : Int?,
    style~ : RichTextStyle?,
    extra~ : Map[String, Json]
  )
  User(user_id~ : String?, style~ : RichTextStyle?, extra~ : Map[String, Json])
  UserGroup(
    usergroup_id~ : String?,
    style~ : RichTextStyle?,
    extra~ : Map[String, Json]
  )
  Channel(
    channel_id~ : String?,
    style~ : RichTextStyle?,
    extra~ : Map[String, Json]
  )
  Team(team_id~ : String?, style~ : RichTextStyle?, extra~ : Map[String, Json])
  /// `@here`, `@channel`, `@everyone`.
  Broadcast(
    range~ : String?,
    style~ : RichTextStyle?,
    extra~ : Map[String, Json]
  )
  /// A colour swatch, from java-slack-sdk's `rich_text_color_code` test.
  Color(value~ : String?, style~ : RichTextStyle?, extra~ : Map[String, Json])
  /// A rendered timestamp. `format` is Slack's date-format string and
  /// `fallback` is what a client that cannot render it should show.
  Date(
    timestamp~ : Int?,
    format~ : String?,
    url~ : String?,
    fallback~ : String?,
    style~ : RichTextStyle?,
    extra~ : Map[String, Json]
  )
  Unknown(type_~ : String, raw~ : Json)
} derive(Eq, Debug)

///|
pub fn RichTextItem::type_name(self : Self) -> String {
  match self {
    Text(..) => "text"
    Link(..) => "link"
    Emoji(..) => "emoji"
    User(..) => "user"
    UserGroup(..) => "usergroup"
    Channel(..) => "channel"
    Team(..) => "team"
    Broadcast(..) => "broadcast"
    Color(..) => "color"
    Date(..) => "date"
    Unknown(type_=t, ..) => t
  }
}

///|
/// Always succeeds: an unrecognised item becomes `Unknown`.
pub fn RichTextItem::from_json(j : Json) -> RichTextItem {
  let tag = type_tag_of(j)
  guard j is Object(o) else { return Unknown(type_=tag, raw=j) }
  let rest = fields_of(o)
  rest.remove("type")
  let style = take_obj(rest, "style", RichTextStyle::from_json)
  match tag {
    "text" => Text(text=take_str(rest, "text"), style~, extra=rest)
    "link" =>
      Link(
        url=take_str(rest, "url"),
        text=take_str(rest, "text"),
        unsafe_=take_bool(rest, "unsafe"),
        style~,
        extra=rest,
      )
    "emoji" =>
      Emoji(
        name=take_str(rest, "name"),
        unicode=take_str(rest, "unicode"),
        skin_tone=take_int(rest, "skin_tone"),
        style~,
        extra=rest,
      )
    "user" => User(user_id=take_str(rest, "user_id"), style~, extra=rest)
    "usergroup" =>
      UserGroup(usergroup_id=take_str(rest, "usergroup_id"), style~, extra=rest)
    "channel" =>
      Channel(channel_id=take_str(rest, "channel_id"), style~, extra=rest)
    "team" => Team(team_id=take_str(rest, "team_id"), style~, extra=rest)
    "broadcast" => Broadcast(range=take_str(rest, "range"), style~, extra=rest)
    "color" => Color(value=take_str(rest, "value"), style~, extra=rest)
    "date" =>
      Date(
        timestamp=take_int(rest, "timestamp"),
        format=take_str(rest, "format"),
        url=take_str(rest, "url"),
        fallback=take_str(rest, "fallback"),
        style~,
        extra=rest,
      )
    _ => Unknown(type_=tag, raw=j)
  }
}

///|
pub fn RichTextItem::to_json(self : Self) -> Json {
  if self is Unknown(raw~, ..) {
    return raw
  }
  let o = out_of(self.type_name())
  let (style, extra) = match self {
    Text(text~, style~, extra~) => {
      put_str(o, "text", text)
      (style, extra)
    }
    Link(url~, text~, unsafe_~, style~, extra~) => {
      put_str(o, "url", url)
      put_str(o, "text", text)
      put_bool(o, "unsafe", unsafe_)
      (style, extra)
    }
    Emoji(name~, unicode~, skin_tone~, style~, extra~) => {
      put_str(o, "name", name)
      put_str(o, "unicode", unicode)
      put_int(o, "skin_tone", skin_tone)
      (style, extra)
    }
    User(user_id~, style~, extra~) => {
      put_str(o, "user_id", user_id)
      (style, extra)
    }
    UserGroup(usergroup_id~, style~, extra~) => {
      put_str(o, "usergroup_id", usergroup_id)
      (style, extra)
    }
    Channel(channel_id~, style~, extra~) => {
      put_str(o, "channel_id", channel_id)
      (style, extra)
    }
    Team(team_id~, style~, extra~) => {
      put_str(o, "team_id", team_id)
      (style, extra)
    }
    Broadcast(range~, style~, extra~) => {
      put_str(o, "range", range)
      (style, extra)
    }
    Color(value~, style~, extra~) => {
      put_str(o, "value", value)
      (style, extra)
    }
    Date(timestamp~, format~, url~, fallback~, style~, extra~) => {
      put_int(o, "timestamp", timestamp)
      put_str(o, "format", format)
      put_str(o, "url", url)
      put_str(o, "fallback", fallback)
      (style, extra)
    }
    Unknown(..) => (None, Map([]))
  }
  put_obj(o, "style", style, RichTextStyle::to_json)
  merge_extra(o, extra)
}

///|
/// One section of a rich-text block: a paragraph, a list, a quote or a code
/// block.
pub(all) enum RichTextBlockElement {
  /// A paragraph.
  Section(elements~ : Array[RichTextItem], extra~ : Map[String, Json])
  /// A bulleted or ordered list. Its `elements` are *sections*, one per item,
  /// which is what makes rich text a tree.
  List(
    style~ : String?,
    indent~ : Int?,
    offset~ : Int?,
    border~ : Int?,
    elements~ : Array[RichTextBlockElement],
    extra~ : Map[String, Json]
  )
  /// A code block.
  Preformatted(
    border~ : Int?,
    elements~ : Array[RichTextItem],
    extra~ : Map[String, Json]
  )
  Quote(
    border~ : Int?,
    elements~ : Array[RichTextItem],
    extra~ : Map[String, Json]
  )
  Unknown(type_~ : String, raw~ : Json)
} derive(Eq, Debug)

///|
pub fn RichTextBlockElement::type_name(self : Self) -> String {
  match self {
    Section(..) => "rich_text_section"
    List(..) => "rich_text_list"
    Preformatted(..) => "rich_text_preformatted"
    Quote(..) => "rich_text_quote"
    Unknown(type_=t, ..) => t
  }
}

///|
fn items_of(rest : Map[String, Json]) -> Array[RichTextItem] {
  match rest.get("elements") {
    Some(Array(items)) => {
      rest.remove("elements")
      items.map(RichTextItem::from_json)
    }
    // No `elements`, or one that is not an array: leave the key in `rest` so it
    // survives the round trip, and report an empty section.
    _ => []
  }
}

///|
pub fn RichTextBlockElement::from_json(j : Json) -> RichTextBlockElement {
  let tag = type_tag_of(j)
  guard j is Object(o) else { return Unknown(type_=tag, raw=j) }
  let rest = fields_of(o)
  rest.remove("type")
  match tag {
    "rich_text_section" => Section(elements=items_of(rest), extra=rest)
    "rich_text_preformatted" =>
      Preformatted(
        border=take_int(rest, "border"),
        elements=items_of(rest),
        extra=rest,
      )
    "rich_text_quote" =>
      Quote(
        border=take_int(rest, "border"),
        elements=items_of(rest),
        extra=rest,
      )
    "rich_text_list" => {
      let elements = match rest.get("elements") {
        Some(Array(items)) => {
          rest.remove("elements")
          items.map(RichTextBlockElement::from_json)
        }
        _ => []
      }
      List(
        style=take_str(rest, "style"),
        indent=take_int(rest, "indent"),
        offset=take_int(rest, "offset"),
        border=take_int(rest, "border"),
        elements~,
        extra=rest,
      )
    }
    _ => Unknown(type_=tag, raw=j)
  }
}

///|
pub fn RichTextBlockElement::to_json(self : Self) -> Json {
  if self is Unknown(raw~, ..) {
    return raw
  }
  let o = out_of(self.type_name())
  match self {
    Section(elements~, extra~) => {
      o["elements"] = Json::array(elements.map(RichTextItem::to_json))
      merge_extra(o, extra)
    }
    Preformatted(border~, elements~, extra~) => {
      put_int(o, "border", border)
      o["elements"] = Json::array(elements.map(RichTextItem::to_json))
      merge_extra(o, extra)
    }
    Quote(border~, elements~, extra~) => {
      put_int(o, "border", border)
      o["elements"] = Json::array(elements.map(RichTextItem::to_json))
      merge_extra(o, extra)
    }
    List(style~, indent~, offset~, border~, elements~, extra~) => {
      put_str(o, "style", style)
      put_int(o, "indent", indent)
      put_int(o, "offset", offset)
      put_int(o, "border", border)
      o["elements"] = Json::array(elements.map(RichTextBlockElement::to_json))
      merge_extra(o, extra)
    }
    Unknown(raw~, ..) => raw
  }
}

///|
pub fn RichTextItem::validate(self : Self) -> Unit raise BlockParseError {
  if self is Unknown(type_=t, ..) {
    raise UnknownRichTextElement(t)
  }
}

///|
pub fn RichTextBlockElement::validate(
  self : Self,
) -> Unit raise BlockParseError {
  match self {
    Unknown(type_=t, ..) => raise UnknownRichTextElement(t)
    Section(elements~, ..)
    | Preformatted(elements~, ..)
    | Quote(elements~, ..) =>
      for item in elements {
        item.validate()
      }
    List(elements~, ..) =>
      for section in elements {
        section.validate()
      }
  }
}