///|
priv struct TextEditResult {
  text : String
  caret : Int
  selection : @core.TextRange?
  start : Int
  before : String
  after : String
}

///|
/// The committed range/replacement pair of a text-input edit, published to
/// the `on_input_edit` channel so document-backed consumers (the Markdown
/// session) can build their transaction from the edit itself instead of
/// re-deriving it by diffing the two whole-source strings.
priv struct RichTextInputEdit {
  start : Int
  deleted : String
  replacement : String
}

///|
priv struct TextHistoryRestoreResult {
  text : String
  control : @core.TextControlStateContext
  changed : Bool
  // The range edit the restore applied, so downstream session editors can
  // build their transaction from it instead of re-diffing two
  // whole-source strings (undo/redo on large documents otherwise pays
  // the same O(document) scan as an untracked keystroke).
  edit : RichTextInputEdit?
}

///|
priv struct TextInputApplyResult {
  text : String
  control : @core.TextControlStateContext
  changed : Bool
  // The edit that produced the text, when this result came from a
  // range/replacement application (None for caret/selection/composition
  // and history-restore results).
  edit : RichTextInputEdit?
}

///|
fn restore_text_history(
  control : @core.TextControlStateContext,
  current : String,
  undo~ : Bool,
) -> TextHistoryRestoreResult {
  rich_text_history_reset_group()
  let source = if undo { control.undo_stack } else { control.redo_stack }
  if source.length() == 0 {
    return { text: current, control, changed: false, edit: None, }
  }
  let restored = source[source.length() - 1]
  let restored_text = if undo {
    restored.undo_text(current)
  } else {
    restored.redo_text(current)
  }
  let next_source = source[:source.length() - 1].to_owned()
  let current_entry : @core.TextHistoryEntryContext = {
    ..restored,
    caret: control.caret,
    selection: control.selection,
  }
  let destination = if undo { control.redo_stack } else { control.undo_stack }
  let next_destination = destination + [current_entry]
  let next_control = if undo {
    {
      ..control,
      caret: restored.caret,
      selection: restored.selection,
      composition: None,
      composition_cursor: None,
      undo_stack: next_source,
      redo_stack: next_destination,
    }
  } else {
    {
      ..control,
      caret: restored.caret,
      selection: restored.selection,
      composition: None,
      composition_cursor: None,
      undo_stack: next_destination,
      redo_stack: next_source,
    }
  }
  let changed = restored_text != current
  // Undo replaces the previously inserted text (`after`) with the removed
  // text (`before`); redo is the inverse. Carrying the exact range keeps
  // the downstream apply incremental.
  let edit : RichTextInputEdit? = if changed {
    Some({
      start: restored.start,
      deleted: if undo {
        restored.after
      } else {
        restored.before
      },
      replacement: if undo {
        restored.before
      } else {
        restored.after
      },
    })
  } else {
    None
  }
  { text: restored_text, control: next_control, changed, edit, }
}

///|
fn apply_text_input(
  control : @core.TextControlStateContext,
  text : String,
  event : @core.TextInputEvent,
  record_history~ : Bool,
) -> TextInputApplyResult {
  match event {
    InsertText(inserted) => {
      if inserted == "" {
        return { text, control, changed: false, edit: None, }
      }
      let edit = replace_current_selection(
        text,
        control.caret,
        control.selection,
        inserted,
      )
      apply_text_edit(control, text, edit, record_history~)
    }
    ReplaceText(range, replacement) => {
      let (next_text, deleted, start) = @core.replace_text_range_edit(
        text~,
        range,
        replacement,
      )
      let next_edit : TextEditResult = {
        text: next_text,
        caret: start + text_length(replacement),
        selection: None,
        start,
        before: deleted,
        after: replacement,
      }
      apply_text_edit(control, text, next_edit, record_history~)
    }
    SetSelection(range) => {
      let normalized = @core.normalize_grapheme_range(
        @core.TextGraphemeBoundaries::new(text~),
        range,
      )
      let next_selection = if normalized.is_collapsed() {
        None
      } else {
        Some(normalized)
      }
      let next_control = {
        ..control,
        caret: normalized.end,
        selection: next_selection,
        composition: None,
        composition_cursor: None,
      }
      {
        text,
        control: next_control,
        changed: next_control.caret != control.caret ||
        next_control.selection != control.selection ||
        control.composition is Some(_) ||
        control.composition_cursor is Some(_),
        edit: None,
      }
    }
    CompositionStart => {
      let next_control = {
        ..control,
        composition: Some(""),
        composition_cursor: None,
      }
      { text, control: next_control, changed: true, edit: None, }
    }
    CompositionUpdate(update) => {
      let next_control = {
        ..control,
        composition: if update.text() == "" {
          None
        } else {
          Some(update.text())
        },
        composition_cursor: match update.cursor() {
          Some(cursor) => {
            let normalized = @core.normalize_grapheme_range(
              @core.TextGraphemeBoundaries::new(text=update.text()),
              cursor,
            )
            let length = text_length(update.text())
            let start = @core.clamp_int(normalized.start, 0, length)
            let end = @core.clamp_int(normalized.end, 0, length)
            Some(@core.TextRange::new(start~, end~))
          }
          None => None
        },
      }
      { text, control: next_control, changed: true, edit: None, }
    }
    CompositionEnd(inserted) => {
      let next_control = {
        ..control,
        composition: None,
        composition_cursor: None,
      }
      if inserted == "" {
        {
          text,
          control: { ..next_control, selection: None, },
          changed: true,
          edit: None,
        }
      } else {
        let edit = replace_current_selection(
          text,
          next_control.caret,
          next_control.selection,
          inserted,
        )
        apply_text_edit(next_control, text, edit, record_history~)
      }
    }
    DeleteRange(range) => {
      let next_text = @core.replace_text_range(text, range, "")
      let normalized = range.normalized()
      let chars = text.to_array()
      let edit : TextEditResult = {
        text: next_text,
        caret: normalized.start,
        selection: None,
        start: normalized.start,
        before: String::from_array(chars[normalized.start:normalized.end]),
        after: "",
      }
      apply_text_edit(control, text, edit, record_history~)
    }
    DeleteSurrounding(before, after) => {
      let range = @core.surrounding_delete_range(
        text,
        control.caret,
        before,
        after,
      )
      let next_text = @core.replace_text_range(text, range, "")
      let chars = text.to_array()
      let edit : TextEditResult = {
        text: next_text,
        caret: range.start,
        selection: None,
        start: range.start,
        before: String::from_array(chars[range.start:range.end]),
        after: "",
      }
      apply_text_edit(control, text, edit, record_history~)
    }
  }
}

///|
/// Classify the undo-merge shape directly from the applied edit's known
/// range (`start`/`before`/`after`) instead of re-deriving it by scanning
/// the two whole documents: the edit is the exact diff, so classification
/// is O(edit). The range/rescan-based `rich_text_history_single_edit`
/// stays as the fallback for callers without an edit record.
fn rich_text_history_edit_shape_at(
  start : Int,
  before : String,
  after : String,
  caret : Int,
  selection_active~ : Bool,
) -> (Int, Int, Char)? {
  if selection_active {
    return None
  }
  // A single-codepoint string spans at most two UTF-16 units; anything
  // longer (or a replacement on both sides) never merges.
  let single = fn(text : String) -> Char? {
    if text.length() > 2 {
      None
    } else {
      let chars = text.to_array()
      if chars.length() == 1 {
        Some(chars[0])
      } else {
        None
      }
    }
  }
  if before == "" {
    match single(after) {
      Some(ch) if ch != '\n' && caret == start => Some((1, start, ch))
      _ => None
    }
  } else if after == "" {
    match single(before) {
      Some(ch) if ch != '\n' =>
        if caret == start + 1 {
          Some((2, start, ch))
        } else if caret == start {
          Some((3, start, ch))
        } else {
          None
        }
      _ => None
    }
  } else {
    None
  }
}

///|
fn apply_text_edit(
  control : @core.TextControlStateContext,
  previous : String,
  edit : TextEditResult,
  record_history~ : Bool,
) -> TextInputApplyResult {
  // Splicing `after` over `before` at one range yields the previous text
  // exactly when the two strings are equal, so the changed test avoids the
  // O(doc) whole-text compare on every keystroke.
  let text_changed = edit.after != edit.before
  let changed = text_changed ||
    edit.caret != control.caret ||
    edit.selection != control.selection ||
    control.composition is Some(_) ||
    control.composition_cursor is Some(_)
  if changed {
    let undo_stack : Array[@core.TextHistoryEntryContext] = []
    undo_stack.append(control.undo_stack)
    if record_history && text_changed {
      rich_text_history_extend_stack(
        undo_stack,
        rich_text_history_record_edit(
          control,
          previous,
          edit.text,
          control.caret,
          shape=Some(
            rich_text_history_edit_shape_at(
              edit.start,
              edit.before,
              edit.after,
              control.caret,
              selection_active=@core.active_text_selection(control.selection)
                is Some(_),
            ),
          ),
        ),
        {
          start: edit.start,
          before: edit.before,
          after: edit.after,
          caret: control.caret,
          selection: control.selection,
        },
      )
    }
    {
      text: edit.text,
      control: {
        ..control,
        caret: edit.caret,
        selection: edit.selection,
        composition: None,
        composition_cursor: None,
        undo_stack,
        redo_stack: [],
      },
      changed: true,
      edit: Some({
        start: edit.start,
        deleted: edit.before,
        replacement: edit.after,
      }),
    }
  } else {
    { text: previous, control, changed: false, edit: None, }
  }
}

///|
fn replace_current_selection(
  text : String,
  caret : Int,
  selection : @core.TextRange?,
  replacement : String,
) -> TextEditResult {
  let range = match selection {
    Some(selection) => selection
    None => @core.TextRange::collapsed(caret)
  }
  // One fused core scan yields the next text, the deleted slice, and the
  // normalized start; no full char-array copy just to slice `before`.
  let (next_text, deleted, start) = @core.replace_text_range_edit(
    text~,
    range,
    replacement,
  )
  {
    text: next_text,
    caret: start + text_length(replacement),
    selection: None,
    start,
    before: deleted,
    after: replacement,
  }
}

///|
test {
  let boundaries = @core.TextGraphemeBoundaries::new(text="hello")
  ignore(@core.text_input_grapheme_selection(boundaries, None))
}

///|
test "rich text replace current selection advances caret by unicode scalars" {
  let edit = replace_current_selection(
    "a🤣b",
    1,
    Some(@core.TextRange::new(start=1, end=2)),
    "尾巴",
  )
  inspect(edit.text, content="a尾巴b")
  inspect(edit.caret, content="3")
}

///|
test "markdown image source resolves relative targets against base_dir" {
  let image = markdown_inline("cat.png", Image, target=Some("cat.png"))
  // Without a base_dir, the target is returned verbatim.
  @debug.debug_inspect(
    markdown_inline_image_source(image),
    content="Some(\"cat.png\")",
  )
  // With a base_dir, a relative target is rooted at the document directory.
  @debug.debug_inspect(
    markdown_inline_image_source(image, base_dir=Some("/tmp/docs")),
    content="Some(\"/tmp/docs/cat.png\")",
  )
  // Trailing separators are handled without doubling.
  @debug.debug_inspect(
    markdown_inline_image_source(image, base_dir=Some("/tmp/docs/")),
    content="Some(\"/tmp/docs/cat.png\")",
  )
  // Absolute paths, data: and http: URIs are never re-rooted.
  let abs = markdown_inline("/abs/cat.png", Image, target=Some("/abs/cat.png"))
  @debug.debug_inspect(
    markdown_inline_image_source(abs, base_dir=Some("/tmp/docs")),
    content="Some(\"/abs/cat.png\")",
  )
  let url = markdown_inline("u", Image, target=Some("https://e.com/c.png"))
  @debug.debug_inspect(
    markdown_inline_image_source(url, base_dir=Some("/tmp/docs")),
    content="Some(\"https://e.com/c.png\")",
  )
  let data = markdown_inline(
    "d",
    Image,
    target=Some("data:image/png;base64,AAAA"),
  )
  @debug.debug_inspect(
    markdown_inline_image_source(data, base_dir=Some("/tmp/docs")),
    content="Some(\"data:image/png;base64,AAAA\")",
  )
}

///|
test "code highlight tokenizer classifies moonbit keywords and strings" {
  let spans = code_highlight_tokenize("let x = \"hi\" // note", "moonbit")
  // Expect: keyword(let) plain(x =) string("hi") plain( ) comment(// note)
  let kinds = spans.map(span => span.kind)
  assert_true(kinds.contains(CodeKeyword))
  assert_true(kinds.contains(CodeString))
  assert_true(kinds.contains(CodeComment))
  // Supported languages still classify tokens instead of falling back.
  let python = code_highlight_tokenize(
    "# config\ndef add(a, b):\n    return a + b\n", "python",
  )
  assert_true(
    python.any(span => span.text == "# config" && span.kind == CodeComment),
  )
  assert_true(
    python.any(span => span.text == "def" && span.kind == CodeKeyword),
  )
  assert_true(
    python.any(span => span.text == "return" && span.kind == CodeKeyword),
  )
  assert_true(python.any(span => span.text == "a" && span.kind == CodePlain))
  assert_true(python.any(span => span.text == "+" && span.kind == CodeOperator))
  // Unsupported language falls back to a single plain span.
  let plain = code_highlight_tokenize("let x = 1", "perl")
  assert_eq(plain.length(), 1)
  assert_true(plain[0].kind == CodePlain)
}

///|
fn[Msg] text_input_messages(
  on_input : ((String) -> Msg)?,
  text : String,
) -> Array[Msg] {
  match on_input {
    Some(handler) => [handler(text)]
    None => []
  }
}

///|
fn[Msg] text_input_edit_messages(
  on_input_edit : ((String, Int, @core.TextRange?, RichTextInputEdit?) -> Msg)?,
  text : String,
  control : @core.TextControlStateContext,
  record? : RichTextInputEdit? = None,
) -> Array[Msg] {
  match on_input_edit {
    Some(handler) => [handler(text, control.caret, control.selection, record)]
    None => []
  }
}

///|
fn rich_text_exact_channel(
  key : @core.DeclarationKey,
) -> @core.DeclarationChannel {
  Exact(key)
}

///|
fn rich_text_declaration(
  layout? : @core.DeclarationChannel = Constant,
  paint? : @core.DeclarationChannel = Constant,
  semantics? : @core.DeclarationChannel = Constant,
  platform? : @core.DeclarationChannel = Constant,
) -> @core.ViewDeclaration {
  @core.ViewDeclaration::channels(layout~, paint~, semantics~, platform~)
}

///|
fn rich_text_record_key(
  type_tag : String,
  fields : Array[(String, @core.DeclarationKey)],
) -> @core.DeclarationKey {
  @core.DeclarationKey::record(type_tag~, fields~)
}

///|
fn declaration_point_key(point : @core.Point) -> @core.DeclarationKey {
  rich_text_record_key("Point", [
    ("x", @core.DeclarationKey::double(point.x)),
    ("y", @core.DeclarationKey::double(point.y)),
  ])
}

///|
fn declaration_size_key(size : @core.Size) -> @core.DeclarationKey {
  rich_text_record_key("Size", [
    ("width", @core.DeclarationKey::double(size.width)),
    ("height", @core.DeclarationKey::double(size.height)),
  ])
}

///|
fn declaration_color_key(color : @core.Color) -> @core.DeclarationKey {
  rich_text_record_key("Color", [
    ("r", @core.DeclarationKey::double(color.r)),
    ("g", @core.DeclarationKey::double(color.g)),
    ("b", @core.DeclarationKey::double(color.b)),
    ("a", @core.DeclarationKey::double(color.a)),
  ])
}

///|
fn declaration_brush_key(value : @core.Brush) -> @core.DeclarationKey {
  match value {
    Solid(color) =>
      rich_text_record_key("Brush.Solid", [
        ("color", declaration_color_key(color)),
      ])
    LinearGradient(spec) =>
      rich_text_record_key("Brush.LinearGradient", [
        ("start", declaration_point_key(spec.start)),
        ("end", declaration_point_key(spec.end)),
        ("start_color", declaration_color_key(spec.start_color)),
        ("end_color", declaration_color_key(spec.end_color)),
      ])
    RadialGradient(spec) =>
      rich_text_record_key("Brush.RadialGradient", [
        ("center", declaration_point_key(spec.center)),
        ("radius", @core.DeclarationKey::double(spec.radius)),
        ("center_color", declaration_color_key(spec.center_color)),
        ("edge_color", declaration_color_key(spec.edge_color)),
      ])
  }
}

///|
fn declaration_border_key(value : @core.BorderStyle) -> @core.DeclarationKey {
  rich_text_record_key("BorderStyle", [
    ("brush", declaration_brush_key(value.brush)),
    ("width", @core.DeclarationKey::double(value.width)),
  ])
}

///|
fn declaration_border_option_key(
  value : @core.BorderStyle?,
) -> @core.DeclarationKey {
  @core.DeclarationKey::option(value.map(declaration_border_key))
}

///|
fn declaration_text_range_key(value : @core.TextRange) -> @core.DeclarationKey {
  rich_text_record_key("TextRange", [
    ("start", @core.DeclarationKey::int(value.start)),
    ("end", @core.DeclarationKey::int(value.end)),
  ])
}

///|
fn declaration_text_range_option_key(
  value : @core.TextRange?,
) -> @core.DeclarationKey {
  @core.DeclarationKey::option(value.map(declaration_text_range_key))
}

///|
fn declaration_font_family_key(
  family : @core.FontFamily,
) -> @core.DeclarationKey {
  match family {
    Name(name) =>
      rich_text_record_key("FontFamily.Name", [
        ("name", @core.DeclarationKey::string(name)),
      ])
    SystemUi => @core.DeclarationKey::int(1)
    UiSansSerif => @core.DeclarationKey::int(2)
    UiSerif => @core.DeclarationKey::int(3)
    UiMonospace => @core.DeclarationKey::int(4)
    SansSerif => @core.DeclarationKey::int(5)
    Serif => @core.DeclarationKey::int(6)
    Monospace => @core.DeclarationKey::int(7)
    Emoji => @core.DeclarationKey::int(8)
    Math => @core.DeclarationKey::int(9)
    FangSong => @core.DeclarationKey::int(10)
  }
}

///|
fn declaration_font_key(font : @core.FontSpec) -> @core.DeclarationKey {
  rich_text_record_key("FontSpec", [
    (
      "families",
      @core.DeclarationKey::array(
        font.families.families.map(declaration_font_family_key),
      ),
    ),
    ("size", @core.DeclarationKey::double(font.size)),
    ("weight", @core.DeclarationKey::int(font.weight)),
    (
      "style",
      @core.DeclarationKey::int(
        match font.style {
          Normal => 0
          Italic => 1
        },
      ),
    ),
  ])
}

///|
fn[Msg] view_event_result(
  changed? : Bool = false,
  activated? : Bool = false,
  focused? : Bool = false,
  captured? : Bool = false,
  state? : @core.ViewStateContext? = None,
  text_control? : @core.TextControlStateContext? = None,
  dirty? : @core.ViewDirtyHint = ViewClean,
  messages? : Array[Msg] = [],
) -> @core.ViewEventResult[Msg] {
  {
    changed,
    activated,
    focused,
    captured,
    state,
    text_control,
    dirty,
    messages,
  }
}

///|
fn text_length(text : String) -> Int {
  // Route through the memoized length so per-frame caret/overlay math on
  // a whole-document text does not re-decode megabytes every frame.
  markdown_editor_text_length(text)
}

///|
fn max_double(a : Double, b : Double) -> Double {
  if a >= b {
    a
  } else {
    b
  }
}

///|
fn min_double(a : Double, b : Double) -> Double {
  if a <= b {
    a
  } else {
    b
  }
}

///|
fn clamp_double(value : Double, min : Double, max : Double) -> Double {
  if value < min {
    min
  } else if value > max {
    max
  } else {
    value
  }
}

///|
pub fn rich_text_editor_content_rect(frame : @core.Rect) -> @core.Rect {
  frame.inset(@core.Insets::symmetric(horizontal=14.0, vertical=2.0))
}

///|
fn string_prefix(text : String, end : Int) -> String {
  let chars = text.to_array()
  String::from_array(chars[:@core.clamp_int(end, 0, chars.length())])
}

///|
pub fn rich_text_document_height(
  document : RichTextDocument,
  base_font : @core.FontSpec,
  text_system? : @core.TextSystem = @core.TextSystem::fallback(),
  block_width? : Double = 0.0,
) -> Double {
  let mut height = 0.0
  for block in document.blocks {
    height = height +
      rich_text_block_line_height(block, base_font, text_system, block_width~) +
      block.inset.top +
      block.inset.bottom
  }
  height
}

///|
fn rich_text_block_line_height(
  block : RichTextBlock,
  base_font : @core.FontSpec,
  text_system : @core.TextSystem,
  block_width? : Double = 0.0,
) -> Double {
  let available_width = if block_width > 0.0 {
    max_double(0.0, block_width - block.inset.left - block.inset.right)
  } else {
    0.0
  }
  let mut height = 0.0
  for
    line_height in rich_text_block_line_heights(
      block, base_font, text_system, available_width,
    ) {
    height = height + line_height
  }
  match block.table {
    Some(table) => {
      // Rows grow with their tallest wrapped cell, so the band height stays
      // in sync with what the table painter draws.
      let table_height = rich_text_table_layout(
        table,
        block,
        base_font,
        frame_width=available_width,
        text_system~,
      ).total_height()
      height = max_double(height, table_height)
    }
    None => ()
  }
  height
}

///|
/// Return the height of each explicit visual line. Image galleries use this
/// rather than a single fixed line height so percentage-sized images can grow
/// with their content column without overlapping following rows.
fn rich_text_block_line_heights(
  block : RichTextBlock,
  base_font : @core.FontSpec,
  text_system : @core.TextSystem,
  available_width : Double,
) -> Array[Double] {
  let line_step = rich_text_block_line_step(block, base_font, text_system)
  let heights : Array[Double] = [line_step]
  for _ in 0.. Array[Double] {
  let block_font = rich_text_block_font(block, base_font)
  let widths : Array[Double] = [
    rich_text_block_marker_lane(block, block_font, text_system),
  ]
  for run in block.runs {
    let font = run.font.unwrap_or(block_font)
    let segments = rich_text_split_lines(rich_text_run_visual_text(run))
    for index, segment in segments {
      let line_index = widths.length() - 1
      widths[line_index] = widths[line_index] +
        rich_text_run_segment_width(
          run,
          segment,
          font,
          text_system,
          available_width~,
        )
      if index + 1 < segments.length() {
        widths.push(0.0)
      }
    }
  }
  widths
}

///|
fn rich_text_block_line_step(
  block : RichTextBlock,
  base_font : @core.FontSpec,
  text_system : @core.TextSystem,
) -> Double {
  block.line_height.unwrap_or(
    text_system.measure_text(
      @core.TextLayoutInput::new(
        text="Mg",
        font=rich_text_block_font(block, base_font),
      ),
    ).size.height,
  )
}

///|
fn rich_text_block_visual_line_count(block : RichTextBlock) -> Int {
  let mut line_count = 1
  line_count = line_count + rich_text_text_newline_count(block.prefix)
  for run in block.runs {
    line_count = line_count +
      rich_text_text_newline_count(rich_text_run_visual_text(run))
  }
  line_count
}

///|
fn rich_text_text_newline_count(text : String) -> Int {
  let mut count = 0
  for ch in text {
    if ch == '\n' {
      count = count + 1
    }
  }
  count
}

///|
fn rich_text_run_visual_text(run : RichTextRun) -> String {
  run.visual_text.unwrap_or(run.text)
}

///|
fn rich_text_block_font(
  block : RichTextBlock,
  base_font : @core.FontSpec,
) -> @core.FontSpec {
  block.font.unwrap_or(base_font)
}

///|
fn rich_text_block_color(
  block : RichTextBlock,
  foreground : @core.Color,
) -> @core.Color {
  block.color.unwrap_or(foreground)
}

///|
fn rich_text_width(
  text : String,
  font : @core.FontSpec,
  text_system : @core.TextSystem,
) -> Double {
  rich_text_run_width_with_cache(text, font, text_system~, measure=(text, font) => {
    text_system.measure_text(@core.TextLayoutInput::new(text~, font~)).size.width
  })
}

///|
fn rich_text_run_segment_width(
  run : RichTextRun,
  text : String,
  font : @core.FontSpec,
  text_system : @core.TextSystem,
  available_width? : Double = 0.0,
) -> Double {
  match rich_text_run_resolved_image_width(run, available_width) {
    Some(width) => width
    None => rich_text_width(text, font, text_system)
  }
}

///|
fn rich_text_run_segment_height(
  run : RichTextRun,
  line_height : Double,
  available_width? : Double = 0.0,
) -> Double {
  match run.image_size {
    Some(size) =>
      match rich_text_run_resolved_image_width(run, available_width) {
        Some(width) if size.width > 0.0 =>
          max_double(line_height, width * size.height / size.width)
        _ => max_double(line_height, size.height)
      }
    None => line_height
  }
}

///|

///|
/// Drawn width of an image run: a percentage-width image scales with the
/// content column, and any image is capped to the column so oversized
/// intrinsic sizes never overflow the page (the usual `max-width: 100%`).
fn rich_text_run_resolved_image_width(
  run : RichTextRun,
  available_width : Double,
) -> Double? {
  match run.image_size {
    Some(size) =>
      match run.image_width_fraction {
        Some(fraction) if available_width > 0.0 =>
          Some(
            max_double(
              0.0,
              min_double(
                available_width,
                available_width * clamp_double(fraction, 0.0, 1.0),
              ),
            ),
          )
        _ =>
          Some(
            if available_width > 0.0 {
              min_double(size.width, available_width)
            } else {
              size.width
            },
          )
      }
    None => None
  }
}

///|
fn rich_text_run_hit_width(run : RichTextRun, width : Double) -> Double? {
  match run.image_source {
    Some(_) => Some(width)
    _ => None
  }
}

///|
fn rich_text_split_lines(text : String) -> Array[String] {
  let lines : Array[String] = []
  let current : Array[Char] = []
  for ch in text {
    if ch == '\n' {
      lines.push(String::from_array(current))
      current.clear()
    } else {
      current.push(ch)
    }
  }
  lines.push(String::from_array(current))
  lines
}

///|
fn rich_text_input_grapheme_selection(
  boundaries : @core.TextGraphemeBoundaries,
  selection : @core.TextRange?,
) -> @core.TextRange? {
  match selection {
    Some(selection) => {
      let start = boundaries.nearest_boundary(selection.start)
      let end = boundaries.nearest_boundary(selection.end)
      if start == end {
        None
      } else {
        Some(@core.TextRange::new(start~, end~))
      }
    }
    _ => None
  }
}

///|
fn select_rich_text_control(
  control : @core.TextControlStateContext,
  text : String,
) -> @core.TextControlStateContext {
  let length = text_length(text)
  {
    ..control,
    caret: length,
    selection: Some(@core.TextRange::new(start=0, end=length)),
    text_selection_anchor: None,
    goal_column: None,
    composition: None,
    composition_cursor: None,
  }
}

///|
fn selected_rich_text(
  text : String,
  document : RichTextDocument,
  selection : @core.TextRange?,
) -> String {
  match @core.active_text_selection(selection) {
    Some(selection) =>
      if rich_text_document_has_source_ranges(document) {
        selected_rich_text_document_text(document, selection)
      } else {
        @core.selected_text(text, Some(selection))
      }
    None => ""
  }
}

///|
fn selected_rich_text_document_text(
  document : RichTextDocument,
  selection : @core.TextRange,
) -> String {
  let lines : Array[String] = []
  for block in document.blocks {
    match selected_rich_text_block_text(block, selection) {
      Some(text) => lines.push(text)
      None => ()
    }
  }
  join_text_lines(lines)
}

///|
fn selected_rich_text_block_text(
  block : RichTextBlock,
  selection : @core.TextRange,
) -> String? {
  match (block.source_range, block.content_range) {
    (Some(source), Some(content)) =>
      if selection.end <= source.start || selection.start >= source.end {
        None
      } else {
        let start = @core.clamp_int(selection.start, content.start, content.end)
        let end = @core.clamp_int(selection.end, content.start, content.end)
        let text = selected_rich_text_runs_text(block, start, end)
        if text == "" && start != end {
          None
        } else {
          Some(text)
        }
      }
    (_, Some(content)) =>
      if selection.end <= content.start || selection.start >= content.end {
        None
      } else {
        let start = @core.clamp_int(selection.start, content.start, content.end)
        let end = @core.clamp_int(selection.end, content.start, content.end)
        let text = selected_rich_text_runs_text(block, start, end)
        if text == "" && start != end {
          None
        } else {
          Some(text)
        }
      }
    _ => None
  }
}

///|
fn selected_rich_text_runs_text(
  block : RichTextBlock,
  start : Int,
  end : Int,
) -> String {
  let parts : Array[String] = []
  for run in block.runs {
    match run.source_range {
      Some(range) =>
        if end > range.start && start < range.end {
          parts.push(
            rich_text_run_copy_range(
              run,
              @core.clamp_int(start - range.start, 0, text_length(run.text)),
              @core.clamp_int(end - range.start, 0, text_length(run.text)),
            ),
          )
        }
      None => ()
    }
  }
  join_text_lines(parts)
}

///|
/// Copy slice for a run between source-text offsets. Wrapped runs carry
/// injected soft-wrap newlines in their visual text; the alignment scan
/// skips exactly those characters so copying a wrapped paragraph yields
/// the canonical source text, matching a plain selection of the same
/// range. Image runs and unwrapped runs keep the existing path: image
/// copy text is alt text, a different string rather than text plus breaks.
fn rich_text_run_copy_range(
  run : RichTextRun,
  start : Int,
  end : Int,
) -> String {
  let copy_text = rich_text_run_copy_text(run)
  if run.image_source is Some(_) || copy_text == run.text {
    let slice_start = rich_text_run_copy_offset(
      start,
      source_text=run.text,
      copy_text~,
    )
    let slice_end = rich_text_run_copy_offset(
      end,
      source_text=run.text,
      copy_text~,
    )
    return rich_text_run_slice(copy_text, slice_start, slice_end)
  }
  let source = run.text.to_array()
  let copy = copy_text.to_array()
  let chars : Array[Char] = []
  let mut source_index = 0
  let mut copy_index = 0
  while source_index < source.length() && copy_index < copy.length() {
    if copy[copy_index] == source[source_index] {
      if source_index >= start && source_index < end {
        chars.push(copy[copy_index])
      }
      source_index = source_index + 1
      copy_index = copy_index + 1
    } else if copy[copy_index] == '\n' {
      copy_index = copy_index + 1
    } else {
      if source_index >= start && source_index < end {
        chars.push(copy[copy_index])
      }
      source_index = source_index + 1
      copy_index = copy_index + 1
    }
  }
  String::from_array(chars)
}

///|
fn rich_text_run_copy_text(run : RichTextRun) -> String {
  match run.visual_text {
    Some(text) => text
    None =>
      match run.image_source {
        Some(_) => rich_text_image_alt_text(run.text).unwrap_or(run.text)
        None => run.text
      }
  }
}

///|
fn rich_text_run_copy_offset(
  source_offset : Int,
  source_text~ : String,
  copy_text~ : String,
) -> Int {
  let source_length = text_length(source_text)
  let copy_length = text_length(copy_text)
  if source_length <= 0 || copy_length <= 0 {
    0
  } else {
    @core.clamp_int(
      (source_offset.to_double() *
      copy_length.to_double() /
      source_length.to_double())
      .round()
      .to_int(),
      0,
      copy_length,
    )
  }
}

///|
fn rich_text_image_alt_text(text : String) -> String? {
  let chars = text.to_array()
  if chars.length() < 5 || chars[0] != '!' || chars[1] != '[' {
    return None
  }
  let mut index = 2
  while index < chars.length() {
    if chars[index] == ']' &&
      index + 1 < chars.length() &&
      chars[index + 1] == '(' {
      return Some(String::from_array(chars[2:index]))
    }
    index = index + 1
  }
  None
}

///|
fn rich_text_run_slice(text : String, start : Int, end : Int) -> String {
  let chars = text.to_array()
  let range = @core.normalize_grapheme_range(
    @core.TextGraphemeBoundaries::new(text~),
    @core.TextRange::new(start~, end~),
  )
  let start = @core.clamp_int(range.start, 0, chars.length())
  let end = @core.clamp_int(range.end, start, chars.length())
  String::from_array(chars[start:end])
}

///|
fn join_text_lines(lines : Array[String]) -> String {
  let chars : Array[Char] = []
  for index in 0.. 0 {
      chars.push('\n')
    }
    chars.append(lines[index].to_array())
  }
  String::from_array(chars)
}

///|
fn rich_text_document_has_source_ranges(document : RichTextDocument) -> Bool {
  document.blocks.any(block => {
    block.source_range is Some(_) ||
    block.content_range is Some(_) ||
    block.runs.any(run => run.source_range is Some(_)) ||
    rich_text_block_table_has_source_ranges(block)
  })
}

///|
fn rich_text_block_table_has_source_ranges(block : RichTextBlock) -> Bool {
  match block.table {
    Some(table) =>
      table.rows.any(row => row.any(cell => cell.source_range is Some(_)))
    None => false
  }
}

///|
fn rich_text_table_column_count(table : RichTextTable) -> Int {
  let mut count = 0
  for row in table.rows {
    count = @core.max_int(count, row.length())
  }
  count
}

///|
fn rich_text_block_cache_key(
  index : Int,
  block : RichTextBlock,
  frame : @core.Rect,
) -> String {
  // 注意:不要把 frame.origin 纳入 cache_key。
  // 渲染器在栅格化 cached layer 时会做 translate(-origin.x, -origin.y) 抵消,
  // pixmap 内容只依赖 block 自身 + frame.size,与 origin 无关。
  // origin 仅在 draw_cached_layer_pixmap 贴图阶段用于定位。
  // 滚动时让节点重建拿到新 origin,是通过 facade.mbt 的 paint declaration
  // key 精确编码 scroll_y 实现;该路径不依赖 layer cache 失效,layer 仍可
  // 命中并以新 frame 贴图。
  // 把 origin 编码进 cache_key 反而会导致滚动时所有 block 缓存全 miss,触发
  // 重栅格化与缓存驱逐抖动,造成滚动后明显卡顿。
  "rich-text-block;range=\{rich_text_source_range_key(block.source_range)};index=\{index};w=\{frame.size.width};h=\{frame.size.height}"
}

///|
fn rich_text_block_content_revision(
  block : RichTextBlock,
  index : Int,
  frame : @core.Rect,
  base_font : @core.FontSpec,
  foreground : @core.Color,
) -> Int {
  let mut hash = rich_text_hash_mix_int(5381, index)
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_source_range_key(block.source_range),
  )
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_source_range_key(block.content_range),
  )
  hash = rich_text_hash_mix_string(hash, block.prefix)
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_font_key(block.font.unwrap_or(base_font)),
  )
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_color_key(block.color.unwrap_or(foreground)),
  )
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_brush_optional_key(block.background),
  )
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_border_optional_key(block.border_left),
  )
  hash = rich_text_hash_mix_string(hash, rich_text_insets_key(block.inset))
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_double_optional_key(block.line_height),
  )
  hash = rich_text_hash_mix_string(
    hash,
    rich_text_align_optional_key(block.line_align),
  )
  hash = rich_text_hash_mix_string(hash, "\{block.corner_radius}")
  hash = rich_text_hash_mix_int(hash, if block.rule { 1 } else { 0 })
  hash = rich_text_hash_mix_string(
    hash,
    "\{frame.size.width}x\{frame.size.height}",
  )
  for run in block.runs {
    hash = rich_text_hash_mix_string(hash, run.text)
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_optional_string_key(run.visual_text),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_optional_string_key(run.image_source),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_size_optional_key(run.image_size),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_double_optional_key(run.image_width_fraction),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_image_fit_key(run.image_fit),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_font_optional_key(run.font),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_color_optional_key(run.color),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_brush_optional_key(run.background),
    )
    hash = rich_text_hash_mix_string(hash, "\{run.background_radius}")
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_decoration_key(run.decoration),
    )
    hash = rich_text_hash_mix_string(
      hash,
      rich_text_source_range_key(run.source_range),
    )
  }
  match block.table {
    Some(table) => {
      hash = rich_text_hash_mix_string(hash, "\{table.row_height}")
      hash = rich_text_hash_mix_string(
        hash,
        rich_text_insets_key(table.cell_padding),
      )
      hash = rich_text_hash_mix_string(hash, rich_text_color_key(table.border))
      hash = rich_text_hash_mix_string(
        hash,
        rich_text_brush_optional_key(table.header_background),
      )
      for row in table.rows {
        for cell in row {
          hash = rich_text_hash_mix_string(hash, cell.text)
          hash = rich_text_hash_mix_int(hash, if cell.header { 1 } else { 0 })
          hash = rich_text_hash_mix_string(
            hash,
            rich_text_align_optional_key(cell.align),
          )
          hash = rich_text_hash_mix_string(
            hash,
            rich_text_source_range_key(cell.source_range),
          )
        }
      }
    }
    None => ()
  }
  if hash < 0 {
    -hash
  } else {
    hash
  }
}

///|
fn rich_text_source_range_key(range : RichTextSourceRange?) -> String {
  match range {
    Some(range) => "len=\{@core.max_int(0, range.end - range.start)}"
    None => "none"
  }
}

///|
fn rich_text_optional_string_key(value : String?) -> String {
  value.unwrap_or("none")
}

///|
fn rich_text_size_optional_key(value : @core.Size?) -> String {
  match value {
    Some(size) => "\{size.width}x\{size.height}"
    None => "none"
  }
}

///|
fn rich_text_double_optional_key(value : Double?) -> String {
  match value {
    Some(value) => "\{value}"
    None => "none"
  }
}

///|
fn rich_text_font_optional_key(value : @core.FontSpec?) -> String {
  match value {
    Some(font) => rich_text_font_key(font)
    None => "none"
  }
}

///|
fn rich_text_font_key(font : @core.FontSpec) -> String {
  "\{font.css_family()}|\{font.size}|\{font.weight}|\{font.css_style()}"
}

///|
fn rich_text_color_optional_key(value : @core.Color?) -> String {
  match value {
    Some(color) => rich_text_color_key(color)
    None => "none"
  }
}

///|
fn rich_text_color_key(color : @core.Color) -> String {
  "\{color.r},\{color.g},\{color.b},\{color.a}"
}

///|
fn rich_text_brush_optional_key(value : @core.Brush?) -> String {
  match value {
    Some(brush) => rich_text_brush_key(brush)
    None => "none"
  }
}

///|
fn rich_text_brush_key(brush : @core.Brush) -> String {
  match brush {
    Solid(color) => "solid:\{rich_text_color_key(color)}"
    LinearGradient(spec) =>
      "linear:\{spec.start.x},\{spec.start.y},\{spec.end.x},\{spec.end.y},\{rich_text_color_key(spec.start_color)},\{rich_text_color_key(spec.end_color)}"
    RadialGradient(spec) =>
      "radial:\{spec.center.x},\{spec.center.y},\{spec.radius},\{rich_text_color_key(spec.center_color)},\{rich_text_color_key(spec.edge_color)}"
  }
}

///|
fn rich_text_border_optional_key(value : @core.BorderStyle?) -> String {
  match value {
    Some(border) => "\{rich_text_brush_key(border.brush)}|\{border.width}"
    None => "none"
  }
}

///|
fn rich_text_insets_key(insets : @core.Insets) -> String {
  "\{insets.top},\{insets.right},\{insets.bottom},\{insets.left}"
}

///|
fn rich_text_image_fit_key(fit : @core.ImageFit) -> String {
  match fit {
    Contain => "contain"
    Cover => "cover"
    Stretch => "stretch"
    ScaleDown => "scale-down"
    FitWidth => "fit-width"
    FitHeight => "fit-height"
  }
}

///|
fn rich_text_decoration_key(value : RichTextDecoration?) -> String {
  match value {
    Some(Strikethrough) => "strikethrough"
    None => "none"
  }
}

///|
fn rich_text_align_optional_key(value : @core.TextAlign?) -> String {
  match value {
    Some(TextStart) => "start"
    Some(TextCenter) => "center"
    Some(TextEnd) => "end"
    None => "none"
  }
}

///|
fn rich_text_string_hash(value : String) -> Int {
  let mut hash = 5381
  for ch in value.to_array() {
    hash = hash * 33 + ch.to_int()
  }
  hash
}

///|
fn rich_text_hash_mix_int(hash : Int, value : Int) -> Int {
  hash * 33 + value
}

///|
fn rich_text_hash_mix_string(hash : Int, value : String) -> Int {
  hash * 33 + rich_text_string_hash(value)
}

///|
/// Exact source↔visual alignment for a run. The visual text is the run's
/// text plus soft-wrap line breaks injected by the shared wrap layout;
/// every injected `'\n'` is flanked by non-newline characters (the viewer
/// wrap refuses to insert before or after a real newline), so aligning
/// the two strings left to right and skipping visual newlines that the
/// source does not have at the same position is exact — no proportional
/// guesswork. Runs without a wrap layer take the equality fast path.
fn rich_text_run_visual_offset_from_source_offset(
  source_offset : Int,
  source_text : String,
  visual_text : String,
) -> Int {
  if source_text == visual_text {
    let clamped = @core.clamp_int(source_offset, 0, text_length(visual_text))
    return clamped
  }
  let source = source_text.to_array()
  let visual = visual_text.to_array()
  let target = @core.clamp_int(source_offset, 0, source.length())
  let mut source_index = 0
  let mut visual_index = 0
  while source_index < target && visual_index < visual.length() {
    if visual[visual_index] == source[source_index] {
      source_index = source_index + 1
      visual_index = visual_index + 1
    } else if visual[visual_index] == '\n' {
      visual_index = visual_index + 1
    } else {
      source_index = source_index + 1
      visual_index = visual_index + 1
    }
  }
  @core.clamp_int(visual_index, 0, visual.length())
}

///|
fn rich_text_run_source_offset_from_visual_offset(
  visual_offset : Int,
  visual_text : String,
  source_text : String,
) -> Int {
  if source_text == visual_text {
    let clamped = @core.clamp_int(visual_offset, 0, text_length(source_text))
    return clamped
  }
  let source = source_text.to_array()
  let visual = visual_text.to_array()
  let target = @core.clamp_int(visual_offset, 0, visual.length())
  let mut source_index = 0
  let mut visual_index = 0
  while visual_index < target &&
        source_index < source.length() &&
        visual_index < visual.length() {
    if visual[visual_index] == source[source_index] {
      source_index = source_index + 1
      visual_index = visual_index + 1
    } else if visual[visual_index] == '\n' {
      visual_index = visual_index + 1
    } else {
      source_index = source_index + 1
      visual_index = visual_index + 1
    }
  }
  @core.clamp_int(source_index, 0, source.length())
}

///|
fn[Msg] selection_messages(
  on_selection_change : ((Int, @core.TextRange?) -> Msg)?,
  caret : Int,
  selection : @core.TextRange?,
) -> Array[Msg] {
  match on_selection_change {
    Some(handler) => [handler(caret, selection)]
    None => []
  }
}

///|
/// Whether a source offset falls inside a text range. A right press inside the
/// current selection keeps it instead of collapsing it, so the context menu's
/// inline formatting still applies to the selected run.
fn rich_text_selection_covers(range : @core.TextRange?, offset : Int) -> Bool {
  match range {
    Some(range) =>
      !range.is_collapsed() && offset >= range.start && offset <= range.end
    None => false
  }
}