// Port of rich/markup.py.

///|
/// A tag in console markup.
pub(all) struct Tag {
  /// The tag name, e.g. `"bold"`.
  name : String
  /// Any additional parameters after the name.
  parameters : String?
} derive(Eq, Debug)

///|
/// Upstream `str(tag)`.
pub impl Show for Tag with fn output(self, logger) {
  match self.parameters {
    None => logger.write_string(self.name)
    Some(p) => logger.write_string(self.name + " " + p)
  }
}

///|
/// `str(tag)` without going through `Show`.
fn Tag::style_text(self : Tag) -> String {
  match self.parameters {
    None => self.name
    Some(p) => self.name + " " + p
  }
}

///|
/// The markup representation of this tag.
pub fn Tag::markup(self : Tag) -> String {
  match self.parameters {
    None => "[\{self.name}]"
    Some(p) => "[\{self.name}=\{p}]"
  }
}

///|
fn must_compile(pattern : String) -> @regex.Regex {
  @regex.compile(pattern) catch {
    _ => abort("invalid built-in regex \{pattern}")
  }
}

///|
let re_handler : @regex.Regex = must_compile("^([\\w.]*?)(\\(.*?\\))?$")

///|
let re_escape : @regex.Regex = must_compile("(\\\\*)(\\[[a-z#/@][^[]*?])")

///|
/// Escape text so that it won't be interpreted as markup.
pub fn escape(markup : String) -> String {
  let markup = if markup.contains("[") {
    re_escape.replace_all(markup, m => {
      let backslashes = m.group(1).unwrap_or("")
      let text = m.group(2).unwrap_or("")
      backslashes + backslashes + "\\" + text
    }) catch {
      _ => markup
    }
  } else {
    markup
  }
  if markup.has_suffix("\\") && !markup.has_suffix("\\\\") {
    markup + "\\"
  } else {
    markup
  }
}

///|
/// An item of parsed markup (upstream `_parse` yields
/// `(position, text, tag)` tuples): text or a tag, at a position.
pub(all) enum MarkupItem {
  PlainText(Int, String)
  TagAt(Int, Tag)
} derive(Eq, Debug)

///|
/// Parse markup in to a list of text and tag items (upstream
/// `markup._parse`).
pub fn parse_markup(markup : String) -> Array[MarkupItem] {
  let out = []
  let mut position = 0
  let n = markup.length()
  // Hand-written equivalent of `re_tags.finditer(markup)`, i.e. the regex
  // `((\\*)\[([a-z#/@][^[]*?)])`: a run of backslashes, `[`, a tag start
  // character and the shortest run of non-`[` characters up to `]`.
  let mut p = 0
  while p < n {
    let c = markup.unsafe_get(p).to_int()
    if c != '\\' && c != '[' {
      p += 1
      continue
    }
    let mut q = p
    while q < n && markup.unsafe_get(q).to_int() == '\\' {
      q += 1
    }
    let tag_start = q + 1
    if q >= n ||
      markup.unsafe_get(q).to_int() != '[' ||
      tag_start >= n ||
      !is_tag_start(markup.unsafe_get(tag_start).to_int()) {
      p += 1
      continue
    }
    let mut r = tag_start + 1
    while r < n {
      let d = markup.unsafe_get(r).to_int()
      if d == '[' || d == ']' {
        break
      }
      r += 1
    }
    if r >= n || markup.unsafe_get(r).to_int() != ']' {
      p += 1
      continue
    }
    // match: whole `p..r+1`, escapes `p..q`, tag text `tag_start..r`
    let end = r + 1
    let escapes_length = q - p
    let mut start = p
    p = end
    if start > position {
      out.push(
        PlainText(start, markup.unsafe_substring(start=position, end=start)),
      )
    }
    if escapes_length != 0 {
      let backslashes = escapes_length / 2
      let escaped = escapes_length % 2
      if backslashes != 0 {
        out.push(PlainText(start, "\\".repeat(backslashes)))
        start += backslashes * 2
      }
      if escaped != 0 {
        out.push(PlainText(start, markup.unsafe_substring(start=q, end~)))
        position = end
        continue
      }
    }
    let tag_text = markup.unsafe_substring(start=tag_start, end=r)
    let (text, equals, parameters) = partition(tag_text, "=")
    out.push(
      TagAt(start, {
        name: text,
        parameters: if equals {
          Some(parameters)
        } else {
          None
        },
      }),
    )
    position = end
  }
  if position < markup.length() {
    out.push(PlainText(position, py_slice(markup, position, markup.length())))
  }
  out
}

///|
/// `[a-z#/@]`: the first character of a markup tag.
fn is_tag_start(c : Int) -> Bool {
  (c >= 'a' && c <= 'z') || c == '#' || c == '/' || c == '@'
}

///|
/// Render console markup in to a Text instance.
pub fn render_markup(
  markup : String,
  style? : StyleType = Def(""),
  emoji? : Bool = true,
  emoji_variant? : EmojiVariant,
) -> Text raise RichError {
  if !markup.contains_char('[') {
    return Text::new(
      text=if emoji {
        @emoji.replace(
          markup,
          default_variant?=emoji_variant.map(v => v.to_string()),
        )
      } else {
        markup
      },
      style~,
    )
  }
  let text = Text::new(style~)
  let style_stack : Array[(Int, Tag)] = []
  let spans : Array[Span] = []
  let items = parse_markup(markup)
  for item in items {
    match item {
      PlainText(_, plain_text) => {
        let plain_text = if plain_text.contains("\\[") {
          plain_text.replace_all(old="\\[", new="[")
        } else {
          plain_text
        }
        text.append_str(
          if emoji {
            // as upstream, the variant only applies to markup without tags
            @emoji.replace(plain_text)
          } else {
            plain_text
          },
        )
        |> ignore
      }
      TagAt(position, tag) =>
        if tag.name.strip_prefix("/") is Some(closing) {
          let style_name = py_strip(closing.to_owned())
          let (start, open_tag) = if style_name != "" {
            let style_name = Style::normalize(style_name)
            let mut found = None
            let mut i = style_stack.length() - 1
            while i >= 0 {
              if style_stack[i].1.name == style_name {
                found = Some(style_stack.remove(i))
                break
              }
              i -= 1
            }
            match found {
              Some(f) => f
              None =>
                raise MarkupError(
                  "closing tag '\{tag.markup()}' at position \{position} doesn't match any open tag",
                )
            }
          } else {
            match style_stack.pop() {
              Some(f) => f
              None =>
                raise MarkupError(
                  "closing tag '[/]' at position \{position} has nothing to close",
                )
            }
          }
          if open_tag.name.has_prefix("@") {
            let meta_params : Json = match open_tag.parameters {
              Some(p) if p != "" => {
                let mut handler_name = ""
                let mut parameters = py_strip(p)
                let handler_match = re_handler.match_at(parameters, 0) catch {
                  _ => None
                }
                if handler_match is Some(m) {
                  handler_name = m.group(1).unwrap_or("")
                  parameters = m.group(2).unwrap_or("()")
                }
                let value = literal_eval_impl(parameters) catch {
                  LitSyntax(msg) =>
                    raise MarkupError(
                      "error parsing \{py_repr(parameters)} in \{py_repr(p)}; \{msg}",
                    )
                  LitValue(msg) =>
                    raise MarkupError("error parsing \{py_repr(p)}; \{msg}")
                }
                if handler_name != "" {
                  let params = match value {
                    PyTuple(items) => items
                    v => [v]
                  }
                  Json::array([
                    Json::string(handler_name),
                    Json::array(params.map(v => v.to_json())),
                  ])
                } else {
                  value.to_json()
                }
              }
              _ => Json::array([])
            }
            spans.push({
              start,
              end: text.length(),
              style: Style(
                Style::new(meta=Map::from_array([(open_tag.name, meta_params)])),
              ),
            })
          } else {
            spans.push({
              start,
              end: text.length(),
              style: Def(open_tag.style_text()),
            })
          }
        } else {
          let normalized_tag = {
            name: Style::normalize(tag.name),
            parameters: tag.parameters,
          }
          style_stack.push((text.length(), normalized_tag))
        }
    }
  }
  let text_length = text.length()
  while style_stack.pop() is Some((start, tag)) {
    let style = tag.style_text()
    if style != "" {
      spans.push({ start, end: text_length, style: Def(style), })
    }
  }
  spans.rev_in_place()
  stable_sort_by(spans, (a, b) => a.start.compare(b.start))
  text.spans_ = spans
  text
}

///|
/// Emoji variants.
pub(all) enum EmojiVariant {
  Emoji
  Text
} derive(Eq, Debug)

///|
pub fn EmojiVariant::to_string(self : EmojiVariant) -> String {
  match self {
    Emoji => "emoji"
    Text => "text"
  }
}