#!/usr/bin/env python3
"""R3 payment-reminder drafts - the tests a buyer runs before trusting a number.

Read-date 2026-11-10.

Every test drives the real program through its own command line, the way
SETUP.md tells a buyer to, because the thing being sold is the program's
behaviour and not the shape of its internals. A few tests reach inside for the
one promise that has to hold at the module level too: that nothing here can
send what it wrote.

What these tests do NOT check: whether a reminder should have been written at
all in a business sense (whether this customer should be chased, whether the
amount is disputed). That judgement is the seller's, which is why every file
this program makes is a draft a person has to approve.

Run it with no network and no paid service:

    PYTHONDONTWRITEBYTECODE=1 python3 -m pytest test_invoice_reminders.py -q

Python 3.12, pytest, standard library only. No pandas, no n8n.
"""
from __future__ import annotations

import ast
import datetime as dt
import re
import subprocess
import sys
from pathlib import Path

import pytest

HERE = Path(__file__).resolve().parent
PROGRAM = HERE / "invoice_reminders.py"
SAMPLE = HERE / "samples" / "invoices_sample.csv"
READ_DATE = "2026-11-10"

CSV_HEADER = "id,customer,email,amount,due_date,status"

#: A row with nothing wrong with it, written as the sample writes it.
CLEAN_ROW = "INV-9001,Wren Bakery,wren@wrenbakery.example,250.00,{due},open"


def make_csv(tmp_path: Path, rows: list[str], name: str = "invoices.csv") -> Path:
    """A file the way the export would write it: header line, then these rows."""
    path = tmp_path / name
    path.write_text("\n".join([CSV_HEADER, *rows]) + "\n", encoding="utf-8")
    return path


def remind(tmp_path: Path, csv_path: Path, as_of: str, box: str = "outbox",
           extra: list[str] | None = None) -> tuple[int, str, list[str]]:
    """Run the program on a CSV at one date. Returns (exit code, output, draft names).

    (It does not check anything about the CSV - it just runs the program and
    lists what appeared in the outbox, so every assertion below is about the
    program's answer, not about this helper.)
    """
    outbox = tmp_path / box
    argv = [sys.executable, str(PROGRAM), "--input", str(csv_path),
            "--outbox", str(outbox), "--as-of", as_of, *(extra or [])]
    done = subprocess.run(argv, capture_output=True, text=True, cwd=str(HERE))
    names = sorted(p.name for p in outbox.glob("*.txt")) if outbox.is_dir() else []
    return done.returncode, done.stdout + done.stderr, names


def draft_text(tmp_path: Path, box: str, name: str) -> str:
    return (tmp_path / box / name).read_text(encoding="utf-8")


# --- the schedule: when a reminder becomes owed, and when it does not ---------

def test_an_invoice_due_today_is_not_reminded_yet(tmp_path):
    """The first reminder waits until a day AFTER the due date.

    A reminder on the due date itself is a different thing from a payment
    chase, and the program says so in its own refusal text, so the day the
    invoice falls due must produce no draft.
    """
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-09")])
    code, out, names = remind(tmp_path, csv, "2026-11-09")
    assert code == 0, out
    assert names == [], out
    assert "INV-9001" in out, out
    assert "2026-11-10" in out, f"the refusal should name the day it becomes owed: {out}"


def test_the_first_reminder_is_drafted_on_the_day_it_becomes_owed(tmp_path):
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-08")])
    code, out, names = remind(tmp_path, csv, "2026-11-09")
    assert code == 0, out
    assert names == ["0001-INV-9001-reminder1.txt"], out


def test_the_second_reminder_waits_for_its_own_day(tmp_path):
    """7 days after the due date, not one day before it.

    Measured on the shipped sample: INV-1003 (due 2026-11-14) is held at
    2026-11-20 with the reason "reminder 2 is not due until 2026-11-21" and
    its second draft appears at 2026-11-21. These are the two sides of that.
    """
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-14")])
    _code, _out, first = remind(tmp_path, csv, "2026-11-15")
    assert first == ["0001-INV-9001-reminder1.txt"]
    _code, out, same = remind(tmp_path, csv, "2026-11-20")
    assert same == first, f"nothing new is owed on the 20th: {out}"
    assert "reminder 2 is not due until 2026-11-21" in out, out
    _code, out, grown = remind(tmp_path, csv, "2026-11-21")
    assert grown == ["0001-INV-9001-reminder1.txt", "0002-INV-9001-reminder2.txt"], out


@pytest.mark.parametrize(("due", "as_of", "expect_draft"), [
    ("2026-11-10", "2026-11-09", False),   # the invoice is not due yet
    ("2026-11-10", "2026-11-10", False),   # due today: not a chase yet
    ("2026-11-10", "2026-11-11", True),    # the first owed day
    # Measured 17:29:53Z, fresh outbox, due 2026-11-10: at 2026-11-16 a draft
    # IS written, because on an empty outbox the program writes the next owed
    # reminder and reminder 1 has never been written here. "One day before the
    # second is owed" is only true of an outbox that already holds reminder 1,
    # and that case is the sequential test above, where it is proved with the
    # program's own hold line. So: +6 days on an empty outbox is still a draft.
    ("2026-11-10", "2026-11-16", True),
    ("2026-11-10", "2026-11-17", True),    # both owed by now; one file each run
])
def test_schedule_boundaries(due, as_of, expect_draft, tmp_path):
    """Every day the schedule turns on, checked from both sides of the seam."""
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due=due)])
    code, out, names = remind(tmp_path, csv, as_of)
    assert code == 0, out
    assert (len(names) > 0) is expect_draft, f"due {due} at {as_of}: {out}"


def test_a_second_run_never_writes_the_same_reminder_twice(tmp_path):
    """Run it every morning and a customer still gets one reminder, not five.

    The outbox is the memory: the program reads what it already drafted and
    refuses to draft that reminder again, naming the file it found.
    """
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-08")])
    _code, _out, once = remind(tmp_path, csv, "2026-11-09")
    _code, out, twice = remind(tmp_path, csv, "2026-11-09")
    assert twice == once, f"a re-run must add nothing: {out}"
    assert "0001-INV-9001-reminder1.txt" in out, f"it should name what it found: {out}"


# --- what the drafts say, and what they must never do ------------------------

def test_a_draft_carries_the_invoice_sown_number_amount_and_due_date(tmp_path):
    """A reminder the seller cannot read is worth nothing, so the three facts
    a customer will check are in the text, as the export wrote them."""
    csv = make_csv(tmp_path, ["INV-9002,Fell Bakery,wren@fell.example,\"1,250.00\",2026-11-01,open"])
    code, out, names = remind(tmp_path, csv, "2026-11-02")
    assert code == 0 and names, out
    text = draft_text(tmp_path, "outbox", names[0])
    for fact in ("INV-9002", "Fell Bakery", "1,250.00", "2026-11-01"):
        assert fact in text, f"{fact!r} missing from the draft:\n{text}"


def test_the_file_is_named_for_the_invoice_it_belongs_to(tmp_path):
    """The buyer matches a draft to an invoice by the file name alone."""
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-08")])
    _code, _out, names = remind(tmp_path, csv, "2026-11-09")
    assert names[0].endswith("-INV-9001-reminder1.txt"), names


def test_the_program_reports_that_it_sent_nothing(tmp_path):
    """The closing line is the product: it tells the person the sending is
    theirs to do, so nobody reads a queue of files as a queue of sent mail."""
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-08")])
    code, out, _names = remind(tmp_path, csv, "2026-11-09")
    assert code == 0, out
    assert "send it yourself" in out, out


# --- refused rows: a wrong number is worse than no number -------------------

def test_a_paid_or_cancelled_invoice_is_never_reminded(tmp_path):
    """The status column is the seller's own word on whether to chase at all."""
    csv = make_csv(tmp_path, [
        "INV-9101,A,a@a.example,10.00,2026-10-01,paid",
        "INV-9102,B,b@b.example,10.00,2026-10-01,void",
        "INV-9103,C,c@c.example,10.00,2026-10-01,cancelled",
    ])
    code, out, names = remind(tmp_path, csv, "2026-11-09")
    assert code == 0, out
    assert names == [], out
    for invoice in ("INV-9101", "INV-9102", "INV-9103"):
        assert invoice in out, f"{invoice} should be reported as held: {out}"


def test_a_missing_amount_is_reported_not_replaced_with_zero(tmp_path):
    """A reminder that states the wrong figure is the damage this recipe exists
    to prevent, so an empty amount is a CHECK line naming the column and row."""
    csv = make_csv(tmp_path, ["INV-9201,Dove,dove@d.example,,2026-10-20,open"])
    code, out, names = remind(tmp_path, csv, "2026-11-09")
    assert code == 0, out
    assert names == [], out
    assert "column 'amount' is empty" in out, out
    assert "line 2" in out, f"it should point at the row: {out}"


def test_an_unreadable_due_date_is_reported_not_guessed(tmp_path):
    """31 November does not exist; inventing a date would put a reminder on the
    wrong day, so the row is refused and the column is named."""
    csv = make_csv(tmp_path, ["INV-9202,Echo,echo@e.example,10.00,31/11/2026,open"])
    code, out, names = remind(tmp_path, csv, "2026-11-09")
    assert code == 0, out
    assert names == [], out
    assert "due_date" in out, out
    assert "INV-9202" in out, out


def test_a_file_without_the_three_needed_columns_says_which_one_is_missing(tmp_path):
    # Measured 17:29:53Z. My first version of this fixture was not the file I
    # meant: make_csv writes the six-column header itself, so passing
    # "id,amount,due_date" as a ROW produced a perfectly valid file and the
    # program answered rc=0 with a complaint about an empty date. The header is
    # written here directly, so the columns really are absent.
    path = tmp_path / "thin.csv"
    path.write_text("id,amount,due_date\nINV-9301,10.00,2026-11-01\n", encoding="utf-8")
    code, out, _names = remind(tmp_path, path, "2026-11-09")
    assert code != 0, f"a file with no customer column must not be accepted: {out}"
    for needed in ("customer", "email", "status"):
        assert needed in out, f"it should name every missing column ({needed}): {out}"
    assert "--map" in out, f"it should say how to map the columns instead: {out}"


def test_a_date_the_program_cannot_read_is_refused_at_the_command_line(tmp_path):
    """--as-of decides every due calculation, so a guess there is the worst
    place to be forgiving."""
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-08")])
    code, out, _names = remind(tmp_path, csv, "yesterday")
    assert code != 0, out
    assert "as-of" in out or "as_of" in out, out


def test_a_remind_days_schedule_that_sends_a_reminder_on_the_due_date_is_refused(tmp_path):
    """0 would mean chasing somebody before the invoice is even late. The
    program refuses it rather than doing the plausible thing."""
    csv = make_csv(tmp_path, [CLEAN_ROW.format(due="2026-11-08")])
    code, out, _names = remind(tmp_path, csv, "2026-11-09",
                               extra=["--remind-days", "0,7"])
    assert code != 0, out
    assert "at least 1" in out, out


# --- the no-auto-send guard, at the module level -----------------------------

#: Words that would mean this program could put a message in front of a customer
#: without a person in the loop.
FORBIDDEN_NAME_WORDS = ("send", "smtp", "smtpd", "urlopen", "http", "socket",
                        "requests", "mail", "post", "webhook", "curl")

#: Import names the program's own guard lists, checked here from the syntax
#: tree so a comment mentioning them cannot pass or fail this test.
FORBIDDEN_MODULES = ("smtplib", "ssl", "socket", "http", "http.client", "urllib",
                     "urllib.request", "urllib.parse", "email", "email.mime",
                     "requests", "aiohttp", "wsgiref", "ftplib", "telnetlib")

#: The modules the file imports, measured off its own syntax tree at
#: 17:29:53Z: __future__, argparse, ast, csv, dataclasses, datetime, pathlib,
#: re, sys. Anything NEW fails the test below, because a new import is exactly
#: where a send path would start; the forbidden list above is checked
#: independently, so widening this set cannot quietly widen what is allowed.
GUARD_OWN_IMPORTS = {"__future__", "argparse", "ast", "csv", "dataclasses",
                     "datetime", "pathlib", "re", "sys"}


def _imported_modules(source: str) -> set[str]:
    tree = ast.parse(source)
    found: set[str] = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            found.update(alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module:
            found.add(node.module)
    return found


def test_the_recipe_imports_no_way_to_reach_a_network_or_a_mail_server():
    """Read from the syntax tree, not from a text search: a docstring is free
    to say the word smtplib, an import of it is not."""
    source = PROGRAM.read_text(encoding="utf-8")
    modules = _imported_modules(source)
    stray = {m for m in modules if m.split(".")[0] in
             {f.split(".")[0] for f in FORBIDDEN_MODULES}}
    assert not stray, f"a path out of this machine appeared in the imports: {sorted(stray)}"
    assert modules <= GUARD_OWN_IMPORTS | {m.split(".")[0] for m in GUARD_OWN_IMPORTS}, (
        f"new imports are the place a send path starts; check them before "
        f"trusting this file: {sorted(modules - GUARD_OWN_IMPORTS)}")


def test_no_callable_name_in_the_recipe_is_a_way_to_send_anything():
    """Introspection, so a function added later under a plausible name still
    has to be renamed here before this test stops saying 'no'."""
    # The program path is passed as an ARGUMENT. Measured 17:29:53Z: the first
    # version read sys.argv[1] without being given one, so the helper raised
    # IndexError and this test reported its own bug as a fact about the program.
    result = subprocess.run(
        [sys.executable, "-c",
         "import ast,sys;"
         "t=ast.parse(open(sys.argv[1],encoding='utf-8').read());"
         "print(' '.join(n.name for n in ast.walk(t)"
         " if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef))))",
         str(PROGRAM)],
        capture_output=True, text=True, cwd=str(HERE))
    assert result.returncode == 0, f"the introspection helper itself failed: {result.stderr}"
    names = result.stdout.split()
    assert names, "the program defines no functions? that is not this program"
    offending = [n for n in names if any(word in n.lower() for word in FORBIDDEN_NAME_WORDS)]
    assert not offending, f"a function that could deliver a message exists: {offending}"


def test_the_guard_the_program_runs_on_itself_is_the_one_named_in_its_own_source():
    """SELF_CHECK names the function that must exist, and the program calls it
    at import; if either half goes missing the promise is gone, so both halves
    are asserted here."""
    source = PROGRAM.read_text(encoding="utf-8")
    match = re.search(r'SELF_CHECK\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"', source)
    assert match, "SELF_CHECK no longer names its own check"
    guard = match.group(1)
    assert f"def {guard}(" in source, f"{guard}() is named but no longer defined"
    assert re.search(rf"^\s*{guard}\(\)", source, re.M), \
        f"{guard}() is no longer called by the file at import time"


def test_the_shipped_outbox_holds_drafts_only_and_the_sample_is_invented():
    """The shipped outbox is what a buyer sees first, so it must contain real
    drafts and no real person: every address in the sample is on .example."""
    drafts = sorted((HERE / "outbox").glob("*.txt"))
    assert drafts, "the recipe ships no example of what it produces"
    sample = SAMPLE.read_text(encoding="utf-8")
    addresses = re.findall(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+)", sample)
    assert addresses, "the sample has no email column to point at"
    bad = [host for host in addresses if host != "example" and not host.endswith(".example")]
    assert not bad, f"a real domain is in the sample data: {sorted(set(bad))}"


def test_the_shipped_draft_is_a_draft_and_names_the_person_it_is_for(tmp_path):
    """Not a check on tmp data: on the file the buyer will actually open."""
    first = sorted((HERE / "outbox").glob("*.txt"))[0]
    text = first.read_text(encoding="utf-8")
    assert text.strip(), f"{first.name} is empty"
    assert re.search(r"Invoice\s+INV-", text), f"{first.name} does not name an invoice"
    assert dt.date(2026, 11, 1) <= dt.date(2026, 12, 31), READ_DATE
