"""
Practice: an audit log that shows when it has been changed. Lab 16's audit.py and script 03
(Exercise 1): fingerprints (hashes) chained entry to entry, the cover-up that fools the chain,
and anchors kept where the person who can write the log can't.

Fill in the three TODOs (entry_hash, verify, rehash), then run the tests:
    pytest test_audit_practice.py        (locally)
or press "Run tests" on the chapter page. The tamper test at the bottom is given.
"""
import hashlib
import hmac
import json
import random

GENESIS = "0" * 64


def canonical(body):
    """The bytes a fingerprint is computed from: keys sorted, no spaces, UTF-8."""
    return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()


def entry_hash(prev, seq, body):
    """sha256 of the previous fingerprint, the sequence number and the body, as hex:
    the bytes are prev.encode() + str(seq).encode() + canonical(body)."""
    # TODO: the fingerprint of one entry.
    return ""


class Anchors:
    """Copies of the latest fingerprint, kept where whoever writes the log can't write. Each copy
    carries an HMAC from a key the application doesn't hold."""

    def __init__(self, every=50, key=b"held by another team"):
        self.every = every
        self.key = key
        self.marks = []

    def sign(self, seq, h):
        return hmac.new(self.key, f"{seq}:{h}".encode(), hashlib.sha256).hexdigest()

    def add(self, seq, h):
        self.marks.append({"seq": seq, "hash": h, "mac": self.sign(seq, h)})


def append(entries, body, anchors=None):
    """Add one entry to the end of the log (a list), and anchor every `anchors.every` entries."""
    seq = len(entries)
    prev = entries[-1]["hash"] if entries else GENESIS
    e = {"seq": seq, "prev": prev, "body": body, "hash": entry_hash(prev, seq, body)}
    entries.append(e)
    if anchors and (seq + 1) % anchors.every == 0:
        anchors.add(seq, e["hash"])
    return e


def verify_plain(entries):
    """A log without fingerprints can only check its sequence numbers."""
    for i, e in enumerate(entries):
        if e["seq"] != i:
            return f"entry {i}: sequence number {e['seq']}"
    return None


def verify(entries, anchors=None):
    """None if the log is intact; otherwise the first problem found, as a message.
    Walk the entries in order, starting from prev = GENESIS. For entry i, in this order:
      - its seq isn't i                     -> f"entry {i}: sequence number {seq}"
      - its prev isn't the previous hash    -> f"entry {i}: doesn't follow the entry before it"
      - its hash isn't entry_hash(prev, seq, body), recomputed from its text
                                            -> f"entry {i}: content doesn't match its hash"
    Then, if anchors are given, for each mark m in anchors.marks:
      - its mac isn't anchors.sign(seq, hash) (compare with hmac.compare_digest)
                                            -> f"anchor at {seq}: bad signature"
      - the log has no entry at m["seq"]     -> f"anchor at {seq}: the log ends at {len - 1} (entries were cut off)"
      - that entry's hash isn't m["hash"]    -> f"anchor at {seq}: the log's hash differs from the anchored one"
    """
    # TODO: the chain check, then the anchors.
    return None


def rehash(entries, start):
    """The cover-up: from position `start` to the end, set each entry's seq to its position, its
    prev to the hash before it (GENESIS for position 0), and recompute its hash. Return a new list
    of copied entries (dict(e)); don't change the one passed in."""
    # TODO: what someone who can write the log does after their change.
    return [dict(e) for e in entries]


# ---- The lab's tamper test (given) --------------------------------------------------------------

KINDS = ["edit an answer", "swap two entries", "delete an entry", "delete, then renumber + rehash",
         "edit, then rehash", "insert a fake entry, rehash", "cut off the end"]


def tamper(kind, entries, rng, new_user="mallory"):
    """One change, made by someone who can write the log, at a random position."""
    e = [dict(x, body=dict(x["body"])) for x in entries]   # a copy (bodies here are flat)
    n = len(e)
    i = rng.randrange(n - 1)
    if kind == "edit an answer":
        e[i]["body"]["answer"] = "I don't have that information."
    elif kind == "edit, then rehash":
        e[i]["body"]["user"] = new_user
        e = rehash(e, i)
    elif kind == "delete an entry":
        del e[i]
    elif kind == "delete, then renumber + rehash":
        del e[i]
        e = rehash(e, i)
    elif kind == "swap two entries":
        e[i], e[i + 1] = e[i + 1], e[i]
    elif kind == "cut off the end":
        e = e[:rng.randrange(n - 30, n)]
    elif kind == "insert a fake entry, rehash":
        fake = dict(e[i], body=dict(e[i]["body"], answer="(inserted)"))
        e.insert(i + 1, fake)
        e = rehash(e, i + 1)
    return e


def tamper_test(entries, anchors, trials=200, seed=16, new_user="mallory", kinds=KINDS):
    """Each kind of change `trials` times, in the lab's order and with its seed, so the positions are
    the lab's. How many each check caught: {kind: (plain, chain, anchored)}. Pass `kinds` to report
    only some (the others still draw their random positions, so the positions stay the lab's)."""
    rng = random.Random(seed)
    out = {}
    for kind in KINDS:
        if kind not in kinds:
            for _ in range(trials):
                rng.randrange(len(entries) - 1)
                if kind == "cut off the end":
                    rng.randrange(len(entries) - 30, len(entries))
            continue
        c = [0, 0, 0]
        for _ in range(trials):
            t = tamper(kind, entries, rng, new_user)
            chain = verify(t)
            c[0] += verify_plain(t) is not None
            c[1] += chain is not None
            # verify(t, anchors) checks the chain first, so whatever the chain caught, it catches too.
            c[2] += chain is not None or verify(t, anchors) is not None
        out[kind] = tuple(c)
    return out
