```python
#!/usr/bin/env python3
# 把 /data/media/uncasefolded/0 安全合并回 /data/media/0
# 原则：只做原子无覆盖 rename，绝不覆盖、绝不删除；冲突改名保留双方；全部动作留日志。
# EXDEV（f2fs 跨 project id 限制）：临时置 0 后 rename，结束恢复原值（崩溃可恢复）。
import os, sys, time, ctypes, subprocess, shutil

SRC = "/data/media/uncasefolded/0"
DST = "/data/media/0"
BASE = "/data/local/tmp"
LOGP = BASE + "/restore_log.txt"
MAPP = BASE + "/restore_map.tsv"
SRCM = BASE + "/src_manifest.tsv"
DSTM0 = BASE + "/dst_manifest_pre.tsv"
ERRP = BASE + "/restore_err.log"
PENDING = BASE + "/pending_projid.tsv"

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

class CrossDevice(Exception):
    def __init__(self, dst):
        self.dst = dst

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 errf(msg):
    with open(ERRP, "a", encoding="utf-8", errors="surrogateescape") as f:
        f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")

def build_map(d):
    m = {}
    for n in os.listdir(d):
        m.setdefault(ci(n), n)
    return m

def count_files(d):
    n = 0
    for root, dirs, files in os.walk(d):
        n += len(files)
    return 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):
    """把非零 projid 临时置 0 并登记（持久化，崩溃后可恢复）"""
    changed = False
    for p in (dst_parent, src_parent):
        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 cmd_manifest():
    for src, out in ((SRC, SRCM), (DST, DSTM0)):
        nf = nd = 0
        total = 0
        with open(out, "w", encoding="utf-8", errors="surrogateescape") as f:
            for root, dirs, files in os.walk(src):
                rel = os.path.relpath(root, src)
                for d in dirs:
                    f.write(os.path.join(rel, d) + "\td\t0\n")
                    nd += 1
                for fn in files:
                    p = os.path.join(root, fn)
                    try:
                        st = os.lstat(p)
                    except OSError:
                        continue
                    f.write(os.path.join(rel, fn) + "\tf\t" + str(st.st_size) + "\n")
                    nf += 1
                    total += st.st_size
        print("manifest %s: dirs=%d files=%d bytes=%d" % (src, nd, nf, total))

def resolve_name(name, bykey):
    """在 bykey 中占位一个不冲突的名字，返回 (name, 是否改名)"""
    key = ci(name)
    if key not in bykey:
        bykey[key] = name
        return name, False
    i = 2
    base = name
    while ci(base) in bykey:
        base = "%s (uncasefolded)" % name if i == 2 else "%s (uncasefolded %d)" % (name, i)
        i += 1
    bykey[ci(base)] = base
    return base, True

def try_rename(src, dst_path, src_parent, dst_parent):
    """rename，遇到 EXDEV 就临时清零两端 projid 再重试"""
    for _ in range(3):
        try:
            rename_noreplace(src, dst_path)
            return
        except OSError as ex:
            if ex.errno == EXDEV:
                if zero_projids(src_parent, dst_parent):
                    continue
                raise
            raise

def move_entry(e, dst_dir, bykey):
    for attempt in range(6):
        name, renamed = resolve_name(e.name, bykey)
        dst_path = os.path.join(dst_dir, name)
        try:
            try_rename(e.path, dst_path, os.path.dirname(e.path), dst_dir)
            return dst_path, renamed
        except OSError as ex:
            if ex.errno == EEXIST:
                bykey.clear()
                bykey.update(build_map(dst_dir))
                continue
            if ex.errno == EXDEV:
                k = ci(name)
                if bykey.get(k) == name:
                    del bykey[k]
                raise CrossDevice(dst_path)
            raise
    raise SystemExit("giveup: " + e.path)

def mkdir_preserve(src_dir, dst_dir):
    st = os.lstat(src_dir)
    os.mkdir(dst_dir)
    try:
        os.chown(dst_dir, st.st_uid, st.st_gid)
    except OSError:
        pass
    shutil.copystat(src_dir, dst_dir, follow_symlinks=False)
    subprocess.call(["toybox", "restorecon", dst_dir],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

def copy_file(src_path, dst_path):
    """兜底复制：临时文件+校验+fsync+无覆盖改名+删源。绝不覆盖。"""
    if os.path.islink(src_path):
        target = os.readlink(src_path)
        st = os.lstat(src_path)
        tmp = dst_path + ".restoretmp0"
        os.symlink(target, tmp)
        try:
            os.lchown(tmp, st.st_uid, st.st_gid)
        except OSError:
            pass
        rename_noreplace(tmp, dst_path)
        os.unlink(src_path)
        return
    st = os.lstat(src_path)
    sfd = os.open(src_path, os.O_RDONLY)
    tmp = None
    try:
        dfd = None
        for i in range(100):
            t = "%s.restoretmp%d" % (dst_path, i)
            try:
                dfd = os.open(t, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
                tmp = t
                break
            except FileExistsError:
                continue
        if dfd is None:
            raise OSError("cannot create temp for " + dst_path)
        try:
            copied = 0
            while True:
                buf = os.read(sfd, 1 << 20)
                if not buf:
                    break
                view = memoryview(buf)
                while view:
                    n = os.write(dfd, view)
                    view = view[n:]
                copied += len(buf)
            if copied != st.st_size:
                raise OSError("copy size mismatch %s: %d != %d" % (src_path, copied, st.st_size))
            os.fsync(dfd)
            os.fchown(dfd, st.st_uid, st.st_gid)
        finally:
            os.close(dfd)
        shutil.copystat(src_path, tmp, follow_symlinks=False)
        rename_noreplace(tmp, dst_path)
        subprocess.call(["toybox", "restorecon", dst_path],
                        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        os.unlink(src_path)
    finally:
        os.close(sfd)
        if tmp:
            try:
                os.unlink(tmp)
            except OSError:
                pass

def cmd_merge():
    print("restoring pending projid from previous run:", restore_pending())
    n0 = count_files(SRC)
    print("src files before:", n0)
    time.sleep(20)
    n1 = count_files(SRC)
    if n0 != n1:
        print("!! uncasefolded 里文件数仍在变化 (%d -> %d)，疑似迁移未结束，中止！" % (n0, n1))
        sys.exit(2)
    stats = {"files": 0, "dirs": 0, "conflicts": 0, "merged": 0, "copied": 0}
    t0 = time.time()
    mapfh = open(MAPP, "a", encoding="utf-8", errors="surrogateescape")

    def merge_dir(src_dir, dst_dir, depth=0):
        bykey = build_map(dst_dir)
        for e in sorted(os.scandir(src_dir), key=lambda x: x.name):
            key = ci(e.name)
            isd = e.is_dir(follow_symlinks=False)
            if key in bykey:
                tpath = os.path.join(dst_dir, bykey[key])
                if isd and os.path.isdir(tpath) and not os.path.islink(tpath):
                    stats["merged"] += 1
                    logf("MERGE %s -> %s/" % (e.path, tpath))
                    merge_dir(e.path, tpath, depth + 1)
                    continue
            try:
                dst_path, renamed = move_entry(e, dst_dir, bykey)
            except CrossDevice as ex:
                # 真实跨挂载点，rename 不可能：目录重建递归，文件复制
                name, renamed = resolve_name(e.name, bykey)
                dst_path = os.path.join(dst_dir, name)
                if isd:
                    mkdir_preserve(e.path, dst_path)
                    stats["dirs"] += 1
                    stats["copied"] += 1
                    logf("MKDIR-XDEV %s -> %s" % (e.path, dst_path))
                    mapfh.write("%s\t%s\n" % (os.path.relpath(e.path, SRC), os.path.relpath(dst_path, DST)))
                    merge_dir(e.path, dst_path, depth + 1)
                else:
                    copy_file(e.path, dst_path)
                    stats["files"] += 1
                    stats["copied"] += 1
                    logf("COPY-XDEV %s -> %s" % (e.path, dst_path))
                    mapfh.write("%s\t%s\n" % (os.path.relpath(e.path, SRC), os.path.relpath(dst_path, DST)))
                if renamed:
                    stats["conflicts"] += 1
                continue
            if isd:
                stats["dirs"] += 1
            else:
                stats["files"] += 1
            if renamed:
                stats["conflicts"] += 1
            logf("MOVED %s -> %s" % (e.path, dst_path))
            mapfh.write("%s\t%s\n" % (os.path.relpath(e.path, SRC), os.path.relpath(dst_path, DST)))
            if (stats["files"] + stats["dirs"]) % 2000 == 0:
                print("progress: files=%d dirs=%d conflicts=%d merged=%d copied=%d elapsed=%.0fs" %
                      (stats["files"], stats["dirs"], stats["conflicts"], stats["merged"], stats["copied"], time.time() - t0))

    print("=== merge start")
    logf("=== merge start")
    try:
        merge_dir(SRC, DST)
    finally:
        n = restore_pending()
        print("projid restored:", n)
        os.system("sync")
    mapfh.close()
    print("=== merge done:", stats)
    logf("=== merge done %r" % (stats,))

def norm(p):
    return p[2:] if p.startswith("./") else p

def cmd_verify():
    srcf = {}
    with open(SRCM, encoding="utf-8", errors="surrogateescape") as f:
        for line in f:
            p, t, s = line.rstrip("\n").split("\t")
            if t == "f":
                srcf[norm(p)] = int(s)
    mp = {}
    with open(MAPP, encoding="utf-8", errors="surrogateescape") as f:
        for line in f:
            s, d = line.rstrip("\n").split("\t")
            mp[norm(s)] = norm(d)
    pre = set()
    with open(DSTM0, encoding="utf-8", errors="surrogateescape") as f:
        for line in f:
            p, t, s = line.rstrip("\n").split("\t")
            if t == "f":
                pre.add(norm(p))
    dstf = {}
    total = 0
    for root, dirs, files in os.walk(DST):
        rel = os.path.relpath(root, DST)
        for fn in files:
            p = norm(os.path.join(rel, fn))
            st = os.lstat(os.path.join(DST, p))
            dstf[p] = st.st_size
            total += st.st_size
    left = 0
    for root, dirs, files in os.walk(SRC):
        left += len(files)

    def resolve(p):
        if p in mp:
            return mp[p]
        parts = p.split("/")
        for i in range(len(parts) - 1, 0, -1):
            anc = "/".join(parts[:i])
            if anc in mp:
                return os.path.join(mp[anc], "/".join(parts[i:]))
        return None

    problems = 0
    nores = 0
    for p, sz in srcf.items():
        d = resolve(p)
        if d is None:
            problems += 1
            nores += 1
            errf("NO-MAP " + p)
            continue
        if d not in dstf:
            problems += 1
            errf("NO-DST %s -> %s" % (p, d))
            continue
        if dstf[d] != sz:
            problems += 1
            errf("SIZE-MISMATCH %s -> %s (%d vs %d)" % (p, d, sz, dstf[d]))
    expected = set()
    for p in srcf:
        d = resolve(p)
        if d:
            expected.add(d)
    expected |= pre
    extra = set(dstf) - expected
    pre_missing = pre - set(dstf)
    print("verify: src_files=%d nores=%d problems=%d" % (len(srcf), nores, problems))
    print("verify: src 剩余文件数(应为0)=%d" % left)
    print("verify: dst files=%d, expected=%d, 多余(迁移期间新出现的)=%d, 原dst文件消失=%d" %
          (len(dstf), len(expected), len(extra), len(pre_missing)))
    for x in sorted(extra)[:60]:
        print("  EXTRA:", x)
    for x in sorted(pre_missing)[:30]:
        print("  PRE-MISSING:", x)
    if problems == 0 and left == 0:
        print("=== VERIFY OK ===")
    else:
        print("=== VERIFY FAILED ===")

def cmd_chattr():
    ok = fail = 0
    with open(BASE + "/chattr_fail.log", "a", encoding="utf-8", errors="surrogateescape") as failf:
        for root, dirs, files in os.walk(DST):
            for d in dirs:
                p = os.path.join(root, d)
                try:
                    subprocess.run(["/system/bin/toybox", "chattr", "+F", p],
                                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                   check=True, timeout=30)
                    ok += 1
                except Exception:
                    fail += 1
                    failf.write(p + "\n")
    print("chattr: ok=%d fail=%d" % (ok, fail))

if __name__ == "__main__":
    cmd = sys.argv[1] if len(sys.argv) > 1 else ""
    cmds = {"manifest": cmd_manifest, "merge": cmd_merge, "verify": cmd_verify, "chattr": cmd_chattr}
    if cmd not in cmds:
        print("usage: restore_merge.py manifest|merge|verify|chattr")
        sys.exit(1)
    cmds[cmd]()
