///|
/// Built-in charset transcoding for the common table-free encodings,
/// applied when an XML part declares a non-UTF-8 encoding (or carries a
/// UTF-16 byte-order mark) and the caller supplied no explicit
/// transcoder. This mirrors Excelize, which defaults its XML decoder to
/// `charset.NewReaderLabel` (the WHATWG encoding standard), for the
/// subset that needs no large lookup table: the Windows-1252 /
/// Latin-1 / ASCII family and UTF-16. The multi-byte CJK encodings
/// (Shift-JIS/GBK/Big5/EUC-KR) still require an explicit `transcoder`.
///
/// Following WHATWG/Go, the ISO-8859-1, Latin-1, and US-ASCII labels all
/// resolve to the Windows-1252 decoder, so bytes 0x80-0x9F render as
/// CP1252 punctuation rather than C1 controls.
///
/// Returns `None` for an encoding this table does not cover, so the
/// caller can fall back to its "requires charset transcoder" error.
fn builtin_charset_decode(encoding : String, bytes : BytesView) -> String? {
  match normalize_charset_label(encoding) {
    "windows-1252" => Some(decode_windows_1252(bytes))
    "utf-16le" => decode_utf16(bytes, Little)
    "utf-16be" => decode_utf16(bytes, Big)
    _ => None
  }
}

///|
/// Resolves a charset label to one of the decoder families this port
/// handles, following the WHATWG encoding standard's label lists (the
/// same table Go's `charset.NewReaderLabel` uses). The Latin-1 and ASCII
/// families intentionally fold into `windows-1252`.
fn normalize_charset_label(encoding : String) -> String {
  match encoding.to_lower().trim() {
    // Windows-1252 / Latin-1 / ASCII family (WHATWG maps them together)
    "windows-1252"
    | "cp1252"
    | "windows1252"
    | "x-cp1252"
    | "ansi"
    | "iso-8859-1"
    | "iso8859-1"
    | "iso88591"
    | "iso_8859-1"
    | "iso_8859-1:1987"
    | "iso-ir-100"
    | "latin1"
    | "latin-1"
    | "l1"
    | "cp819"
    | "ibm819"
    | "csisolatin1"
    | "us-ascii"
    | "ascii"
    | "us_ascii"
    | "iso646-us"
    | "ansi_x3.4-1968"
    | "cp367" => "windows-1252"
    // UTF-16: the plain "utf-16" label is little-endian by default in
    // WHATWG; a byte-order mark still overrides at decode time.
    "utf-16"
    | "utf16"
    | "utf-16le"
    | "utf16le"
    | "utf-16-le"
    | "utf16_le"
    | "ucs-2"
    | "ucs2"
    | "unicode"
    | "csunicode"
    | "iso-10646-ucs-2"
    | "unicodefeff" => "utf-16le"
    "utf-16be" | "utf16be" | "utf-16-be" | "utf16_be" | "unicodefffe" =>
      "utf-16be"
    other => other.to_owned()
  }
}

///|
/// Decodes UTF-16 bytes. A leading byte-order mark is authoritative and
/// consumed (FEFF -> big-endian, FFFE -> little-endian, matching WHATWG);
/// otherwise the label's default endianness applies. Returns `None` on
/// malformed input.
fn decode_utf16(
  bytes : BytesView,
  default_endian : @encoding/utf16.Endian,
) -> String? {
  let (endian, body) = if bytes.length() >= 2 &&
    bytes[0] == 0xfe &&
    bytes[1] == 0xff {
    (@encoding/utf16.Endian::Big, bytes[2:])
  } else if bytes.length() >= 2 && bytes[0] == 0xff && bytes[1] == 0xfe {
    (Little, bytes[2:])
  } else {
    (default_endian, bytes)
  }
  match
    (Ok(@encoding/utf16.decode(body, endianness=endian, ignore_bom=false)) catch {
      e => Err(e)
    }) {
    Ok(text) => Some(text)
    Err(_) => None
  }
}

///|
/// The 0x80-0x9F code points that Windows-1252 remaps away from the
/// Latin-1 C1 control block (WHATWG index-windows-1252). Index is
/// `byte - 0x80`; a 0 marks a byte Windows-1252 leaves undefined
/// (0x81, 0x8D, 0x8F, 0x90, 0x9D), which this decoder passes through as
/// the C1 control code of the same value, matching browsers and Go.
let windows_1252_high : FixedArray[Int] = [
  0x20ac, 0x0000, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030,
  0x0160, 0x2039, 0x0152, 0x0000, 0x017d, 0x0000, 0x0000, 0x2018, 0x2019, 0x201c,
  0x201d, 0x2022, 0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0x0000,
  0x017e, 0x0178,
]

///|
/// Decodes Windows-1252 (and the Latin-1 / ASCII labels that fold into
/// it): identical to Latin-1 except for the 0x80-0x9F range, which maps
/// to typographic punctuation and letters.
fn decode_windows_1252(bytes : BytesView) -> String {
  let sb = StringBuilder::new()
  for b in bytes {
    let value = b.to_int()
    let code = if value >= 0x80 && value <= 0x9f {
      let mapped = windows_1252_high[value - 0x80]
      if mapped == 0 {
        value
      } else {
        mapped
      }
    } else {
      value
    }
    sb.write_char(Int::unsafe_to_char(code))
  }
  sb.to_string()
}

///|
/// Returns the UTF-16 charset label implied by a leading byte-order mark,
/// if any. A UTF-16-encoded XML document's `` declaration is
/// itself UTF-16 bytes, so the ASCII declaration scan cannot see its
/// encoding; the BOM is how such a document is recognized.
fn utf16_bom_label(bytes : BytesView) -> String? {
  if bytes.length() >= 2 && bytes[0] == 0xfe && bytes[1] == 0xff {
    Some("utf-16be")
  } else if bytes.length() >= 2 && bytes[0] == 0xff && bytes[1] == 0xfe {
    Some("utf-16le")
  } else {
    None
  }
}

///|
test "builtin charset decode: Windows-1252 / Latin-1 / ASCII fold together" {
  // Windows-1252: 0x80 -> €, 0x92 -> ' (right single quote), 0x99 -> ™
  debug_inspect(
    builtin_charset_decode("windows-1252", b"\x80\x92\x99"),
    content=(
      #|Some("€’™")
    ),
  )
  // ISO-8859-1 and US-ASCII labels resolve to the same Windows-1252
  // decoder (WHATWG/Go behavior): 0xE9 -> é for all of them
  debug_inspect(
    builtin_charset_decode("ISO-8859-1", b"caf\xe9"),
    content=(
      #|Some("café")
    ),
  )
  debug_inspect(
    builtin_charset_decode("latin1", b"caf\xe9"),
    content=(
      #|Some("café")
    ),
  )
  // ASCII text is unchanged; a high byte decodes as CP1252, not rejected
  debug_inspect(
    builtin_charset_decode("us-ascii", b"hello"),
    content=(
      #|Some("hello")
    ),
  )
  debug_inspect(
    builtin_charset_decode("ascii", b"caf\xe9"),
    content=(
      #|Some("café")
    ),
  )
  // an undefined Windows-1252 slot (0x81) passes through as its C1 control
  guard builtin_charset_decode("cp1252", b"\x81") is Some(s)
  inspect(s.get_char(0).unwrap().to_int(), content="129")
}

///|
test "builtin charset decode: UTF-16 label endianness and BOM" {
  // plain utf-16 defaults to little-endian (WHATWG)
  debug_inspect(
    builtin_charset_decode("utf-16", b"h\x00i\x00"),
    content=(
      #|Some("hi")
    ),
  )
  // explicit big-endian, no BOM
  debug_inspect(
    builtin_charset_decode("utf-16be", b"\x00h\x00i"),
    content=(
      #|Some("hi")
    ),
  )
  // a BOM overrides the label default and is stripped
  debug_inspect(
    builtin_charset_decode("utf-16le", b"\xfe\xff\x00h\x00i"),
    content=(
      #|Some("hi")
    ),
  )
  // unicodefffe is UTF-16BE despite the FFFE-looking name (WHATWG)
  debug_inspect(
    builtin_charset_decode("unicodeFFFE", b"\x00h\x00i"),
    content=(
      #|Some("hi")
    ),
  )
}

///|
test "builtin charset decode returns None for unsupported encodings" {
  inspect(builtin_charset_decode("Shift_JIS", b"abc") is None, content="true")
  inspect(builtin_charset_decode("gbk", b"abc") is None, content="true")
  inspect(builtin_charset_decode("euc-kr", b"abc") is None, content="true")
  inspect(builtin_charset_decode("big5", b"abc") is None, content="true")
}

///|
test "decode_utf8 applies builtin charset without a transcoder" {
  // Latin-1 declaration, é as 0xE9, no transcoder supplied
  let xml = b"caf\xe9"
  debug_inspect(
    decode_utf8(xml, None),
    content=(
      #|"café"
    ),
  )
}

///|
test "decode_utf8 recognizes a UTF-16 document by its BOM" {
  // UTF-16LE bytes for "hi" with a leading FFFE BOM; no ASCII
  // \x00h\x00i\x00<\x00/\x00a\x00>\x00"
  debug_inspect(
    decode_utf8(doc, None),
    content=(
      #|"hi"
    ),
  )
}