#!/usr/bin/env python3
"""Check: every piece of work that was finished left a report.

    work-paired <directory>
    work-paired --next <directory>    print the number the next brief takes

Briefs live in docs/briefs/, reports in docs/reports/, and they pair by the
number at the front of the filename:

    docs/briefs/007-slow-login.md
    docs/reports/007-slow-login.md

A brief carries `status: open`, `done` or `dropped`.

A round is one piece of work like any other, so it takes a number too. Its
brief is short and points at `docs/rounds/<name>/`, where the argument lives.
The report at the same number says what came out.

CATCHES  A brief marked done with no report. That is work that happened and
         left nothing behind, which is the state everybody's memory is already
         in and the reason none of this can be reconstructed later.

         Also a report answering a brief that does not exist, two briefs
         sharing a number, and a status nobody can read.

An open brief is not a problem. Work in progress is the normal state of a
project, and a check that complained about it would be a check people turn off.

Exit status:
    0   every finished brief has its report
    1   at least one does not, and it is named
    2   the check could not run
"""
import os
import re
import sys

BRIEFS = os.path.join("docs", "briefs")
REPORTS = os.path.join("docs", "reports")
NUMBERED = re.compile(r"^(\d{1,6})[-_]")
# No leading whitespace. An indented `status:` inside a nested mapping used to
# win over the real one below it, so a brief could say done, have no report,
# and pass.
STATUS = re.compile(r"^status\s*:\s*[\"']?([a-z]+)", re.M | re.I)

# A report has to say something. The file was never opened, so `touch
# docs/reports/0007-x.md` satisfied the check whose whole purpose is catching
# work that left nothing behind. Making `docs/reports` a symlink to
# `docs/briefs` did the same for every brief at once.
MEANINGFUL = 20

OPEN, DONE, DROPPED = "open", "done", "dropped"

# Paths the runner has told this check to stay out of. The kit's own fixtures
# are wrong on purpose. Resolved against the directory being checked, not
# against wherever the person happened to be standing: a relative exclude used
# to mean two different places depending on the caller.
def excluded_paths(root):
    out = []
    for p in os.environ.get("FORMWORK_EXCLUDE", "").split(os.pathsep):
        if not p:
            continue
        out.append(os.path.realpath(p if os.path.isabs(p)
                                    else os.path.join(root, p)))
    return out


def is_excluded(path, excludes):
    a = os.path.realpath(path)
    return any(a == e or a.startswith(e + os.sep) for e in excludes)

ALLOWED = (OPEN, DONE, DROPPED)


def numbered(root, sub):
    """Every numbered markdown file in a folder, as {number: [filenames]}."""
    d = os.path.join(root, sub)
    out = {}
    if not os.path.isdir(d) or is_excluded(d, excluded_paths(root)):
        return None
    for fn in sorted(os.listdir(d)):
        if not fn.endswith(".md") or fn.upper().startswith("README"):
            continue
        m = NUMBERED.match(fn)
        if not m:
            out.setdefault(None, []).append(fn)
            continue
        out.setdefault(int(m.group(1)), []).append(fn)
    return out


def main(argv):
    want_next = "--next" in argv
    args = [a for a in argv[1:] if not a.startswith("--")]
    if not args:
        print("usage: work-paired [--next] <directory>", file=sys.stderr)
        return 2
    root = args[0]
    if not os.path.isdir(root):
        print("ERROR: not a directory: %s" % root, file=sys.stderr)
        return 2

    briefs = numbered(root, BRIEFS)
    reports = numbered(root, REPORTS)

    if briefs is None:
        if want_next:
            print("0001")
            return 0
        print("no %s directory, nothing claimed" % BRIEFS)
        return 0

    if want_next:
        taken = [n for n in briefs if n is not None]
        nxt = (max(taken) + 1) if taken else 1
        # The filename pattern accepts six digits. Handing out a seventh would
        # give somebody a number this same program then refuses.
        if nxt > 999999:
            print("ERROR: every number up to 999999 is taken", file=sys.stderr)
            return 2
        print("%04d" % nxt)
        return 0

    problems = []
    seen_status = []
    reports = reports or {}

    for fn in briefs.get(None, []):
        problems.append("%s/%s — the filename does not start with a number"
                        % (BRIEFS, fn))
    for fn in reports.get(None, []):
        problems.append("%s/%s — the filename does not start with a number"
                        % (REPORTS, fn))

    for n, names in sorted((k, v) for k, v in briefs.items() if k is not None):
        if len(names) > 1:
            problems.append("number %04d is used by %d briefs: %s"
                            % (n, len(names), ", ".join(names)))
            continue
        fn = names[0]
        try:
            text = open(os.path.join(root, BRIEFS, fn),
                        encoding="utf-8", errors="replace").read()
        except OSError as e:
            print("ERROR: cannot read %s/%s: %s" % (BRIEFS, fn, e),
                  file=sys.stderr)
            return 2
        m = STATUS.search(text)
        if not m:
            problems.append("%s/%s — no 'status:' line. One of: %s"
                            % (BRIEFS, fn, ", ".join(ALLOWED)))
            continue
        status = m.group(1).lower()
        seen_status.append(status)
        if status not in ALLOWED:
            problems.append("%s/%s — status '%s' is not one of: %s"
                            % (BRIEFS, fn, m.group(1), ", ".join(ALLOWED)))
        elif status == DONE and n not in reports:
            problems.append("%s/%s — says done, and there is no report for it "
                            "in %s" % (BRIEFS, fn, REPORTS))
        elif status == DONE:
            rn = reports[n][0]
            rp = os.path.join(root, REPORTS, rn)
            if os.path.realpath(rp) == os.path.realpath(
                    os.path.join(root, BRIEFS, fn)):
                problems.append("%s/%s — its report is the brief itself"
                                % (REPORTS, rn))
                continue
            try:
                body = open(rp, encoding="utf-8", errors="replace").read()
            except OSError as e:
                print("ERROR: cannot read %s/%s: %s" % (REPORTS, rn, e),
                      file=sys.stderr)
                return 2
            if len(body.strip()) < MEANINGFUL:
                problems.append("%s/%s — is empty, or close enough. A report "
                                "that says nothing is the state this check "
                                "exists to catch" % (REPORTS, rn))

    for n, names in sorted((k, v) for k, v in reports.items() if k is not None):
        if len(names) > 1:
            problems.append("number %04d is used by %d reports: %s"
                            % (n, len(names), ", ".join(names)))
        if n not in briefs:
            problems.append("%s/%s — answers brief %04d, which does not exist"
                            % (REPORTS, names[0], n))

    if problems:
        print("%d problem(s) across %d brief(s)"
              % (len(problems), len([k for k in briefs if k is not None])))
        for p in problems:
            print("  %s" % p)
        return 1

    total = len([k for k in briefs if k is not None])
    still_open = seen_status.count(OPEN)
    print("%d brief(s), every finished one has its report%s"
          % (total, (", %d still open" % still_open) if still_open > 0 else ""))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
