```python
#!/usr/bin/env python3
# normalize_casefold.py — 为 /data/media/0 下可转换的旧目录启用 casefold（F 标志）
# 用法: su -c 'python3 /data/local/tmp/normalize_casefold.py'
import os, sys, time, ctypes, subprocess, shutil

ROOT = "/data/media/0"
BASE = "/data/local/tmp"
LOGP = BASE + "/norm_log.txt"
MAPP = BASE + "/norm_map.tsv"
STATE = BASE + "/norm_state.tsv"
PENDING = BASE + "/pending_projid.tsv"

libc = ctypes.CDLL(None, use_errno=True)
AT_FDCWD = -100
RENAME_NOREPLACE = 1
EEXIST = 17
EXDEV = 18

stage_counter = 0

def rename_noreplace(src, dst):
    r = libc.renameat2(AT_FDCWD, os.fsencode(src), AT_FDCWD, os.fsencode(dst), RENAME_NOREPLACE)
    if r != 0:
        e = ctypes.get_errno()
        raise OSError(e, "renameat2(%r, %r): %s" % (src, dst, os.strerror(e)))

def ci(n):
    return n.casefold()

def logf(msg):
    with open(LOGP, "a", encoding="utf-8", errors="surrogateescape") as f:
        f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")

def run(cmd):
    return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout.decode(errors="replace").strip()

def get_projid(path):
    try:
        return int(run(["toybox", "lsattr", "-p", "-d", path]).split()[0])
    except Exception:
        return None

def set_projid(path, val):
    return subprocess.call(["toybox", "chattr", "-p", str(val), path],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0

def zero_projids(src_parent, dst_parent, src_inode=None):
    """把非零 projid 临时置 0 并登记：两个父目录 + 源 inode 本身"""
    changed = False
    for p in (dst_parent, src_parent, src_inode):
        if not p:
            continue
        v = get_projid(p)
        if v is not None and v != 0:
            if set_projid(p, 0):
                with open(PENDING, "a", encoding="utf-8", errors="surrogateescape") as f:
                    f.write("%s\t%d\n" % (p, v))
                changed = True
    return changed

def restore_pending():
    entries = []
    try:
        with open(PENDING, encoding="utf-8", errors="surrogateescape") as f:
            for line in f:
                line = line.rstrip("\n")
                if line:
                    p, v = line.split("\t", 1)
                    entries.append((p, int(v)))
    except FileNotFoundError:
        return 0
    n = 0
    for p, v in entries:
        if set_projid(p, v):
            n += 1
    try:
        os.remove(PENDING)
    except OSError:
        pass
    return n

def try_rename(src, dst_path, src_parent, dst_parent):
    for _ in range(4):
        try:
            rename_noreplace(src, dst_path)
            return
        except OSError as ex:
            if ex.errno == EXDEV:
                if zero_projids(src_parent, dst_parent, src):
                    continue
                raise
            raise

def chattr_f(d):
    return subprocess.call(["toybox", "chattr", "+F", d],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0

def has_ci_dup_names(names):
    seen = set()
    for n in names:
        k = ci(n)
        if k in seen:
            return True
        seen.add(k)
    return False

def state_add(stage, orig):
    with open(STATE, "a", encoding="utf-8", errors="surrogateescape") as f:
        f.write("%s\t%s\n" % (stage, orig))

def state_remove(stage):
    rows = []
    try:
        with open(STATE, encoding="utf-8", errors="surrogateescape") as f:
            for line in f:
                line = line.rstrip("\n")
                if line:
                    s, o = line.split("\t", 1)
                    if s != stage:
                        rows.append((s, o))
    except FileNotFoundError:
        return
    with open(STATE, "w", encoding="utf-8", errors="surrogateescape") as f:
        for s, o in rows:
            f.write("%s\t%s\n" % (s, o))

def crash_recovery():
    """恢复上次运行中断的 stage 目录：state 文件 + 日志里的 STAGE-LEFT/FAIL 配对"""
    pairs = []
    try:
        with open(STATE, encoding="utf-8", errors="surrogateescape") as f:
            for line in f:
                line = line.rstrip("\n")
                if line:
                    s, o = line.split("\t", 1)
                    pairs.append((s, o))
    except FileNotFoundError:
        pass
    last_fail_orig = None
    try:
        with open(LOGP, encoding="utf-8", errors="surrogateescape") as f:
            for line in f:
                if line.startswith("20") and " FAIL " in line:
                    try:
                        last_fail_orig = line.split(" FAIL ", 1)[1].split(":", 1)[0]
                    except Exception:
                        pass
                elif " STAGE-LEFT " in line:
                    try:
                        stg = line.split(" STAGE-LEFT ", 1)[1].strip()
                    except Exception:
                        stg = None
                    if stg and last_fail_orig and stg not in [s for s, o in pairs]:
                        pairs.append((stg, last_fail_orig))
    except FileNotFoundError:
        pass
    n = 0
    for stage, orig in pairs:
        if not os.path.isdir(stage):
            continue
        if not os.path.isdir(orig):
            try:
                os.rename(stage, orig)
                logf("RECOVER-RENAME-STAGE %s -> %s" % (stage, orig))
            except OSError as ex:
                logf("RECOVER-FAIL %s: %s" % (stage, ex))
            continue
        try:
            for name in sorted(os.listdir(stage)):
                sp = os.path.join(stage, name)
                dst = os.path.join(orig, name)
                try:
                    try_rename(sp, dst, stage, orig)
                except OSError as ex:
                    if ex.errno == EEXIST:
                        base = "%s (uncasefolded)" % name
                        i = 2
                        while ci(base) in {ci(x) for x in os.listdir(orig)}:
                            base = "%s (uncasefolded %d)" % (name, i)
                            i += 1
                        dst2 = os.path.join(orig, base)
                        try_rename(sp, dst2, stage, orig)
                        logf("RECOVER-RENAME %s -> %s" % (sp, dst2))
                        continue
                    raise
                logf("RECOVER-MOVE %s -> %s" % (sp, dst))
            os.rmdir(stage)
            n += 1
        except Exception as ex:
            logf("RECOVER-FAIL %s: %s" % (stage, ex))
    return n

def next_suffix(d, name):
    base = "%s (uncasefolded)" % name
    i = 2
    while ci(base) in {ci(x) for x in os.listdir(d)}:
        base = "%s (uncasefolded %d)" % (name, i)
        i += 1
    return base

def norm_dir(d, mapfh):
    """搬空 -> +F -> 搬回。返回 'done'|'fail'"""
    global stage_counter
    names = sorted(os.listdir(d))
    parent = os.path.dirname(d)
    stage_counter += 1
    stage = "%s/.norm_stage_%d_%d" % (parent, os.getpid(), stage_counter)
    state_add(stage, d)
    try:
        os.mkdir(stage)
    except OSError as ex:
        logf("FAIL-MKDIR-STAGE %s: %s" % (stage, ex))
        return "fail"
    moved = []
    try:
        for n in names:
            try:
                try_rename(os.path.join(d, n), os.path.join(stage, n), d, stage)
                moved.append(n)
            except Exception as ex:
                for m in reversed(moved):
                    try:
                        try_rename(os.path.join(stage, m), os.path.join(d, m), stage, d)
                    except Exception as ex2:
                        logf("ROLLBACK-FAIL %s <- %s: %s" % (os.path.join(d, m), os.path.join(stage, m), ex2))
                raise
        if not chattr_f(d):
            raise OSError("chattr +F failed on " + d)
        for n in moved:
            sp = os.path.join(stage, n)
            dst = os.path.join(d, n)
            try:
                try_rename(sp, dst, stage, d)
            except OSError as ex:
                if ex.errno == EEXIST:
                    nn = next_suffix(d, n)
                    dst2 = os.path.join(d, nn)
                    try_rename(sp, dst2, stage, d)
                    mapfh.write("%s\t%s\n" % (dst, dst2))
                    logf("RENAME-CONFLICT %s -> %s" % (dst, dst2))
                else:
                    raise
    except Exception as ex:
        logf("FAIL %s: %s" % (d, ex))
        return "fail"
    finally:
        try:
            os.rmdir(stage)
            state_remove(stage)
        except OSError:
            logf("STAGE-LEFT %s" % stage)
    return "done"

def main():
    print("crash recovery:", crash_recovery())
    print("restoring pending projid:", restore_pending())
    todo = []
    stats = {"already_f": 0, "skip_dup": 0, "done": 0, "fail": 0}
    t0 = time.time()
    for root, dirs, files in os.walk(ROOT, topdown=False):
        for d in dirs:
            p = os.path.join(root, d)
            names = os.listdir(p)
            if has_ci_dup_names(names):
                stats["skip_dup"] += 1
                logf("SKIP-DUP %s" % p)
                continue
            n0 = len(names)
            if chattr_f(p):
                n1 = len(os.listdir(p))
                if n1 != n0:
                    logf("CRITICAL-SHADOW %s (%d->%d)" % (p, n0, n1))
                    subprocess.call(["toybox", "chattr", "-F", p],
                                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                stats["already_f"] += 1
                continue
            todo.append(p)
    print("phase1: already_f=%d skip_dup=%d todo=%d (%.0fs)" %
          (stats["already_f"], stats["skip_dup"], len(todo), time.time() - t0))
    mapfh = open(MAPP, "a", encoding="utf-8", errors="surrogateescape")
    try:
        for i, p in enumerate(todo):
            r = norm_dir(p, mapfh)
            stats[r] += 1
            if (i + 1) % 200 == 0:
                print("phase2: %d/%d done=%d fail=%d elapsed=%.0fs" %
                      (i + 1, len(todo), stats["done"], stats["fail"], time.time() - t0))
    finally:
        mapfh.close()
        n = restore_pending()
        print("projid restored:", n)
        os.system("sync")
    print("=== norm done:", stats)
    logf("=== norm done %r" % (stats,))

if __name__ == "__main__":
    main()
