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

///|
priv struct TextHistoryRestoreResult {
  text : String
  control : @core.TextControlStateContext
  changed : Bool
}

///|
priv struct TextInputApplyResult {
  text : String
  control : @core.TextControlStateContext
  changed : Bool
}

///|
fn restore_text_history(
  control : @core.TextControlStateContext,
  current : String,
  undo~ : Bool,
) -> TextHistoryRestoreResult {
  let source = if undo { control.undo_stack } else { control.redo_stack }
  if source.length() == 0 {
    return { text: current, control, changed: false }
  }
  let restored = source[source.length() - 1]
  let next_source = source[:source.length() - 1].to_owned()
  let current_entry : @core.TextHistoryEntryContext = {
    text: current,
    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,
    }
  }
  {
    text: restored.text,
    control: next_control,
    changed: restored.text != current,
  }
}

///|
fn apply_text_input(
  control : @core.TextControlStateContext,
  text : String,
  event : @core.TextInputEvent,
  record_history~ : Bool,
) -> TextInputApplyResult {
  match event {
    @core.TextInputEvent::InsertText(inserted) => {
      if inserted == "" {
        return { text, control, changed: false }
      }
      let edit = replace_current_selection(
        text,
        control.caret,
        control.selection,
        inserted,
      )
      apply_text_edit(control, text, edit, record_history~)
    }
    @core.TextInputEvent::CompositionStart => {
      let next_control = {
        ..control,
        composition: Some(""),
        composition_cursor: None,
      }
      { text, control: next_control, changed: true }
    }
    @core.TextInputEvent::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 }
    }
    @core.TextInputEvent::CompositionEnd(inserted) => {
      let next_control = {
        ..control,
        composition: None,
        composition_cursor: None,
      }
      if inserted == "" {
        { text, control: { ..next_control, selection: None }, changed: true }
      } else {
        let edit = replace_current_selection(
          text,
          next_control.caret,
          next_control.selection,
          inserted,
        )
        apply_text_edit(next_control, text, edit, record_history~)
      }
    }
    @core.TextInputEvent::DeleteRange(range) => {
      let next_text = @core.replace_text_range(text, range, "")
      let edit : TextEditResult = {
        text: next_text,
        caret: range.normalized().start,
        selection: None,
      }
      apply_text_edit(control, text, edit, record_history~)
    }
    @core.TextInputEvent::DeleteSurrounding(before, after) => {
      let range = @core.surrounding_delete_range(
        text,
        control.caret,
        before,
        after,
      )
      let next_text = @core.replace_text_range(text, range, "")
      let edit : TextEditResult = {
        text: next_text,
        caret: range.start,
        selection: None,
      }
      apply_text_edit(control, text, edit, record_history~)
    }
  }
}

///|
fn apply_text_edit(
  control : @core.TextControlStateContext,
  previous : String,
  edit : TextEditResult,
  record_history~ : Bool,
) -> TextInputApplyResult {
  let changed = edit.text != previous ||
    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 && previous != edit.text {
      undo_stack.push({
        text: previous,
        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,
    }
  } else {
    { text: previous, control, changed: false }
  }
}

///|
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)
  }
  let normalized = range.normalized()
  let next_text = @core.replace_text_range(text, range, replacement)
  let caret = normalized.start + text_length(replacement)
  { text: next_text, caret, selection: None }
}

///|
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?) -> Msg)?,
  text : String,
  control : @core.TextControlStateContext,
) -> Array[Msg] {
  match on_input_edit {
    Some(handler) => [handler(text, control.caret, control.selection)]
    None => []
  }
}

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

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

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

///|
fn view_text_range_option_revision(value : @core.TextRange?) -> String {
  match value {
    Some(value) => "\{value.start}:\{value.end}"
    None => "none"
  }
}

///|
fn view_font_revision(font : @core.FontSpec) -> String {
  let style = match font.style {
    @core.FontStyle::Normal => "normal"
    @core.FontStyle::Italic => "italic"
  }
  "\{font.size}:\{font.weight}:\{style}"
}

///|
fn view_size_revision(size : @core.Size) -> String {
  "\{size.width}x\{size.height}"
}

///|
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 = @core.ViewDirtyHint::ViewClean,
  messages? : Array[Msg] = [],
) -> @core.ViewEventResult[Msg] {
  {
    changed,
    activated,
    focused,
    captured,
    state,
    text_control,
    dirty,
    messages,
  }
}

///|
fn text_length(text : String) -> Int {
  text.char_length()
}

///|
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
  }
}