///|
pub const NAMING_POLICY : String = "moonbit-readable-names-v1"

///|
fn lower_ascii(c : Char) -> Bool {
  c >= 'a' && c <= 'z'
}

///|
fn upper_ascii(c : Char) -> Bool {
  c >= 'A' && c <= 'Z'
}

///|
fn digit_ascii(c : Char) -> Bool {
  c >= '0' && c <= '9'
}

///|
/// Split ASCII source words at underscores and camel-case/initialism boundaries.
/// Unsupported characters are escaped by the allocator, never canonical parsing.
fn normalized_source(text : String) -> (@ir.Name, Bool) raise {
  let chars = text.to_array()
  let words : Array[String] = []
  let token = StringBuilder()
  let mut unsupported = false
  fn flush() -> Unit {
    let word = token.to_string()
    if word != "" {
      let uppercase = word.to_array().all(c => upper_ascii(c) || digit_ascii(c)) &&
        word.to_array().filter(upper_ascii).length() > 1
      words.push(if uppercase { word } else { word.to_lower() })
      token.reset()
    }
  }
  for i = 0; i < chars.length(); i = i + 1 {
    let c = chars[i]
    if c == '_' {
      flush()
      continue
    }
    if !lower_ascii(c) && !upper_ascii(c) && !digit_ascii(c) {
      unsupported = true
      flush()
      continue
    }
    if i > 0 &&
      upper_ascii(c) &&
      (
        lower_ascii(chars[i - 1]) ||
        digit_ascii(chars[i - 1]) ||
        (
          upper_ascii(chars[i - 1]) &&
          i + 1 < chars.length() &&
          lower_ascii(chars[i + 1])
        )
      ) {
      flush()
    }
    token.write_char(c)
  }
  flush()
  if words.is_empty() {
    words.push("name")
    unsupported = true
  }
  (@ir.Name::parse(words.join("-")), unsupported)
}

///|
fn spelling_hex(text : String) -> String {
  let digits = "0123456789abcdef".to_array()
  let result = StringBuilder()
  for byte in @utf8.encode(text) {
    result.write_char(digits[byte.to_int() / 16])
    result.write_char(digits[byte.to_int() % 16])
  }
  result.to_string()
}

///|
/// Prefix extension and checked counters guarantee uniqueness against user names.
fn escaped_name(
  base : String,
  original : String,
  reserved : Map[String, Bool],
) -> @ir.Name raise {
  let hex = spelling_hex(original)
  let mut length = if hex.length() < 8 { hex.length() } else { 8 }
  let mut counter = 0
  while true {
    let suffix = if counter == 0 { "" } else { "-" + counter.to_string() }
    let candidate = base + "-source-" + hex[:length].to_owned() + suffix
    if !reserved.contains(candidate) {
      reserved[candidate] = true
      return @ir.Name::parse(candidate)
    }
    if length < hex.length() {
      length += 2
    } else {
      counter += 1
    }
  } nobreak {
    abort("unreachable")
  }
}

///|
fn diagnostic_span(source : @ir.SourceLocation) -> @diagnostics.Span {
  {
    start_line: source.start_line,
    start_column: source.start_column,
    end_line: source.end_line,
    end_column: source.end_column,
  }
}

///|
priv struct SourceNames {
  names : Map[String, @ir.Name]
  warnings : Array[@diagnostics.Warning]
}

///|
/// Allocate an entire identity namespace before binding any references.
fn allocate_source_names(
  inputs : Array[(String, @ir.SourceLocation)],
  owner : String,
) -> SourceNames raise {
  let groups : Map[String, Array[String]] = Map([])
  let spans : Map[String, @ir.SourceLocation] = Map([])
  let unsupported : Map[String, Bool] = Map([])
  let reserved : Map[String, Bool] = Map([])
  for (text, source) in inputs {
    if spans.contains(text) {
      continue
    }
    spans[text] = source
    let (name, escaped) = normalized_source(text)
    let base = name.to_canonical()
    unsupported[text] = escaped
    reserved[base] = true
    let group = groups.get(base).unwrap_or([])
    group.push(text)
    groups[base] = group
  }
  let names : Map[String, @ir.Name] = Map([])
  let warnings = []
  let bases = groups.keys().collect()
  bases.sort()
  for base in bases {
    let group = groups[base]
    group.sort_by((a, b) => spelling_hex(a).compare(spelling_hex(b)))
    let mut preferred = group[0]
    for text in group {
      if text == base.replace(old="-", new="_") && !unsupported[text] {
        preferred = text
        break
      }
    }
    for text in group {
      names[text] = if text == preferred && !unsupported[text] {
        @ir.Name::parse(base)
      } else {
        escaped_name(base, text, reserved)
      }
    }
    if group.length() > 1 || group.any(text => unsupported[text]) {
      let renamed = group[group.length() - 1]
      let mut n = 1
      let root = (if digit_ascii(base.to_array()[0]) { "name_" } else { "" }) +
        base.replace(old="-", new="_").to_lower() +
        "_renamed_"
      let mut suggestion = root + n.to_string()
      while reserved.contains(normalized_source(suggestion).0.to_canonical()) {
        n += 1
        suggestion = root + n.to_string()
      }
      warnings.push(@diagnostics.Warning::{
        code: if group.length() > 1 {
          "moonbit_frontend.name_normalization_collision"
        } else {
          "moonbit_frontend.name_spelling_escape"
        },
        stage: "frontend",
        message: if group.length() > 1 {
          "Source spellings normalize to " +
          base +
          "; continuing with distinct identities"
        } else {
          "Source spelling requires a portable identity escape; continuing automatically"
        },
        locations: group.map(text => {
          owner: owner + "#" + names[text].to_canonical(),
          original: text,
          allocated: names[text].to_canonical(),
          source: Some(diagnostic_span(spans[text])),
        }),
        fix: if group.length() > 1 {
          "Rename " +
          renamed +
          " to " +
          suggestion +
          " so its semantic words differ. A backend targetNames request alone cannot repair an IR identity collision."
        } else {
          "Rename " +
          renamed +
          " to " +
          suggestion +
          " to use distinct portable words."
        },
      })
    }
  }
  { names, warnings, }
}

///|
/// Reserve user bases before allocation; freshening never captures a user binding.
priv struct BinderNames {
  source : SourceNames
  reserved : Map[String, Bool]
  used : Map[String, Bool]
}

///|
fn BinderNames::new(
  inputs : Array[(String, @ir.SourceLocation)],
  owner : String,
) -> BinderNames raise {
  let source = allocate_source_names(inputs, owner)
  let reserved = Map([])
  for _, name in source.names {
    reserved[name.to_canonical()] = true
  }
  { source, reserved, used: Map([]), }
}

///|
fn BinderNames::fresh(
  self : BinderNames,
  base : String,
  role : String,
) -> @ir.Name raise {
  let mut counter = 1
  while true {
    let candidate = base + "-" + role + "-" + counter.to_string()
    if !self.reserved.contains(candidate) && !self.used.contains(candidate) {
      self.used[candidate] = true
      return @ir.Name::parse(candidate)
    }
    counter += 1
  } nobreak {
    abort("unreachable")
  }
}

///|
fn BinderNames::bind(self : BinderNames, text : String) -> @ir.Name raise {
  let name = self.source.names[text]
  let base = name.to_canonical()
  if self.used.contains(base) {
    return self.fresh(base, "shadow")
  }
  self.used[base] = true
  name
}

///|
fn collect_bindings(
  expr : LibraryExpr,
  names : Array[(String, @ir.SourceLocation)],
) -> Unit {
  match expr {
    Binding(text, source, value, body, _) => {
      names.push((text, source))
      collect_bindings(value, names)
      collect_bindings(body, names)
    }
    Negation(value, _)
    | NumericNegation(value, _)
    | StringLength(value, _)
    | Constraint(value, _, _) => collect_bindings(value, names)
    StringSlice(value, start, end, _) => {
      collect_bindings(value, names)
      collect_bindings(start, names)
      collect_bindings(end, names)
    }
    Binary(_, lhs, rhs, _) => {
      collect_bindings(lhs, names)
      collect_bindings(rhs, names)
    }
    Conditional(cond, yes, no, _) => {
      collect_bindings(cond, names)
      collect_bindings(yes, names)
      collect_bindings(no, names)
    }
    Call(_, arguments, _) | QualifiedCall(_, _, arguments, _) =>
      for arg in arguments {
        collect_bindings(arg, names)
      }
    MethodCall(_, receiver, arguments, _) => {
      collect_bindings(receiver, names)
      for arg in arguments {
        collect_bindings(arg, names)
      }
    }
    _ => ()
  }
}

///|
fn check_naming_warnings(
  warnings : Array[@diagnostics.Warning],
  strict : Bool,
) -> Unit raise FrontendError {
  if strict && !warnings.is_empty() {
    let warning = warnings[0]
    let source = warning.locations[0].source.map(s => @ir.SourceLocation::{
      start_line: s.start_line,
      start_column: s.start_column,
      end_line: s.end_line,
      end_column: s.end_column,
    })
    raise Invalid({ code: warning.code, detail: warning.display(), source, })
  }
}

///|
fn SourceNames::reason(self : SourceNames, text : String) -> String {
  for warning in self.warnings {
    if warning.locations.any(loc => loc.original == text) {
      return if warning.code == "moonbit_frontend.name_normalization_collision" {
        "normalization-collision"
      } else {
        "portable-spelling-escape"
      }
    }
  }
  "normalized"
}