"""Every number the commentary claims, pinned. Plain asserts, no pytest.

Run:  python3 test_tinyfs.py

Images go under `test-data/`, re-formatted per test.
"""

import os
import subprocess
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from tinyfs import FileSystem, Inode

DATA = os.path.join(HERE, "test-data")
IMG = os.path.join(DATA, "t.img")

SECRET = (b"ROOT PASSWORD: hunter2-correct-horse\n"
          b"SSN: 123-45-6789\n"
          b"CARD: 4111 1111 1111 1111 exp 09/29 cvv 402\n")

TESTS = []


def test(fn):
    TESTS.append(fn)
    return fn


def fresh(**opts):
    return FileSystem.format(IMG, **opts)


def recycled(**opts):
    """write secret -> rm -> write a 3-byte file. Returns the fs."""
    fs = fresh(**opts)
    fs.write("secret.txt", SECRET)
    fs.rm("secret.txt")
    fs.write("notes.txt", b"ok\n")
    return fs


@test
def geometry_survives_a_remount():
    fs = fresh()
    assert (fs.block_size, fs.num_blocks, fs.num_inodes, fs.direct) == \
        (64, 32, 8, 4)
    assert fs.inode_size == 45, fs.inode_size          # 1 + 4 + 24 + 4*4
    assert fs.image_size == 32 + 8 * 45 + 32 + 32 * 64 == 2472
    m = FileSystem.mount(IMG)
    assert (m.block_size, m.num_blocks, m.num_inodes, m.direct) == \
        (64, 32, 8, 4)


@test
def a_file_round_trips_through_blocks():
    fs = fresh()
    fs.write("secret.txt", SECRET)
    assert len(SECRET) == 98
    assert fs.ls() == [("secret.txt", 98, [0, 1])]     # 98 bytes -> 2 blocks
    assert fs.read("secret.txt") == SECRET


@test
def rm_writes_nothing_to_the_data_region():
    fs = fresh()
    fs.write("secret.txt", SECRET)
    before = fs.block_writes
    fs.rm("secret.txt")
    assert fs.block_writes - before == 0
    assert fs.ls() == []
    assert sum(fs.bitmap()) == 0                       # bits are back


@test
def rm_leaves_the_plaintext_in_the_block():
    fs = fresh()
    fs.write("secret.txt", SECRET)
    fs.rm("secret.txt")
    assert fs.block(0) == SECRET[:64]                  # untouched on disk


@test
def first_fit_hands_the_freed_block_straight_back():
    fs = recycled()
    assert fs.ls() == [("notes.txt", 3, [0])]          # block 0 again


@test
def the_honest_read_path_leaks_nothing():
    fs = recycled()
    got = fs.read("notes.txt")
    assert got == b"ok\n" and len(got) == 3            # capped at inode.size


@test
def a_legal_truncate_grow_exposes_61_bytes():
    fs = recycled()
    fs.truncate("notes.txt", 64)
    got = fs.read("notes.txt")
    assert len(got) == 64
    assert got[:3] == b"ok\n"
    assert got[3:] == SECRET[3:64]
    assert len(got[3:]) == 61
    assert b"hunter2-correct-horse" in got
    assert b"123-45-6789" in got


@test
def zero_on_free_closes_it_and_costs_one_write_per_freed_block():
    fs = fresh(zero_on_free=True)
    fs.write("secret.txt", SECRET)
    before = fs.block_writes
    fs.rm("secret.txt")
    assert fs.block_writes - before == 2               # was 0
    fs.write("notes.txt", b"ok\n")
    fs.truncate("notes.txt", 64)
    assert fs.read("notes.txt") == b"ok\n" + b"\0" * 61


@test
def zero_on_alloc_closes_it_from_the_other_side():
    fs = recycled(zero_on_alloc=True)
    fs.truncate("notes.txt", 64)
    assert fs.read("notes.txt") == b"ok\n" + b"\0" * 61


@test
def the_two_fixes_bill_different_operations():
    bills = {}
    for label, opts in (("default", {}),
                        ("free", {"zero_on_free": True}),
                        ("alloc", {"zero_on_alloc": True})):
        fs = FileSystem.format(os.path.join(DATA, "cost.img"), num_blocks=128,
                               num_inodes=4, direct=64, **opts)
        fs.write("big", b"A" * 4096)
        w = fs.block_writes
        fs.rm("big")
        bills[label] = (w, fs.block_writes - w)
    assert bills == {"default": (64, 0), "free": (64, 64), "alloc": (128, 0)}


@test
def the_leak_is_exactly_the_slack_in_the_last_block():
    for n, expected in ((3, 61), (62, 2), (63, 1), (64, 0)):
        fs = fresh()
        fs.write("secret.txt", SECRET)
        fs.rm("secret.txt")
        fs.write("notes.txt", b"x" * n)
        fs.truncate("notes.txt", 64)
        got = fs.read("notes.txt")
        stale = sum(1 for i in range(n, 64) if got[i] == SECRET[i])
        assert stale == expected, (n, stale, expected)


@test
def truncate_down_frees_blocks_and_truncate_up_reallocates():
    fs = fresh()
    fs.write("f", b"z" * 200)
    assert fs.ls() == [("f", 200, [0, 1, 2, 3])]
    fs.truncate("f", 10)
    assert fs.ls() == [("f", 10, [0])]
    assert sum(fs.bitmap()) == 1
    fs.truncate("f", 200)
    assert fs.ls() == [("f", 200, [0, 1, 2, 3])]
    assert fs.read("f")[:10] == b"z" * 10
    assert fs.read("f")[64:] == b"z" * 136             # blocks 1-3 came back


@test
def overwriting_a_name_recycles_its_own_blocks():
    fs = fresh()
    fs.write("f", SECRET)
    fs.write("f", b"tiny")
    assert fs.ls() == [("f", 4, [0])]
    assert fs.read("f") == b"tiny"


@test
def the_limits_are_enforced():
    fs = fresh()
    try:
        fs.write("f", b"z" * 257)                      # 5 blocks > 4 direct
        raise AssertionError("expected OSError")
    except OSError as e:
        assert "too large" in str(e)
    try:
        fs.read("nope")
        raise AssertionError("expected FileNotFoundError")
    except FileNotFoundError:
        pass
    try:
        FileSystem.mount(os.path.join(DATA, "junk.img"))
        raise AssertionError("expected OSError")
    except OSError as e:
        assert "not a tinyfs image" in str(e)


@test
def an_inode_is_a_fixed_size_record():
    raw = Inode(1, 98, b"secret.txt", [0, 1]).pack(4)
    assert len(raw) == 45
    back = Inode.unpack(raw, 4)
    assert (back.used, back.size, back.name) == (1, 98, b"secret.txt")
    assert back.blocks == [0, 1, 0, 0]


@test
def the_cli_reproduces_the_leak():
    img = os.path.join(DATA, "cli.img")
    env = dict(os.environ, TINYFS_IMG=img)
    def cli(*args):
        return subprocess.run([sys.executable, os.path.join(HERE, "tinyfs.py"),
                               *args], capture_output=True, env=env, cwd=HERE)
    cli("format")
    cli("write", "secret.txt", SECRET.decode())
    cli("rm", "secret.txt")
    cli("write", "notes.txt", "ok\n")
    assert cli("read", "notes.txt").stdout == b"ok\n"
    cli("truncate", "notes.txt", "64")
    assert cli("read", "notes.txt").stdout == b"ok\n" + SECRET[3:64]
    assert cli("ls").stdout == b"notes.txt        64 bytes  blocks=[0]\n"


if __name__ == "__main__":
    os.makedirs(DATA, exist_ok=True)
    with open(os.path.join(DATA, "junk.img"), "wb") as f:
        f.write(b"not a filesystem at all, just bytes")
    for fn in TESTS:
        fn()
        print("ok   %s" % fn.__name__.replace("_", " "))
    print("\n%d tests passed" % len(TESTS))
