// 每 user 物理分片的内置 BM25 关键词索引。
// 分词器:CJK 单字 + 相邻二元组;拉丁字母数字串按词(统一小写)。
// 与 HashingEmbedder 共用同一 tokenize,保证语义一致性。

///|
/// BM25 参数(经典默认值);package-private.
const BM25_K1 : Double = 1.5

///|
const BM25_B : Double = 0.75

///|
/// 判断是否 CJK 表意字符(含扩展 A 区与兼容表意区)。
fn is_cjk_char(c : Char) -> Bool {
  let v = c.to_int()
  if v >= 0x3400 {
    if v <= 0x4DBF {
      return true // CJK 扩展 A
    }
    if v >= 0x4E00 {
      if v <= 0x9FFF {
        return true // CJK 基本区
      }
      if v >= 0xF900 && v <= 0xFAFF {
        return true // CJK 兼容表意区
      }
    }
  }
  false
}

///|
fn is_latin_word_char(c : Char) -> Bool {
  (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}

///|
/// 分词器:CJK 逐字出单字 token,相邻 CJK 字额外出二元组 token;
/// 拉丁字母数字连续串出一个词 token(小写化);其余字符为分隔符。
/// 确定性、无外部依赖,三后端一致;被 BM25 索引与 HashingEmbedder 共用。
fn tokenize(text : String) -> Array[String] {
  let tokens : Array[String] = []
  let buf = StringBuilder()
  let mut prev_cjk : Char? = None
  for c in text.iter() {
    if is_cjk_char(c) {
      flush_word(buf, tokens)
      tokens.push("\{c}")
      match prev_cjk {
        Some(p) => tokens.push("\{p}\{c}")
        None => ()
      }
      prev_cjk = Some(c)
    } else if is_latin_word_char(c) {
      buf.write_char(c)
      prev_cjk = None
    } else {
      flush_word(buf, tokens)
      prev_cjk = None
    }
  }
  flush_word(buf, tokens)
  tokens
}

///|
fn flush_word(buf : StringBuilder, tokens : Array[String]) -> Unit {
  if buf.is_empty() == false {
    tokens.push(buf.to_string().to_lower())
    buf.reset()
  }
}

// ---------------------------------------------------------------------------
// BM25 分片索引
// ---------------------------------------------------------------------------

///|
priv struct Bm25Doc {
  tf : Map[String, Int32]
  len : Int32
}

///|
/// BM25 索引:按 user 物理分片,维护每 user 的 df 与总词长。
/// Package-private (abstract): host API is MemoryStore only.
struct KeywordIndex {
  // user → (entry_id → doc)
  shards : Map[String, Map[String, Bm25Doc]]
  // user → (term → 文档频率 df)
  df : Map[String, Map[String, Int32]]
  // user → 分片总词长(所有 doc len 之和)
  total_len : Map[String, Int32]
}

///|
fn KeywordIndex::new() -> KeywordIndex {
  KeywordIndex::{ shards: Map([]), df: Map([]), total_len: Map([]), }
}

///|
fn KeywordIndex::shard(
  self : KeywordIndex,
  user_id : String,
) -> Map[String, Bm25Doc] {
  match self.shards.get(user_id) {
    Some(m) => m
    None => {
      let m : Map[String, Bm25Doc] = Map([])
      self.shards.set(user_id, m)
      m
    }
  }
}

///|
fn KeywordIndex::df_shard(
  self : KeywordIndex,
  user_id : String,
) -> Map[String, Int32] {
  match self.df.get(user_id) {
    Some(m) => m
    None => {
      let m : Map[String, Int32] = Map([])
      self.df.set(user_id, m)
      m
    }
  }
}

///|
fn KeywordIndex::add_total_len(
  self : KeywordIndex,
  user_id : String,
  delta : Int32,
) -> Unit {
  let cur = match self.total_len.get(user_id) {
    Some(v) => v
    None => 0
  }
  self.total_len.set(user_id, cur + delta)
}

///|
/// 写入 / 覆盖某 user 的一条文档(keywords 为 tokenize 结果)。
/// 覆盖时先按旧文档回滚 df 与总词长,保证统计正确。
fn KeywordIndex::upsert(
  self : KeywordIndex,
  user_id : String,
  id : String,
  keywords : Array[String],
) -> Unit {
  let shard = self.shard(user_id)
  // 回滚旧文档统计
  match shard.get(id) {
    Some(old) => {
      let df_map = self.df_shard(user_id)
      for term in old.tf.keys() {
        match df_map.get(term) {
          Some(n) =>
            if n <= 1 {
              df_map.remove(term)
            } else {
              df_map.set(term, n - 1)
            }
          None => ()
        }
      }
      self.add_total_len(user_id, -old.len)
    }
    None => ()
  }
  // 建新文档
  let tf : Map[String, Int32] = Map([])
  for t in keywords {
    let cur = match tf.get(t) {
      Some(n) => n
      None => 0
    }
    tf.set(t, cur + 1)
  }
  shard.set(id, Bm25Doc::{ tf, len: keywords.length(), })
  self.add_total_len(user_id, keywords.length())
  // 更新 df(按去重词表)
  let df_map = self.df_shard(user_id)
  for term in tf.keys() {
    let cur = match df_map.get(term) {
      Some(n) => n
      None => 0
    }
    df_map.set(term, cur + 1)
  }
}

///|
/// 摘除某 user 的一条文档(supersede / forget 共用)。
fn KeywordIndex::remove(
  self : KeywordIndex,
  user_id : String,
  id : String,
) -> Unit {
  match self.shards.get(user_id) {
    Some(shard) =>
      match shard.get(id) {
        Some(doc) => {
          let df_map = self.df_shard(user_id)
          for term in doc.tf.keys() {
            match df_map.get(term) {
              Some(n) =>
                if n <= 1 {
                  df_map.remove(term)
                } else {
                  df_map.set(term, n - 1)
                }
              None => ()
            }
          }
          self.add_total_len(user_id, -doc.len)
          shard.remove(id)
        }
        None => ()
      }
    None => ()
  }
}

///|
/// 某 user 分片的条目数(wbtest 用;主路径不读 size)。
#warnings("-unused_value")
fn KeywordIndex::size(self : KeywordIndex, user_id : String) -> Int32 {
  match self.shards.get(user_id) {
    Some(m) => m.length()
    None => 0
  }
}

///|
/// BM25 检索:仅在该 user 分片内打分,按分数降序取前 k。
/// 排序确定性:分数降序,相同分按 id 升序。
fn KeywordIndex::search(
  self : KeywordIndex,
  user_id : String,
  query : String,
  k : Int32,
) -> Array[(String, Double)] {
  let query_terms = tokenize(query)
  if query_terms.length() == 0 {
    return []
  }
  let shard = match self.shards.get(user_id) {
    Some(m) => m
    None => return []
  }
  let n_docs = shard.length()
  if n_docs == 0 {
    return []
  }
  let total_len = match self.total_len.get(user_id) {
    Some(v) => v
    None => 0
  }
  let avgdl = total_len.to_double() / n_docs.to_double()
  if avgdl <= 0.0 {
    return []
  }
  let df_map = self.df_shard(user_id)
  let scored : Array[(String, Double)] = []
  for kv in shard.iter() {
    let id = kv.0
    let doc = kv.1
    let mut score = 0.0
    for term in query_terms {
      let tf = match doc.tf.get(term) {
        Some(v) => v
        None => 0
      }
      if tf == 0 {
        continue
      }
      let df = match df_map.get(term) {
        Some(v) => v
        None => 0
      }
      let idf = @math.ln(
        1.0 + ((n_docs - df).to_double() + 0.5) / (df.to_double() + 0.5),
      )
      let tf_d = tf.to_double()
      let norm = BM25_K1 * (1.0 - BM25_B + BM25_B * doc.len.to_double() / avgdl)
      score = score + idf * tf_d * (BM25_K1 + 1.0) / (tf_d + norm)
    }
    if score > 0.0 {
      scored.push((id, score))
    }
  }
  scored.sort_by((a, b) => {
    if a.1 > b.1 {
      -1
    } else if a.1 < b.1 {
      1
    } else if a.0 < b.0 {
      -1
    } else if a.0 > b.0 {
      1
    } else {
      0
    }
  })
  let out : Array[(String, Double)] = []
  let limit = if k < scored.length() { k } else { scored.length() }
  for i = 0; i < limit; i = i + 1 {
    out.push(scored[i])
  }
  out
}