// Cross-target pure helpers for session search.
//
// Split out of session_search.mbt so the truncation / formatting
// routines (and the timestamp renderer) compile on --target native.
// The Promise.all-based LLM concurrency orchestration and the
// top-level `session_search` async fn remain in session_search.mbt
// (JS-only) until we add a native concurrency story.

///|
fn srch_max(a : Int, b : Int) -> Int {
  if a > b {
    a
  } else {
    b
  }
}

///|
fn srch_min(a : Int, b : Int) -> Int {
  if a < b {
    a
  } else {
    b
  }
}

///|
fn srch_abs(a : Int) -> Int {
  if a < 0 {
    -a
  } else {
    a
  }
}

///| Find every starting index of `pattern` inside `text` (both
///| case-folded to lowercase). Returns empty when pattern is empty.
///| Substring match — no regex needed, cross-target.
fn _find_all_positions(text : String, pattern : String) -> Array[Int] {
  let out : Array[Int] = []
  if pattern.is_empty() {
    return out
  }
  let t = text.to_lower()
  let p = pattern.to_lower()
  let tv = t.view()
  let pv = p.view()
  let tl = tv.length()
  let pl = pv.length()
  if pl > tl {
    return out
  }
  let mut i = 0
  while i <= tl - pl {
    let mut j = 0
    let mut ok = true
    while j < pl {
      if tv.get_char(i + j) != pv.get_char(j) {
        ok = false
        break
      }
      j = j + 1
    }
    if ok {
      out.push(i)
    }
    i = i + 1
  }
  out
}

///| Tokenize on ASCII whitespace, lowercase, and drop empties.
fn _split_words(s : String) -> Array[String] {
  let tokens : Array[String] = []
  let buf = StringBuilder::new()
  let view = s.to_lower().view()
  let flush = fn() {
    let t = buf.to_string()
    if !t.is_empty() {
      tokens.push(t)
      buf.reset()
    }
  }
  let mut i = 0
  while i < view.length() {
    let ch = match view.get_char(i) {
      Some(c) => c
      None => ' '
    }
    if ch == ' ' ||
      ch == '\t' ||
      ch == '\n' ||
      ch == '\r' ||
      ch == '\u{000C}' ||
      ch == '\u{000B}' {
      flush()
    } else {
      buf.write_char(ch)
    }
    i = i + 1
  }
  flush()
  tokens
}

///| Extract `name` field (or `function.name`) from each entry of a
///| JSON-serialized tool_calls array. Returns an empty array on parse
///| failure or non-array input. Uses moonbitlang/core/json so it stays
///| cross-target.
fn _parse_tool_call_names(json_str : String) -> Array[String] {
  let out : Array[String] = []
  let root = try {
    @json.parse(json_str)
  } catch {
    _ => return out
  }
  match root {
    Json::Array(items) =>
      for item in items {
        match item {
          Json::Object(obj) => {
            let top = match obj.get("name") {
              Some(Json::String(s)) => Some(s)
              _ => None
            }
            let name = match top {
              Some(s) => s
              None =>
                match obj.get("function") {
                  Some(Json::Object(f)) =>
                    match f.get("name") {
                      Some(Json::String(s)) => s
                      _ => "?"
                    }
                  _ => "?"
                }
            }
            out.push(name)
          }
          _ => ()
        }
      }
    _ => ()
  }
  out
}

// ── truncate_around_matches ──

pub let max_session_chars : Int = 100000

///|
pub fn truncate_around_matches(
  full_text : String,
  query : String,
  max_chars~ : Int = max_session_chars
) -> String {
  if full_text.length() <= max_chars {
    return full_text
  }
  let query_trimmed = query.to_lower().trim().to_string()

  // 1. Full phrase search
  let phrase_positions : Array[Int] = _find_all_positions(
    full_text, query_trimmed,
  )
  let match_positions : Array[Int] = []
  if phrase_positions.length() > 0 {
    for p in phrase_positions {
      match_positions.push(p)
    }
  }

  // 2. Proximity co-occurrence (all terms within 200 chars of rarest)
  if match_positions.length() == 0 {
    let terms = _split_words(query)
    if terms.length() > 1 {
      let term_pos : Array[(String, Array[Int])] = terms.map(fn(t) {
        (t, _find_all_positions(full_text, t))
      })
      let mut rarest_idx = 0
      let mut rarest_len = term_pos[0].1.length()
      let mut ti = 1
      while ti < term_pos.length() {
        let cur_len = term_pos[ti].1.length()
        if cur_len < rarest_len {
          rarest_len = cur_len
          rarest_idx = ti
        }
        ti = ti + 1
      }
      let (rarest_term, rarest_positions) = term_pos[rarest_idx]
      for pos in rarest_positions {
        let mut all_near = true
        let mut j = 0
        while j < term_pos.length() {
          let (t, positions) = term_pos[j]
          if t != rarest_term {
            let mut found_near = false
            let mut k = 0
            while k < positions.length() {
              if srch_abs(positions[k] - pos) < 200 {
                found_near = true
                break
              }
              k = k + 1
            }
            if !found_near {
              all_near = false
              break
            }
          }
          j = j + 1
        }
        if all_near {
          match_positions.push(pos)
        }
      }
    }
  }

  // 3. Individual term positions (last resort)
  if match_positions.length() == 0 {
    let terms = _split_words(query)
    for t in terms {
      let pos = _find_all_positions(full_text, t)
      for p in pos {
        match_positions.push(p)
      }
    }
  }

  // No hits — take head
  if match_positions.length() == 0 {
    let truncated = full_text[0:max_chars].to_string()
    return truncated + "\n\n...[later conversation truncated]..."
  }

  // Sort positions ascending
  let sorted = match_positions.copy()
  sorted.sort_by(fn(a, b) { a - b })

  // Pick window covering the most positions (25% bias before match)
  let mut best_start = 0
  let mut best_count = 0
  for candidate in sorted {
    let ws = srch_max(0, candidate - max_chars / 4)
    let we = ws + max_chars
    let mut count = 0
    for p in sorted {
      if p >= ws && p < we {
        count = count + 1
      }
    }
    if count > best_count {
      best_count = count
      best_start = ws
    }
  }

  // Clamp to end of text
  if best_start + max_chars > full_text.length() {
    best_start = srch_max(0, full_text.length() - max_chars)
  }
  let start = best_start
  let end = srch_min(full_text.length(), start + max_chars)
  let truncated = full_text[start:end].to_string()
  let prefix = if start > 0 {
    "...[earlier conversation truncated]...\n\n"
  } else {
    ""
  }
  let suffix = if end < full_text.length() {
    "\n\n...[later conversation truncated]..."
  } else {
    ""
  }
  prefix + truncated + suffix
}

// ── format_conversation ──

///|
pub struct MsgForFormat {
  role : String
  content : String
  tool_name : String
  tool_calls : String
}

///|
pub fn format_conversation(messages : Array[MsgForFormat]) -> String {
  let parts : Array[String] = []
  for msg in messages {
    let role = msg.role.to_upper()
    let content = msg.content
    if role == "TOOL" && msg.tool_name != "" {
      let c = if content.length() > 500 {
        content[0:250].to_string() +
          "\n...[truncated]...\n" +
          content[(content.length() - 250):content.length()].to_string()
      } else {
        content
      }
      parts.push("[TOOL:\{msg.tool_name}]: \{c}")
    } else if role == "ASSISTANT" {
      if msg.tool_calls != "" {
        let names = _parse_tool_call_names(msg.tool_calls)
        if names.length() > 0 {
          let joined = names.join(", ")
          parts.push("[ASSISTANT]: [Called: \{joined}]")
        }
      }
      if content != "" {
        parts.push("[ASSISTANT]: \{content}")
      }
    } else {
      parts.push("[\{role}]: \{content}")
    }
  }
  parts.join("\n\n")
}

///|
pub struct SearchResult {
  session_id : String
  when : String
  source : String
  model : String
  summary : String
}

///|
pub struct SessionSearchResponse {
  ok : Bool
  query : String
  results : Array[SearchResult]
  count : Int
  sessions_searched : Int
}

///| Render an epoch-ms timestamp as "YYYY-MM-DD HH:MM UTC". Uses
///| moonbitlang/x/time so it runs on both backends. Returns "unknown"
///| if parsing fails.
fn _format_timestamp(ts_str : String) -> String {
  let ms = (try? @string.parse_int64(ts_str.view())).unwrap_or(0L)
  if ms == 0L {
    return "unknown"
  }
  let secs = ms / 1000L
  let zdt_res = (try? @time.unix(secs))
  match zdt_res {
    Ok(zdt) => {
      let d = zdt.to_plain_date_time()
      let pad2 = fn(n : Int) -> String {
        if n < 10 {
          "0" + n.to_string()
        } else {
          n.to_string()
        }
      }
      d.year().to_string() +
        "-" +
        pad2(d.month()) +
        "-" +
        pad2(d.day()) +
        " " +
        pad2(d.hour()) +
        ":" +
        pad2(d.minute()) +
        " UTC"
    }
    Err(_) => ts_str
  }
}

// ── inline tests ──

///|
test "truncate: returns full text when short" {
  let text = "hello world"
  let result = truncate_around_matches(text, "hello")
  assert_eq(result, text)
}

///|
test "truncate: adds later marker when no match and long text" {
  let text = "x".repeat(200)
  let result = truncate_around_matches(text, "notfound", max_chars=50)
  assert_eq(result.contains("[later conversation truncated]"), true)
}

///|
test "truncate: phrase fallback finds phrase" {
  let prefix = "x".repeat(1000)
  let suffix = "y".repeat(1000)
  let text = prefix + "target phrase" + suffix
  let result = truncate_around_matches(text, "target phrase", max_chars=200)
  assert_eq(result.contains("target phrase"), true)
}

///|
test "truncate: proximity fallback finds terms near each other" {
  let far = "z".repeat(2000)
  let text = far + "alpha blah beta" + "z".repeat(2000)
  let result = truncate_around_matches(text, "alpha beta", max_chars=500)
  assert_eq(result.contains("alpha"), true)
  assert_eq(result.contains("beta"), true)
}

///|
test "truncate: individual term fallback finds at least one term" {
  let far = "z".repeat(2000)
  let text = far + "foo" + "z".repeat(2000)
  let result = truncate_around_matches(text, "foo bar", max_chars=200)
  assert_eq(result.contains("foo"), true)
}

///|
test "format_conversation: user message" {
  let msgs : Array[MsgForFormat] = [
    { role: "user", content: "hello", tool_name: "", tool_calls: "" },
  ]
  let result = format_conversation(msgs)
  assert_eq(result, "[USER]: hello")
}

///|
test "format_conversation: assistant with content" {
  let msgs : Array[MsgForFormat] = [
    { role: "assistant", content: "I can help", tool_name: "", tool_calls: "" },
  ]
  let result = format_conversation(msgs)
  assert_eq(result, "[ASSISTANT]: I can help")
}

///|
test "format_conversation: tool truncates long content" {
  let long_content = "x".repeat(600)
  let msgs : Array[MsgForFormat] = [
    { role: "tool", content: long_content, tool_name: "bash", tool_calls: "" },
  ]
  let result = format_conversation(msgs)
  assert_eq(result.contains("[truncated]"), true)
  assert_eq(result.length() < long_content.length(), true)
}

///|
test "format_conversation: multiple messages joined" {
  let msgs : Array[MsgForFormat] = [
    { role: "user", content: "hi", tool_name: "", tool_calls: "" },
    { role: "assistant", content: "hello", tool_name: "", tool_calls: "" },
  ]
  let result = format_conversation(msgs)
  assert_eq(result.contains("[USER]: hi"), true)
  assert_eq(result.contains("[ASSISTANT]: hello"), true)
}