#!/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.

         More briefs open than the project allows. One by default, set with
         `open_briefs` under [strength] in .formwork.toml, and 0 means no
         limit. Several open at once is how the human stops holding the
         picture and starts carrying text between windows.

         A brief marked done whose report has no read-back. The report needs
         two lines, each at the start of a line:

             accepted: 2026-10-13
             in my words: <at least five words>

         The first with a real date, the second with at least five words
         after the colon.

CANNOT TELL  Whether the human wrote those words, or whether they are true.
         An agent can type both lines, and a sentence copied from the report
         passes. This stops the cheapest way of skipping the step, the same as
         predictions-first, and nothing more. Reading the sentence for sense
         was not attempted on purpose: a check that judged it would pass for
         the wrong reason and claim more than it can see.

One 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 and its read-back
    1   at least one does not, or too many are open, and it is named
    2   the check could not run
"""
import datetime
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

# The read-back. Anchored at the start of a line for the same reason as STATUS:
# a quoted example of the line inside a sentence is not the line.
ACCEPTED = re.compile(r"^accepted\s*:[ \t]*(\S*)", re.M | re.I)
IN_MY_WORDS = re.compile(r"^in my words\s*:(.*)$", re.M | re.I)
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
READ_BACK_WORDS = 5

DEFAULT_OPEN = 1

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


# The same shape as setting() in formwork/guard/quality-gate, which reads
# gate_budget. A second parser for the same file would drift from the first.
def setting(root, key, allowed, default):
    config = os.path.join(root, ".formwork.toml")
    if not os.path.exists(config):
        return default
    try:
        text = open(config, encoding="utf-8", errors="ignore").read()
    except OSError:
        return default
    m = re.search(r'^\s*%s\s*=\s*["\']?([A-Za-z0-9_]+)["\']?' % key, text, re.M)
    if not m:
        return default
    v = m.group(1).strip().lower()
    return v if (allowed is None or v in allowed) else default


def open_limit(root):
    v = setting(root, "open_briefs", None, str(DEFAULT_OPEN))
    return int(v) if str(v).isdigit() else DEFAULT_OPEN


def read_back_missing(body):
    """What the report lacks of the read-back, as a list of sentences."""
    missing = []
    m = ACCEPTED.search(body)
    if not m:
        missing.append("no 'accepted:' line")
    else:
        when = m.group(1)
        try:
            ok = bool(DATE.match(when)) and bool(
                datetime.date.fromisoformat(when))
        except ValueError:
            ok = False
        if not ok:
            missing.append("'accepted:' is followed by '%s', which is not a "
                           "date written YYYY-MM-DD" % when)
    m = IN_MY_WORDS.search(body)
    if not m:
        missing.append("no 'in my words:' line")
    elif len(m.group(1).split()) < READ_BACK_WORDS:
        missing.append("'in my words:' has %d word(s), and needs at least %d"
                       % (len(m.group(1).split()), READ_BACK_WORDS))
    return missing

# 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 = []
    open_names = []
    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 == OPEN:
            open_names.append("%s/%s" % (BRIEFS, fn))
        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))
                continue
            for gap in read_back_missing(body):
                problems.append("%s/%s: brief %04d says done, and the report "
                                "has %s. The human writes the read-back, see "
                                "formwork/templates/report.md"
                                % (REPORTS, rn, n, gap))

    limit = open_limit(root)
    if limit and len(open_names) > limit:
        problems.append("%d briefs are open and the limit is %d: %s. Finish "
                        "or drop one, or raise open_briefs under [strength] "
                        "in .formwork.toml"
                        % (len(open_names), limit, ", ".join(open_names)))

    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 and its read-back%s"
          % (total, (", %d still open" % still_open) if still_open > 0 else ""))
    return 0


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