"""
=============================================================================
第 1 讲 · 代码 1：Unicode 与 UTF-8 —— 文字是怎么变成数字的？
=============================================================================

对应讲义：lectures/lecture01_overview_tokenization.md 第 7 节（字符级）、第 8 节（字节级）

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
语言模型本质上是一个"数学函数"，它只能处理数字，不能直接处理文字。
所以在讨论任何分词器（tokenizer）之前，我们必须先搞清楚一个更底层的问题：

    计算机本身是怎么用数字来表示文字的？

答案分两层：
    第 1 层：Unicode —— 给世界上每个字符分配一个编号，叫"码位（code point）"。
             例如 'a' 是 97，'你' 是 20320，'🌍' 是 127757。
    第 2 层：UTF-8   —— 把码位变成字节（byte，0~255 的整数）序列，用来存储和传输。
             例如 'a' → 1 个字节，'你' → 3 个字节，'🌍' → 4 个字节。

后面的"字符级分词器"用的就是第 1 层的码位；
"字节级分词器"和 BPE 用的就是第 2 层的字节。
不理解这两层，你会在写 BPE 时被各种 UnicodeDecodeError 搞得一头雾水。

-----------------------------------------------------------------------------
【整体思路】
-----------------------------------------------------------------------------
    第 1 部分：字符 ↔ 码位（ord / chr）
    第 2 部分：str（字符串）和 bytes（字节串）是两种不同的东西
    第 3 部分：UTF-8 的编码规则 —— 亲手实现一个 UTF-8 编码器，和 Python 内置的对比
    第 4 部分：UTF-8 / UTF-16 / UTF-32 谁更省空间？为什么大模型选 UTF-8？
    第 5 部分：解码的陷阱 —— 为什么"把字节切开再解码"会出错（BPE 解码时的真实问题）
    第 6 部分：看起来一样的文字，字节可能不一样（Unicode 规范化）

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
在终端中进入本文件所在目录，然后运行：

    python 01_unicode_and_utf8.py

本文件只用到 Python 标准库，不需要安装任何第三方库。

-----------------------------------------------------------------------------
【注释约定】（全课代码通用）
-----------------------------------------------------------------------------
    【思路】         这一段代码想做什么、为什么这么做
    【参数】         函数的输入，每个参数的类型和合法取值范围
    【返回】         函数的输出
    【边界】         特殊情况、会报错的情况、容易踩的坑
    【Python 小知识】语法讲解，写给编程初学者
    【大模型联系】   这段代码和真实的大语言模型 / 讲义里哪个概念对应
"""

# =============================================================================
# 第 0 部分：导入需要的库
# =============================================================================
# 【Python 小知识】
#   import 用来"借用"别人写好的代码。unicodedata 是 Python 自带的标准库
#   （不用安装），里面存着 Unicode 官方的字符数据库，比如每个字符的官方英文名字。
import unicodedata


# -----------------------------------------------------------------------------
# 一个小工具：打印醒目的标题，让输出更好读
# -----------------------------------------------------------------------------
def print_header(title):
    """
    打印一个带分隔线的标题。

    【参数】
        title : str，要显示的标题文字。
    【返回】
        无（None）。这个函数只负责"打印"这个副作用。

    【Python 小知识】
        "=" * 70 表示把字符串 "=" 重复 70 次，得到一条分隔线。
        print() 里的 f"..." 叫 f-string（格式化字符串）：花括号 {} 里可以直接写变量或表达式，
        Python 会把它的值填进去。例如 name = "Lily"; f"Hi {name}" 得到 "Hi Lily"。
    """
    print()
    print("=" * 70)
    print(f"  {title}")
    print("=" * 70)


# =============================================================================
# 第 1 部分：字符 ↔ 码位
# =============================================================================
# 【思路】
#   Unicode 是一张巨大的"字符 → 编号"对照表。这个编号叫码位（code point），
#   习惯上写成 "U+" 加 4~6 位十六进制数，例如 '你' 的码位是 U+4F60（十进制 20320）。
#
#   Python 提供两个内置函数在字符和码位之间转换：
#       ord('你')   → 20320        （character → ordinal number，字符 → 编号）
#       chr(20320)  → '你'         （编号 → 字符）
#
#   码位的合法范围是 0 ~ 0x10FFFF（十进制 1,114,111），一共 1,114,112 个位置。
#   但其中只有大约 15 万个真正分配了字符（每年 Unicode 新版本还会增加一些），
#   其余大部分是"空位"或保留位。
#
# 【大模型联系】
#   "字符级分词器"就是直接把 ord(c) 当成 token id。
#   问题是：词表要覆盖约 15 万个字符（甚至 111 万个码位），而且大部分字符极其罕见——
#   这就是讲义第 7 节说的"词表太大、利用率太低"。

# 码位的最大值。0x 开头表示十六进制。0x10FFFF == 1114111。
MAX_CODE_POINT = 0x10FFFF


def describe_char(ch):
    """
    返回一个字符的"身份证"：码位、官方名字、UTF-8 字节等信息。

    【思路】
        把一个字符在"两层表示"（码位 + UTF-8 字节）中的样子都列出来，
        方便我们肉眼对比：为什么有的字符 1 个字节，有的却要 4 个字节。

    【参数】
        ch : str，必须是**恰好一个字符**的字符串，例如 'a'、'你'、'🌍'。

    【返回】
        dict（字典），包含以下键：
            "char"       : 字符本身
            "code_point" : 码位（十进制整数）
            "u_plus"     : 码位的 U+XXXX 写法（字符串）
            "name"       : Unicode 官方英文名（如果没有名字，比如控制字符，就是 "<无名字>"）
            "utf8"       : UTF-8 编码后的字节串（bytes 类型）
            "utf8_len"   : UTF-8 字节数

    【边界】
        - 如果传入的字符串长度不是 1（比如空字符串 "" 或者 "ab"），ord() 会报 TypeError。
          我们提前检查，给出更友好的中文报错。
        - 注意：有些"看起来是一个字符"的东西其实是多个码位，比如某些带肤色的 emoji
          "👍🏽" 是 2 个码位（👍 + 肤色修饰符），len("👍🏽") == 2，传进来会报错。
          这在第 6 部分还会讲到。

    【Python 小知识】
        - raise ValueError("...") 表示"主动抛出一个错误"，程序会停下来并显示这段信息。
          这是一种防御式编程：尽早发现错误的输入，而不是让错误悄悄传下去。
        - f"U+{cp:04X}" 里的 :04X 是格式说明：X 表示用大写十六进制显示，
          04 表示至少 4 位、不足补 0。例如 97 → "0061"。
        - unicodedata.name(ch, "<无名字>")：第二个参数是"找不到名字时的默认值"，
          避免报错。
    """
    if len(ch) != 1:
        raise ValueError(f"describe_char 需要恰好 1 个字符，但收到了 {len(ch)} 个：{ch!r}")

    cp = ord(ch)                      # 字符 → 码位
    utf8 = ch.encode("utf-8")         # 字符串 → UTF-8 字节串
    return {
        "char": ch,
        "code_point": cp,
        "u_plus": f"U+{cp:04X}",
        "name": unicodedata.name(ch, "<无名字>"),
        "utf8": utf8,
        "utf8_len": len(utf8),
    }


def demo_code_points():
    """演示 1：常见字符的码位与 UTF-8 字节。"""
    print_header("演示 1：字符 ↔ 码位（ord / chr）")

    # 选一些有代表性的字符：
    #   英文字母、空格、带重音的拉丁字母、希腊字母、中文、日文、emoji
    examples = ["a", "A", " ", "é", "π", "你", "好", "の", "🌍"]

    # 打印表头。:<6 表示左对齐、占 6 格；:>8 表示右对齐、占 8 格。
    print(f"{'字符':<4}{'码位(十进制)':>12}{'U+写法':>10}  {'UTF-8字节数':>10}  UTF-8 字节 / 官方名字")
    for ch in examples:
        info = describe_char(ch)
        # info["utf8"] 是 bytes，list(...) 把它变成整数列表，方便看每个字节的数值
        byte_values = list(info["utf8"])
        print(f"{ch!r:<6}{info['code_point']:>12}{info['u_plus']:>10}  {info['utf8_len']:>10}  "
              f"{byte_values}  {info['name']}")

    # chr 是 ord 的逆运算：对任何合法码位 cp，都有 ord(chr(cp)) == cp
    # 【Python 小知识】assert 条件, "信息"：如果条件为假，程序立刻报错并显示信息。
    #   我们用 assert 来做"自我检查"——如果哪天你改坏了代码，assert 会第一时间告诉你。
    for ch in examples:
        assert chr(ord(ch)) == ch, "chr 和 ord 应该互为逆运算"
    print("\n✓ 检查通过：对所有例子，chr(ord(c)) == c")

    print(f"\n码位的合法范围：0 ~ {MAX_CODE_POINT}（即 0x10FFFF），共 {MAX_CODE_POINT + 1:,} 个位置")
    print("但真正分配了字符的只有约 15 万个 —— 这就是'字符级分词器词表太大'的来源。")

    # 边界：chr 的参数超出范围会报错
    try:
        chr(MAX_CODE_POINT + 1)
    except ValueError as e:
        # 【Python 小知识】try ... except ...：尝试执行 try 块里的代码，
        #   如果出现指定类型的错误，就跳到 except 块处理，程序不会崩溃。
        print(f"\n【边界】chr({MAX_CODE_POINT + 1}) 超出范围 → ValueError: {e}")


# =============================================================================
# 第 2 部分：str 和 bytes 是两种不同的东西
# =============================================================================
# 【思路】
#   Python 里有两种"看起来都像文字"的类型：
#       str   : 字符串，由"字符"组成。   "你好"        长度 2
#       bytes : 字节串，由"字节"组成。  b'\xe4\xbd\xa0\xe5\xa5\xbd'  长度 6
#
#   两者之间的转换：
#       str  --encode("utf-8")-->  bytes     （编码：文字 → 字节）
#       bytes --decode("utf-8")--> str       （解码：字节 → 文字）
#
#   【大模型联系】
#   BPE 分词器的词表里，每个 token 对应的是一段 **bytes**，而不是 str。
#   为什么？因为 BPE 从字节开始合并，合并出来的 token 可能是"半个汉字"
#   （比如 '你' 的前两个字节 b'\xe4\xbd'），这样的东西根本不是合法的 str。
#   你在讲义第 6 节 GPT-2 的例子里会看到这种"半个汉字"的 token。


def demo_str_vs_bytes():
    """演示 2：str 与 bytes 的区别。"""
    print_header("演示 2：str（字符串） vs bytes（字节串）")

    text = "Hello, 🌍! 你好!"   # CS336 第 1 讲里用的例子字符串
    data = text.encode("utf-8")

    print(f"原始字符串 text = {text!r}")
    print(f"type(text) = {type(text).__name__},  len(text) = {len(text)}   ← 字符数")
    print(f"编码后 data = {data!r}")
    print(f"type(data) = {type(data).__name__}, len(data) = {len(data)}  ← 字节数")

    # 对 bytes 做下标访问，得到的是一个 0~255 的整数，而不是一个字符！
    # 这是初学者最常踩的坑之一。
    print(f"\ntext[0] = {text[0]!r}   ← 对 str 取下标，得到一个字符（str）")
    print(f"data[0] = {data[0]!r}      ← 对 bytes 取下标，得到一个整数（int）")
    print(f"data[0:1] = {data[0:1]!r}  ← 对 bytes 做切片，得到的仍然是 bytes")

    # 【Python 小知识】bytes([104]) 把整数列表变成 bytes；bytes 可以像列表一样遍历
    print(f"bytes([72, 105]) = {bytes([72, 105])!r}   ← 从整数列表构造 bytes")

    # 往返检查：decode(encode(s)) 一定等于 s
    assert data.decode("utf-8") == text
    print("\n✓ 检查通过：text.encode('utf-8').decode('utf-8') == text（编码再解码能还原）")

    # 逐字符看一下每个字符占了几个字节
    print("\n逐字符拆开看：")
    for ch in text:
        b = ch.encode("utf-8")
        print(f"  {ch!r:6} → {len(b)} 个字节 {list(b)}")
    print("可以看到：英文和标点 1 个字节，中文 3 个字节，emoji 4 个字节。")


# =============================================================================
# 第 3 部分：UTF-8 的编码规则（亲手实现一个编码器）
# =============================================================================
# 【思路】
#   UTF-8 是一种"变长编码"：码位越小，用的字节越少。规则如下（x 表示码位的二进制位）：
#
#   码位范围              字节数   字节模式（二进制）
#   U+0000  ~ U+007F        1     0xxxxxxx
#   U+0080  ~ U+07FF        2     110xxxxx 10xxxxxx
#   U+0800  ~ U+FFFF        3     1110xxxx 10xxxxxx 10xxxxxx
#   U+10000 ~ U+10FFFF      4     11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
#
#   几个精妙的设计（讲义第 8.1 节会解释为什么重要）：
#     (1) 1 字节的部分和 ASCII 完全一样 → 所有旧的英文文件天然就是合法的 UTF-8。
#     (2) 看首字节的开头几位就知道这个字符一共几个字节（0→1个，110→2个，1110→3个，11110→4个）。
#     (3) 后续字节都以 10 开头，不可能和首字节混淆 → 从任意位置开始都能"重新同步"。
#
#   本部分是【选读】：涉及二进制位运算。如果暂时看不懂，可以只看运行结果，
#   记住上面那张表就够了。


def utf8_length_from_code_point(cp):
    """
    根据码位计算它在 UTF-8 中需要几个字节。

    【参数】
        cp : int，码位，合法范围 0 ~ 0x10FFFF。

    【返回】
        int，1、2、3 或 4。

    【边界】
        - cp < 0 或 cp > 0x10FFFF：不是合法码位，抛出 ValueError。
        - 0xD800 ~ 0xDFFF 叫"代理区（surrogates）"，是 UTF-16 内部使用的保留码位，
          它们**不能**被编码成 UTF-8。Python 中 chr(0xD800).encode("utf-8") 会报错。
          我们在这里也把它当成非法输入。
    """
    if cp < 0 or cp > MAX_CODE_POINT:
        raise ValueError(f"码位 {cp} 超出合法范围 0 ~ 0x10FFFF")
    if 0xD800 <= cp <= 0xDFFF:
        raise ValueError(f"码位 U+{cp:04X} 属于代理区，不能编码为 UTF-8")
    if cp <= 0x7F:
        return 1
    if cp <= 0x7FF:
        return 2
    if cp <= 0xFFFF:
        return 3
    return 4


def utf8_length_from_first_byte(b):
    """
    只看一个字节，判断它是不是"首字节"，如果是，这个字符一共几个字节。

    【思路】
        这正是 UTF-8 "自同步"特性的体现：解码器读到任何一个字节，
        都能立刻知道它是一个字符的开头还是中间。

    【参数】
        b : int，0 ~ 255 的整数（一个字节的值）。

    【返回】
        int：
            1 / 2 / 3 / 4  → 这是一个首字节，对应字符总共这么多字节
            0              → 这是一个"后续字节"（continuation byte，以 10 开头），
                              不能作为字符的开头

    【边界】
        - 0xF8 ~ 0xFF（二进制 11111xxx）在现代 UTF-8 中永远不会出现，抛出 ValueError。
        - 严格来说 0xC0、0xC1、0xF5~0xF7 也是非法首字节（会产生"过长编码"或超出范围），
          这里为了保持简单没有特别处理，Python 的解码器会拒绝它们。

    【Python 小知识】位运算
        b & 0b10000000 ：按位与（AND）。只保留 b 的最高位，其他位变 0。
                         用来"检查某几位是什么"。0b 开头表示二进制写法。
        例如 b = 0b11100100（228）时：
            b & 0b11110000 = 0b11100000，正好等于 0b11100000 → 说明开头是 1110 → 3 字节字符
    """
    if not (0 <= b <= 255):
        raise ValueError(f"字节值必须在 0~255 之间，收到 {b}")
    if b & 0b10000000 == 0b00000000:   # 0xxxxxxx
        return 1
    if b & 0b11000000 == 0b10000000:   # 10xxxxxx → 后续字节
        return 0
    if b & 0b11100000 == 0b11000000:   # 110xxxxx
        return 2
    if b & 0b11110000 == 0b11100000:   # 1110xxxx
        return 3
    if b & 0b11111000 == 0b11110000:   # 11110xxx
        return 4
    raise ValueError(f"字节 0x{b:02X} 不可能出现在合法的 UTF-8 中")


def encode_utf8_manually(ch):
    """
    亲手实现 UTF-8 编码：把一个字符变成字节串。

    【思路】
        1. 先算出码位 cp 和需要的字节数 n。
        2. 把 cp 的二进制位"切成几段"，分别填进上面那张表的 x 位置。
        3. 首字节加上长度标记（0 / 110 / 1110 / 11110），后续字节加上 10 标记。

    【参数】
        ch : str，恰好一个字符。

    【返回】
        bytes，UTF-8 编码结果。应当与 ch.encode("utf-8") 完全相同。

    【边界】
        同 utf8_length_from_code_point：代理区字符会报错。

    【Python 小知识】
        cp >> 6       ：右移 6 位，相当于整除 2^6 = 64，把低 6 位"挤掉"。
        cp & 0b111111 ：只保留最低 6 位（0b111111 == 63）。
        a | b         ：按位或（OR），把两个数的 1 位"合并"起来。这里用来给数据加上标记位。
    """
    if len(ch) != 1:
        raise ValueError("encode_utf8_manually 需要恰好 1 个字符")
    cp = ord(ch)
    n = utf8_length_from_code_point(cp)

    if n == 1:
        # 0xxxxxxx：码位本身就是字节值（与 ASCII 一致）
        return bytes([cp])
    if n == 2:
        # 110xxxxx 10xxxxxx：码位共 11 个有效位，高 5 位放首字节，低 6 位放第二个字节
        return bytes([
            0b11000000 | (cp >> 6),
            0b10000000 | (cp & 0b111111),
        ])
    if n == 3:
        # 1110xxxx 10xxxxxx 10xxxxxx：码位共 16 个有效位，切成 4 + 6 + 6
        return bytes([
            0b11100000 | (cp >> 12),
            0b10000000 | ((cp >> 6) & 0b111111),
            0b10000000 | (cp & 0b111111),
        ])
    # n == 4：11110xxx 10xxxxxx 10xxxxxx 10xxxxxx，码位共 21 个有效位，切成 3 + 6 + 6 + 6
    return bytes([
        0b11110000 | (cp >> 18),
        0b10000000 | ((cp >> 12) & 0b111111),
        0b10000000 | ((cp >> 6) & 0b111111),
        0b10000000 | (cp & 0b111111),
    ])


def demo_utf8_rules():
    """演示 3：验证手写的 UTF-8 编码器，特别是各个区间的"边界码位"。"""
    print_header("演示 3：UTF-8 编码规则（手写编码器 vs Python 内置）")

    # 边界测试：每个区间的第一个和最后一个码位最容易写错，一定要测
    boundary_code_points = [
        0x0000, 0x007F,      # 1 字节区间的两端
        0x0080, 0x07FF,      # 2 字节区间的两端
        0x0800, 0xFFFF,      # 3 字节区间的两端（注意 0xFFFF 是"非字符"，但仍可编码）
        0x10000, 0x10FFFF,   # 4 字节区间的两端
    ]
    normal_chars = ["a", "é", "π", "你", "🌍"]

    all_chars = [chr(cp) for cp in boundary_code_points] + normal_chars
    for ch in all_chars:
        mine = encode_utf8_manually(ch)
        builtin = ch.encode("utf-8")
        ok = "✓" if mine == builtin else "✗"
        # 把每个字节显示成 8 位二进制，方便看到 110 / 1110 / 10 这些标记位
        bits = " ".join(f"{b:08b}" for b in mine)
        print(f"{ok} U+{ord(ch):06X} → {len(mine)} 字节  {bits}")
        assert mine == builtin, f"手写编码器在 U+{ord(ch):04X} 处出错"

    print("\n✓ 手写编码器与 Python 内置的 UTF-8 编码结果完全一致")

    # 演示"看首字节就知道长度"
    data = "a你🌍".encode("utf-8")
    print(f"\n字节串 {list(data)} 中每个字节的角色：")
    for b in data:
        n = utf8_length_from_first_byte(b)
        role = "后续字节" if n == 0 else f"首字节（该字符共 {n} 字节）"
        print(f"  0x{b:02X} = {b:08b} → {role}")

    # 边界：代理区码位不能编码
    try:
        chr(0xD800).encode("utf-8")
    except UnicodeEncodeError:
        print("\n【边界】代理区码位 U+D800 无法编码为 UTF-8（Python 内置编码器也会拒绝）")


# =============================================================================
# 第 4 部分：UTF-8 / UTF-16 / UTF-32 谁更省空间？
# =============================================================================
# 【思路】
#   除了 UTF-8，还有 UTF-16（大多数字符 2 字节，emoji 4 字节）和 UTF-32（一律 4 字节）。
#   为什么大模型的字节级分词器几乎都选 UTF-8？我们直接量一量不同文本的编码长度。
#
#   【边界】BOM（字节顺序标记）
#   在 Python 里写 "utf-16" 编码时，会在开头自动加 2 个字节的 BOM（\xff\xfe），
#   用来标明字节顺序（大端/小端）。为了公平比较，我们用 "utf-16-le" / "utf-32-le"
#   （le = little-endian，小端序），它们不加 BOM。
#   这也是 UTF-8 的一个优点：它按字节定义，根本不存在"字节顺序"的问题。


def encoded_lengths(text):
    """
    计算一段文本在三种编码下分别占多少字节。

    【参数】
        text : str，任意文本（可以为空字符串，此时三个结果都是 0）。

    【返回】
        dict：{"chars": 字符数, "utf-8": 字节数, "utf-16": 字节数, "utf-32": 字节数}
    """
    return {
        "chars": len(text),
        "utf-8": len(text.encode("utf-8")),
        "utf-16": len(text.encode("utf-16-le")),
        "utf-32": len(text.encode("utf-32-le")),
    }


def demo_compare_encodings():
    """演示 4：比较三种编码的长度。"""
    print_header("演示 4：UTF-8 / UTF-16 / UTF-32 编码长度对比")

    samples = {
        "英文故事": "Once upon a time, there was a little girl named Lily.",
        "中文故事": "从前，有一个叫小明的男孩。他非常喜欢在公园里玩耍。",
        "Python 代码": "def add(a, b):\n    return a + b\n",
        "emoji": "🌍🚀😀👍",
        "课程例句": "Hello, 🌍! 你好!",
    }
    print(f"{'文本类型':<12}{'字符数':>6}{'UTF-8':>8}{'UTF-16':>8}{'UTF-32':>8}")
    for name, text in samples.items():
        L = encoded_lengths(text)
        print(f"{name:<12}{L['chars']:>6}{L['utf-8']:>8}{L['utf-16']:>8}{L['utf-32']:>8}")

    print("""
观察：
  - 英文和代码：UTF-8 是 UTF-16 的一半、UTF-32 的四分之一。
  - 中文：UTF-8 每字 3 字节，反而比 UTF-16（每字 2 字节）长。
  - 互联网上的训练数据以英文和代码为主，且网页几乎都用 UTF-8 存储，
    所以整体上 UTF-8 最省，而且词表只需 256 个"字节 token"。
  - UTF-32 定长虽然简单，但浪费太多（大部分字节是 0）。
  → 这也是 CS336 作业 1 里的一道思考题：为什么要在 UTF-8 字节上训练分词器？""")


# =============================================================================
# 第 5 部分：解码的陷阱
# =============================================================================
# 【思路】
#   编码（str → bytes）永远不会失败（代理区除外）；
#   但解码（bytes → str）很容易失败：任意一串字节**不一定**是合法的 UTF-8。
#
#   【大模型联系】这在大模型里是真实问题：
#     - 模型每次生成一个 token，而一个 token 可能只是"半个汉字"的字节。
#       如果你每生成一个 token 就立刻解码显示，就会遇到非法字节。
#     - BPE 分词器的 decode 函数必须能处理"模型生成了一串不合法字节"的情况，
#       标准做法是 bytes.decode("utf-8", errors="replace")：
#       遇到非法字节就用替换字符 U+FFFD（显示为 �）代替，而不是崩溃。


def decode_utf8_bytes_to_str_wrong(bytestring):
    """
    【错误示范】把每个字节单独解码，再拼起来。

    这个函数改编自 CS336 作业 1 里的一道题：它对纯英文能正常工作，但对中文会出错。

    【为什么错】
        多字节字符（例如 '你' = 3 个字节）必须 3 个字节**一起**解码。
        单独拿出第一个字节 0xE4 去解码，解码器会说："这是一个 3 字节字符的开头，
        但后面的字节不见了！" → UnicodeDecodeError。

    【Python 小知识】
        bytes([b]) 把一个整数 b 变成长度为 1 的 bytes。
        "".join(列表) 把列表里所有字符串首尾相接。
    """
    return "".join([bytes([b]).decode("utf-8") for b in bytestring])


def demo_decoding_pitfalls():
    """演示 5：各种解码失败的情况，以及安全的处理方式。"""
    print_header("演示 5：解码的陷阱")

    # (a) 纯英文：错误的函数碰巧能用
    print("(a) 纯英文用'逐字节解码'：", decode_utf8_bytes_to_str_wrong("hello".encode("utf-8")))

    # (b) 中文：错误的函数会崩溃
    try:
        decode_utf8_bytes_to_str_wrong("你好".encode("utf-8"))
    except UnicodeDecodeError as e:
        print(f"(b) 中文用'逐字节解码' → UnicodeDecodeError：{e.reason}")

    # (c) 把一个多字节字符"切断"：只取 '你' 的前 2 个字节
    truncated = "你".encode("utf-8")[:2]
    print(f"\n(c) '你' 的前 2 个字节 {truncated!r}：")
    try:
        truncated.decode("utf-8")
    except UnicodeDecodeError as e:
        print(f"    严格解码 → UnicodeDecodeError（{e.reason}）")
    print(f"    errors='replace' → {truncated.decode('utf-8', errors='replace')!r}  ← 用 U+FFFD 替换")
    print(f"    errors='ignore'  → {truncated.decode('utf-8', errors='ignore')!r}  ← 直接丢弃（信息丢失，不推荐）")

    # (d) 几种典型的非法字节序列
    print("\n(d) 典型的非法字节序列：")
    bad_examples = {
        b"\x80": "单独的后续字节（10xxxxxx 不能当开头）",
        b"\xff": "0xFF 永远不会出现在 UTF-8 中",
        b"\xc3\x28": "首字节 0xC3 要求后面跟 10xxxxxx，但 0x28 是 '(' ",
        b"\xe4\xbd": "3 字节字符只给了 2 个字节（被截断）",
    }
    for bs, why in bad_examples.items():
        try:
            bs.decode("utf-8")
            result = "（意外地成功了）"
        except UnicodeDecodeError:
            result = "解码失败"
        print(f"    {bs!r:14} → {result}；原因：{why}")

    # (e) 模拟"模型逐 token 生成"：token 边界切在了汉字中间
    print("\n(e) 模拟流式生成：假设模型依次生成了下面 3 个'token'（字节片段）")
    token_bytes = [b"\xe4\xbd", b"\xa0\xe5", b"\xa5\xbd"]   # 拼起来正好是 '你好'
    for i, tb in enumerate(token_bytes):
        print(f"    token {i}: {tb!r} 单独解码 → {tb.decode('utf-8', errors='replace')!r}")
    print(f"    全部拼接后再解码 → {b''.join(token_bytes).decode('utf-8')!r}")
    print("    结论：必须先把字节拼起来，再统一解码。这正是 BPE decode 的做法。")


# =============================================================================
# 第 6 部分：看起来一样的文字，字节可能不一样
# =============================================================================
# 【思路】
#   Unicode 允许同一个"视觉字符"有多种表示：
#     - 'é' 可以是 1 个码位 U+00E9（预组合形式），
#       也可以是 2 个码位 'e' + U+0301（字母 e + "组合用尖音符"）。
#     - 全角 'Ａ'（U+FF21）和半角 'A'（U+0041）看起来差不多，但码位不同。
#     - 零宽空格 U+200B 完全看不见，但它确实存在。
#
#   【大模型联系】
#   分词器看到的是字节，不是"视觉效果"。同一句话用不同的 Unicode 表示，
#   会被切成完全不同的 token，模型也就"看到"了不同的输入。
#   有些分词器（比如 SentencePiece 默认配置）会先做 Unicode 规范化（NFKC）；
#   而 GPT-2 式的字节级 BPE **不做**任何规范化，原样保留所有字节，
#   这样可以保证 decode(encode(s)) == s 严格成立。两种选择各有利弊。


def demo_normalization():
    """演示 6：Unicode 规范化与"看不见的字符"。"""
    print_header("演示 6：看起来一样，字节却不一样")

    composed = "é"          # é（1 个码位）
    decomposed = "é"       # e + 组合尖音符（2 个码位）
    print(f"预组合 é：{composed!r}  码位数={len(composed)}  UTF-8={list(composed.encode())}")
    print(f"分解式 é：{decomposed!r}  码位数={len(decomposed)}  UTF-8={list(decomposed.encode())}")
    print(f"两者用 == 比较：{composed == decomposed}   ← Python 认为它们不相等！")

    # NFC：尽量合成（组合成预组合形式）；NFD：尽量分解
    nfc = unicodedata.normalize("NFC", decomposed)
    print(f"NFC 规范化后比较：{nfc == composed}   ← 规范化之后才相等")

    # NFKC 还会做"兼容性"转换：全角 → 半角 等
    fullwidth = "ＡＢＣ１２３"
    print(f"\n全角字符 {fullwidth!r} 的 NFKC 规范化 → {unicodedata.normalize('NFKC', fullwidth)!r}")

    # 看不见的字符
    sneaky = "hello​world"   # 中间有一个零宽空格
    print(f"\n'hello\\u200bworld' 打印出来是：{sneaky}   ← 看起来没有空格")
    print(f"但它的长度是 {len(sneaky)}（不是 10），UTF-8 字节数是 {len(sneaky.encode())}")

    # chr(0)：空字符。作业 1 里的一道小题：它打印出来是什么？
    null_char = chr(0)
    print(f"\nchr(0) 的 repr 是 {null_char!r}，直接 print 时什么也看不见：[{null_char}]")
    print("它是合法的码位，UTF-8 编码为一个字节 0x00。分词器也必须能正确处理它。")

    # 肤色 emoji：看起来 1 个"字"，其实是 2 个码位
    thumbs = "👍🏽"
    print(f"\n'👍🏽' 看起来是 1 个符号，但 len = {len(thumbs)}，码位 = {[f'U+{ord(c):X}' for c in thumbs]}")


# =============================================================================
# 主函数：按顺序运行所有演示
# =============================================================================
def main():
    demo_code_points()
    demo_str_vs_bytes()
    demo_utf8_rules()
    demo_compare_encodings()
    demo_decoding_pitfalls()
    demo_normalization()
    print("\n全部演示运行完毕。建议对照讲义第 7、8 节再看一遍输出。")


# 【Python 小知识】
#   if __name__ == "__main__": 的意思是"只有直接运行这个文件时，才执行下面的代码"。
#   如果别的文件用 import 导入这个文件，main() 就不会自动运行。
#   这是 Python 脚本的标准写法。
if __name__ == "__main__":
    main()
