#!/usr/bin/env python3
"""Compute the treatment-card materials hash for a set of files.

Reproduces the hash shown by the drafter (drafter/index.html) without a
browser, so anyone can check that a card was drafted from a given set of
materials. Pass files and/or directories; directories are walked
recursively and every file in them is included, hidden files too.

    python3 hash_materials.py path/to/materials/
    python3 hash_materials.py --manifest stimuli.pdf protocol.docx

Rule (manifest format sha256sum-v1): paths are taken relative to the deepest
directory containing all the files, with "/" separators and Unicode NFC;
each file gives a line "<sha256>  <path>\\n"; lines are sorted by the UTF-8
bytes of the path; the materials hash is the SHA-256 of those lines.
"""

import argparse
import hashlib
import os
import sys
import unicodedata


def collect(args):
    files = []
    for arg in args:
        if os.path.isdir(arg):
            for root, dirs, names in os.walk(arg):
                dirs.sort()
                for name in names:
                    path = os.path.join(root, name)
                    if os.path.isfile(path):
                        files.append(path)
        elif os.path.isfile(arg):
            files.append(arg)
        else:
            sys.exit(f"error: no such file or directory: {arg}")
    if not files:
        sys.exit("error: no files found")
    return files


def relative_paths(files):
    parts = [
        unicodedata.normalize("NFC", os.path.abspath(f)).split(os.sep)
        for f in files
    ]
    common = parts[0][:-1]
    for p in parts:
        dirs = p[:-1]
        i = 0
        while i < min(len(common), len(dirs)) and common[i] == dirs[i]:
            i += 1
        common = common[:i]
    return ["/".join(p[len(common):]) for p in parts]


def sha256_file(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def build_manifest(files):
    entries = []
    seen = set()
    for f, rel in zip(files, relative_paths(files)):
        if any(c in rel for c in "\n\r\\"):
            sys.exit(f"error: file names may not contain a newline or backslash: {rel!r}")
        if rel in seen:
            sys.exit(f"error: two files share the path {rel!r}")
        seen.add(rel)
        entries.append((rel.encode("utf-8"), sha256_file(f), rel))
    entries.sort()
    return "".join(f"{digest}  {rel}\n" for _, digest, rel in entries)


def main():
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("paths", nargs="+", help="files or directories")
    parser.add_argument("--manifest", action="store_true", help="also print the per-file manifest")
    args = parser.parse_args()

    manifest = build_manifest(collect(args.paths))
    if args.manifest:
        sys.stdout.write(manifest)
        print()
    print(f"materials sha256: {hashlib.sha256(manifest.encode('utf-8')).hexdigest()}")


if __name__ == "__main__":
    main()
