"""
=============================================================================
前置讲 P1 · 代码 01：字符、码位、字节 —— 一段文字到底"有多长"
=============================================================================

对应讲义：lectures/prereq1_text_bytes_tokens.md §1–§2

【这个脚本要回答的问题】
    1. "中"、"é"、"😀"、"👍🏽" 各是几个字符、几个码位、几个字节？len() 到底在数什么？（演示 1）
    2. 两个"看起来一模一样"的字符串为什么会不相等？（演示 2：Unicode 规范化）
    3. 文字里能藏着看不见的东西吗？（演示 3：隐形字符、同形字、反向显示）
    4. 字节被切断、被用错的编码解读时会发生什么？（演示 4：乱码与 "�"，流式输出的坑）

【怎么读输出】
    "码位"是 Unicode 给每个字符编的号（U+4E2D 就是"中"）；"字节"是这个字符在 UTF-8 编码下实际占的存储。

【运行】python3 01_bytes_unicode.py     （不到 1 秒）
【依赖】只用 Python 标准库；import 同目录的 text_lab.py。
"""

import codecs
import os
import sys
import unicodedata

# 共享的打印工具在第 1 讲的目录里：把那个目录加进 Python 的模块搜索路径（只读地使用，不修改它）
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01"))
from console import print_header, print_observations, print_table  # noqa: E402
from text_lab import describe, find_invisible, mixed_scripts  # noqa: E402


def demo1_lengths():
    """演示 1：字符、码位、字节。"""
    print_header("演示 1：一段文字有多长？——看你数的是什么")
    samples = [
        ("A", "英文字母"), ("é", "带重音的字母"), ("中", "汉字"), ("😀", "emoji"),
        ("👍🏽", "emoji + 肤色修饰"), ("🇨🇳", "国旗（两个'地区指示符'）"),
        ("👨‍👩‍👧", "家庭 emoji（用零宽连字拼起来）"), ("prompt", "英文单词"), ("提示工程", "中文词"),
    ]
    rows = []
    for s, note in samples:
        rows.append([s, note, len(s), len(s.encode("utf-8")), len(s.encode("utf-16-le")) // 2])
    print_table(["文字", "说明", "len()（码位数）", "UTF-8 字节数", "UTF-16 单元数"], rows)
    print("\n逐码位拆开 '👨‍👩‍👧'：")
    for ch, cp, name, b in describe("👨‍👩‍👧"):
        print(f"  {cp:8s} {b:12s} {name}")
    print_observations([
        "Python 的 len() 数的是**码位**，不是'你看到的字符'：一个家庭 emoji 是 5 个码位（3 个人 + 2 个零宽连字），"
        "一面国旗是 2 个码位。",
        "UTF-8 是**变长**编码：英文字母 1 个字节，带重音的拉丁字母 2 个，汉字 3 个，emoji 4 个。"
        "所以同样'一个字'，存储和传输的成本差了好几倍。",
        "API 的计费、上下文窗口、max_tokens 都按 **token** 算，而 token 是从**字节**切出来的（02 号脚本）。"
        "'这段提示有多少字'在不同的层面上有不同的答案，工程上要说清楚你在数哪一种。",
    ])


def demo2_normalization():
    """演示 2：看起来一样的字符串为什么不相等。"""
    print_header("演示 2：看起来一模一样，却不相等——Unicode 规范化")
    composed = "café"            # é 是一个码位 U+00E9
    decomposed = "café"         # e + 组合用的重音符号 U+0301
    full = "ＡＢＣ１２３"               # 全角字母和数字（中文输入法常常打出来）
    rows = [
        ["'café'（合成形式）", composed, len(composed), composed.encode("utf-8").hex(" ")],
        ["'café'（分解形式）", decomposed, len(decomposed), decomposed.encode("utf-8").hex(" ")],
    ]
    print_table(["写法", "显示", "码位数", "UTF-8 字节"], rows)
    print(f"\n  合成 == 分解？ {composed == decomposed}")
    print(f"  NFC 规范化后相等？ {unicodedata.normalize('NFC', composed) == unicodedata.normalize('NFC', decomposed)}")
    print(f"  全角 {full!r} 的 NFKC 规范化：{unicodedata.normalize('NFKC', full)!r}")
    print_observations([
        "同一个 'é' 可以是 1 个码位（合成），也可以是 'e' + 一个组合重音（分解）。屏幕上一模一样，== 却是 False。",
        "NFC 把它们统一成合成形式；NFKC 更激进，还会把全角字母、上标数字等'兼容字符'换成普通字符。",
        "【提示工程联系】评估时用'精确匹配'判对错、用字符串做缓存键或去重之前，先做规范化（以及去掉首尾空白）。"
        "否则模型明明答对了，却因为它输出的是另一种 'é' 被判错——这种 bug 很难从屏幕上看出来。",
    ])


def demo3_invisible():
    """演示 3：看不见的字符、同形字、反向显示。"""
    print_header("演示 3：文字里能藏东西——隐形字符、同形字、反向显示")
    a = "password"
    b = "pass​word"                       # 中间有一个零宽空格
    c = "pаypal"                          # 第二个字母是西里尔字母 а（U+0430）
    d = "Total: 100 dollars"              # 不换行空格
    e = "safe_‮txt.exe"                   # 右到左覆盖：显示成 "safe_exe.txt"
    rows = []
    for name, s in [("普通", a), ("夹了零宽空格", b), ("西里尔字母 а", c), ("不换行空格", d), ("右到左覆盖", e)]:
        inv = find_invisible(s)
        scripts = set().union(*(mixed_scripts(w) for w in s.replace("_", " ").split())) if s.strip() else set()
        rows.append([name, repr(s), len(s), s == a, "; ".join(f"{i}:{n.split('（')[0]}" for i, _, n in inv) or "-",
                     "+".join(sorted(scripts))])
    print_table(["情形", "repr()", "len", "== 'password'", "隐形字符（位置:名字）", "文字系统"], rows)
    print_observations([
        "零宽空格让 'pass\\u200bword' 显示得和 'password' 一样，长度却多 1、比较结果不相等。"
        "用 repr() 打印字符串（而不是 print），隐形字符才会现形。",
        "西里尔字母 а 和拉丁字母 a 长得一样（同形字，homoglyph）：一个词里混了两种文字系统，是钓鱼链接的典型特征。",
        "右到左覆盖字符（U+202E）会让后面的文字倒着显示：文件名看起来以 .txt 结尾，实际是 .exe。",
        "【提示工程联系】这些字符在从网页、PDF 复制来的文本里很常见，也会被故意用来在文档里藏指令"
        "（第 12 讲的间接提示注入）。把外部文本放进提示之前，检查并清理它们是基本的卫生习惯。",
    ])


def demo4_broken_bytes():
    """演示 4：用错编码、在字符中间切断。"""
    print_header("演示 4：字节被切断、被误读——乱码与 '�'")
    s = "提示"
    b = s.encode("utf-8")
    print(f"'{s}' 的 UTF-8 字节：{b.hex(' ')}（{len(b)} 个）")
    rows = [
        ["用正确的 UTF-8 解码", b.decode("utf-8")],
        ["误当成 Latin-1 解码（经典乱码）", b.decode("latin-1")],
        ["只收到前 4 个字节，strict 解码", _try_decode(b[:4], "strict")],
        ["只收到前 4 个字节，replace 解码", b[:4].decode("utf-8", errors="replace")],
        ["先收 4 个字节、再收 2 个，分别解码后拼接", b[:4].decode("utf-8", errors="replace") + b[4:].decode("utf-8", errors="replace")],
        ["先把字节攒齐再一起解码", (b[:4] + b[4:]).decode("utf-8")],
        ["用增量解码器，一块一块喂", _incremental([b[:4], b[4:]])],
    ]
    print_table(["做法", "结果"], rows, aligns=["<", "<"])
    print_observations([
        "把 UTF-8 字节当成 Latin-1 读，每个字节变成一个奇怪的拉丁字符——这就是'乱码'（mojibake）。",
        "一个汉字的 3 个字节被从中间切开，单独解码就会出错；用 errors='replace' 会得到替换字符 '�'。",
        "【提示工程联系】LLM API 的流式输出（前置讲 P2）是一小块一小块到达的，而字节级分词器的一个 token "
        "可能只是半个汉字（02 号脚本会看到 '<e6>' 这样的 token）。正确的做法是先攒字节、能解出完整字符再显示；"
        "Python 的 codecs.getincrementaldecoder('utf-8') 就是干这个的。",
    ])


def _incremental(chunks):
    """
    用增量解码器处理分块到达的字节：不完整的字符先留在解码器里，等后面的字节到了再一起输出。

    【Python 小知识】codecs.getincrementaldecoder("utf-8")() 创建一个"有记忆"的解码器；
        每次 .decode(块) 只返回已经完整的字符，最后 .decode(b"", final=True) 把剩下的冲出来。
    """
    dec = codecs.getincrementaldecoder("utf-8")()
    out = [dec.decode(c) for c in chunks]
    out.append(dec.decode(b"", final=True))
    return " | ".join(out) + "   （每块解出的文字，用 | 分开）"


def _try_decode(b, errors):
    """尝试解码，失败时返回错误信息（而不是让程序崩溃）。"""
    try:
        return b.decode("utf-8", errors=errors)
    except UnicodeDecodeError as e:
        return f"UnicodeDecodeError: {e.reason}"


def main():
    demo1_lengths()
    demo2_normalization()
    demo3_invisible()
    demo4_broken_bytes()


if __name__ == "__main__":
    main()
