#!/usr/bin/env python3
"""Tests for recipe R2 (weekly report). Offline: no network, no install, no browser.

Run from the recipes folder, through the box (bare pytest is refused on this
machine because the session carries the owner's DISPLAY):

    cd /home/stephan/jarvis/workspace/money/build/product_a/recipes
    PYTHONDONTWRITEBYTECODE=1 /home/stephan/jarvis/tools/boxed \\
        /usr/bin/python3 -m pytest r2/test_weekly_report.py -q -p no:cacheprovider

What is being proved here, in plain words. The week arithmetic is right, and
right across a year end and across a gap in the data. Every number in a report
equals an independent count of the same file. Bad input is named, counted and
left out - never guessed at, never silently zeroed. The two cases that break a
naive report writer (one week only, a previous total of zero) come out as an
honest "n/a" rather than as a crash or an "inf". The HTML it writes cannot
fetch anything. And nothing in the recipe has a way to put a byte on a wire.

Read-date: 2026-11-10.
"""

from __future__ import annotations

import ast
import csv
import re
import sys
from datetime import date
from pathlib import Path

import pytest

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

import weekly_report as wr  # noqa: E402

SELF = Path(__file__).resolve()

#: The words that would mean this recipe shipped unfinished. Named once, at
#: module level, so the scan can exempt its own definition line and nothing else.
WORK_MARKERS = ("TODO", "FIXME", "XXX", "PLACEHOLDER", "CHANGEME", "coming soon")

SAMPLE = HERE / "samples" / "weekly_sales.csv"
OUT_DIR = HERE / "out"

#: The stamp the shipped out/report.* files were generated with. Passing the
#: same --now again must reproduce them byte for byte - that is what proves
#: they are this program's output and not something typed by hand.
SHIP_STAMP = "2026-11-10T14:00:00Z"

#: Names that would mean this recipe could put a byte on a wire. Checked
#: against the module's own code, not against its prose.
FORBIDDEN_CAPABILITY = re.compile(
    r"send|smtp|urlopen|urllib|requests|httpx|http|socket|ssl|ftplib|smtplib|"
    r"subprocess|popen|upload|webbrowser|xmlrpc|urlretrieve|curl|wget|mail",
    re.IGNORECASE,
)

#: Only these standard-library roots may be imported anywhere in the module.
ALLOWED_IMPORTS = {
    "__future__", "argparse", "csv", "html", "math", "re", "sys",
    "collections", "dataclasses", "datetime", "pathlib",
}


# ------------------------------------------------------------------- helpers


def write_csv(tmp_path: Path, text: str, name: str = "data.csv") -> Path:
    """One CSV of my own making, for a test that needs to control every cell."""
    path = tmp_path / name
    path.write_text(text, encoding="utf-8")
    return path


def report_of(path: Path, **kwargs) -> wr.Report:
    """The findings, without touching the filesystem anywhere else."""
    return wr.analyse(path, **kwargs)


def run_main(tmp_path: Path, *args: str) -> tuple[int, str, str]:
    """One command-line run from a throwaway folder; returns (rc, stdout, stderr).
    """
    import contextlib
    import io

    buffer_out, buffer_err = io.StringIO(), io.StringIO()
    argv = [str(arg) for arg in args]
    with contextlib.redirect_stdout(buffer_out), contextlib.redirect_stderr(buffer_err):
        code = wr.main(argv)
    return code, buffer_out.getvalue(), buffer_err.getvalue()


def table_of(text: str) -> list[list[str]]:
    """The rendered table, read back out of report.txt as cells.

    Measured 2026-11-10: the header used to be found with
    ``line.startswith("metric")``, which also matches the summary line
    ``metrics totalled   : units, revenue, refunds`` that sits ABOVE the table.
    ``next()`` then returned that line, ``lines[start + 2:]`` began on a blank
    line, the loop broke at once, and every one of these seven assertions
    failed against an empty list (``assert [<row>] in []``) while the report on
    disk held the right numbers. The header is matched as ``"metric "``, with
    its space, which the summary line ("metrics totalled") does not have.
    """
    lines = text.splitlines()
    start = next(i for i, line in enumerate(lines) if line.startswith("metric "))
    rows = []
    for line in lines[start + 2:]:
        if not line.strip():
            break
        # Second defect in this same accessor, measured on the same run: the
        # table is right-aligned and columns are separated by TWO or more
        # spaces, so ``line.split()`` cut the two-word cell "no data" into two
        # cells and no expectation naming a missing week could ever match
        # (``['units', '15', 'no', 'data', ...] in [...]``). Splitting on a run
        # of 2+ spaces is what the format means by a column boundary.
        rows.append([cell for cell in re.split(r" {2,}", line.strip()) if cell])
    return rows


# ---------------------------------------------- the sample, and what it must say


def test_the_shipped_sample_makes_two_reports_and_a_terminal_summary(tmp_path, monkeypatch):
    monkeypatch.chdir(HERE)
    code, out, err = run_main(tmp_path, "--input", "samples/weekly_sales.csv",
                              "--outdir", tmp_path / "out")
    assert code == 0, err
    assert (tmp_path / "out" / "report.txt").stat().st_size > 500
    assert (tmp_path / "out" / "report.html").stat().st_size > 500
    assert "2026-45" in out and "2026-44" in out
    assert "wrote " in out


def test_the_sample_totals_equal_an_independent_count_of_the_file(tmp_path, monkeypatch):
    """48/39 and friends were worked out by hand from samples/weekly_sales.csv.

    The numbers below are the oracle, written down before looking at the
    program's answer: week 2026-45 units 8+6+10+7+5+3+9 = 48 over 7 readable
    cells (the eighth row's units cell is blank); week 2026-44 units
    7+5+9+4+6+8 = 39. Revenue 552+690+483+1200+621+0 = 3,546 over 6 cells, two
    values in that column are not numbers; 483+345+621+276+414+552 = 2,691.
    Refunds 0+0+1+0+0+0+1+0 = 2; 0+1+0+0+2+0 = 3.
    """
    monkeypatch.chdir(HERE)
    code, _out, err = run_main(tmp_path, "--input", "samples/weekly_sales.csv",
                              "--outdir", tmp_path / "out", "--now", SHIP_STAMP)
    assert code == 0, err
    text = (tmp_path / "out" / "report.txt").read_text(encoding="utf-8")
    assert table_of(text) == [
        ["units", "48", "39", "+9", "+23.1%", "7/6", "0"],
        ["revenue", "3,546", "2,691", "+855", "+31.8%", "6/6", "2"],
        ["refunds", "2", "3", "-1", "-33.3%", "8/6", "0"],
    ]


def test_the_reports_on_disk_are_this_programs_own_output(tmp_path, monkeypatch):
    """Same input, same --now, same bytes: nothing in out/ was hand-written."""
    monkeypatch.chdir(HERE)
    code, _out, err = run_main(tmp_path, "--input", "samples/weekly_sales.csv",
                              "--outdir", tmp_path / "out", "--now", SHIP_STAMP, "--quiet")
    assert code == 0, err
    for name in ("report.txt", "report.html"):
        shipped = (OUT_DIR / name).read_bytes()
        fresh = (tmp_path / "out" / name).read_bytes()
        assert fresh == shipped, f"out/{name} has drifted from what the program writes"
        assert SHIP_STAMP.encode() in shipped


def test_the_sample_names_every_value_it_left_out(tmp_path, monkeypatch):
    """The honest-reporting paths are exercised by the file a buyer is handed."""
    monkeypatch.chdir(HERE)
    run_main(tmp_path, "--input", "samples/weekly_sales.csv", "--outdir", tmp_path / "out")
    text = (tmp_path / "out" / "report.txt").read_text(encoding="utf-8")
    assert "revenue: 2 value(s) in this file could not be read as a number" in text
    assert "row 10 held 'n/a'" in text
    assert "row 14 held '49,95'" in text
    assert "units: 1 cell(s) in this file were blank" in text
    assert "1 row(s) had a date this recipe could not read" in text
    assert "row 17 held '31/11/2026'" in text
    assert "1 dated row(s) fall in neither reported week" in text


def test_a_decrease_is_shown_as_a_decrease(tmp_path):
    path = write_csv(tmp_path, "date,units\n"
                               "2026-11-02,100\n2026-11-03,20\n"
                               "2026-10-26,60\n2026-10-27,40\n")
    report = report_of(path)
    assert report.current_label == "2026-45" and report.previous_label == "2026-44"
    change, reason = report.metrics[0].change_pct(report.previous_present)
    # Arithmetic, by hand on the four cells above, which is why this
    # expectation was rewritten and the module left alone. This week (2026-45)
    # totals 100 + 20 = 120; last week (2026-44) totals 60 + 40 = 100. The
    # week-over-week change is (120 - 100) / 100 = +20 %, which is what the
    # module returns. The old expectation, -40.0, is the difference of the two
    # FIRST cells only (100 - 60), made negative: it is not a change of totals,
    # and no reading of this fixture makes these totals fall.
    assert change == 20.0 and reason == ""
    assert wr.fmt_change(change) == "+20.0%"

    # The decrease this test's name promises, added 2026-11-10 so the name is
    # not decoration: last week 100, this week 60, so (60 - 100) / 100 = -40 %.
    down = report_of(write_csv(tmp_path, "date,units\n"
                                         "2026-11-02,60\n2026-11-03,\n"
                                         "2026-10-26,100\n2026-10-27,\n",
                               name="down.csv"))
    down_change, down_reason = down.metrics[0].change_pct(down.previous_present)
    assert down_change == -40.0 and down_reason == ""
    assert wr.fmt_change(down_change) == "-40.0%"


# ----------------------------------------------- the five cases that must be handled


def test_case_a_an_empty_file_is_named_and_nothing_is_written(tmp_path):
    empty = tmp_path / "empty.csv"
    empty.write_text("", encoding="utf-8")
    code, _out, err = run_main(tmp_path, "--input", empty, "--outdir", tmp_path / "out")
    assert code == 2
    assert "empty.csv" in err
    assert "no header row" in err
    assert not (tmp_path / "out").exists()


def test_case_a_a_header_with_no_data_rows_is_named_too(tmp_path):
    path = write_csv(tmp_path, "date,units,revenue\n", name="header_only.csv")
    code, _out, err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert code == 2
    assert "no data rows" in err
    assert "units" in err, "it lists the columns it did find"


def test_case_b_one_week_only_writes_a_report_and_says_it_has_nothing_to_compare(tmp_path):
    path = write_csv(tmp_path, "date,units\n2026-11-02,10\n2026-11-04,5\n")
    code, out, err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert code == 0, err
    text = (tmp_path / "out" / "report.txt").read_text(encoding="utf-8")
    web = (tmp_path / "out" / "report.html").read_text(encoding="utf-8")
    for place in (out, text, web):
        assert wr.NO_PRIOR in place
        assert "n/a" in place
    assert ["units", "15", "no data", "-", "n/a", "2/-", "0"] in table_of(text)
    for place in (text, web):
        assert not re.search(r"\b(inf|infinity|nan)\b", place, re.IGNORECASE), \
            "a percent must never come out as inf or nan"


def test_case_c_a_missing_date_column_is_named_and_the_real_ones_are_listed(tmp_path):
    path = write_csv(tmp_path, "day,units\n2026-11-02,3\n2026-10-26,4\n")
    code, _out, err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert code == 2
    assert "date: no date column of that name" in err
    assert "day" in err and "units" in err
    assert "--date-column" in err, "it says which flag to fix"


def test_case_c_a_missing_metric_column_is_named_and_the_real_ones_are_listed(tmp_path):
    path = write_csv(tmp_path, "date,units,revenue\n2026-11-02,3,9\n2026-10-26,4,8\n")
    code, _out, err = run_main(tmp_path, "--input", path, "--metrics", "units,nope",
                              "--outdir", tmp_path / "out")
    assert code == 2
    assert "nope: no metric column of that name" in err
    assert "units" in err and "revenue" in err
    assert "--metrics" in err


def test_case_d_junk_in_a_number_column_is_counted_and_the_run_carries_on(tmp_path):
    path = write_csv(tmp_path, "date,units\n"
                               "2026-11-02,10\n2026-11-03,pending\n2026-11-04,4\n"
                               "2026-10-26,3\n2026-10-27,7\n")
    code, out, err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert code == 0, err
    text = (tmp_path / "out" / "report.txt").read_text(encoding="utf-8")
    assert ["units", "14", "10", "+4", "+40.0%", "2/2", "1"] in table_of(text)
    assert "units: 1 value(s) in this file could not be read as a number" in text
    assert "row 3 held 'pending'" in text
    assert "pending" not in out.split("WHAT WAS LEFT OUT")[0].split("metric")[1].split("\n\n")[0]


def test_case_e_a_previous_total_of_zero_does_not_divide_by_anything(tmp_path):
    path = write_csv(tmp_path, "date,units\n"
                               "2026-11-02,9\n2026-11-03,3\n"
                               "2026-10-26,0\n2026-10-27,0\n")
    report = report_of(path)
    metric = report.metrics[0]
    assert report.previous_present is True
    assert metric.previous_total == 0 and metric.current_total == 12
    change, reason = metric.change_pct(report.previous_present)
    assert change is None
    assert "previous week's total is 0" in reason
    assert metric.delta(True) == 12.0, "the delta is still a true figure"

    code, out, err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert code == 0, err
    assert ["units", "12", "0", "+12", "n/a", "2/2", "0"] in table_of(out)
    assert "n/a: the previous week's total is 0" in out


# ------------------------------------------------------- the extras it must also do


def test_unparsable_dates_are_counted_named_and_kept_out_of_every_total(tmp_path):
    path = write_csv(tmp_path, "date,units\n"
                               "2026-11-02,10\nsometime,999\n2026-11-04,5\n"
                               "2026-10-26,4\nnot a date,999\n")
    code, out, err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert code == 0, err
    assert ["units", "15", "4", "+11", "+275.0%", "2/1", "0"] in table_of(out)
    assert "2 row(s) had a date this recipe could not read" in out
    assert "row 3 held 'sometime'" in out and "row 6 held 'not a date'" in out
    assert "Nothing was dated by guess." in out
    assert "999" not in out.split("WHAT WAS LEFT OUT")[0]


def test_a_date_column_named_differently_works_exactly_and_by_case(tmp_path):
    path = write_csv(tmp_path, "Order Date,units\n2026-11-02,10\n2026-10-26,4\n")
    for wanted in ("Order Date", "order date", "  ORDER DATE  "):
        report = report_of(path, date_column=wanted)
        assert report.date_column == "Order Date"
        assert report.current_label == "2026-45"


def test_a_gap_in_the_data_is_honoured_and_not_filled_from_an_older_week(tmp_path):
    """Weeks 44 and 46 are present; the week before 46 is 45, and 45 is empty."""
    path = write_csv(tmp_path, "date,units\n2026-10-26,100\n2026-11-09,7\n")
    report = report_of(path)
    assert report.current_label == "2026-46"
    assert report.previous_label == "2026-45"
    assert report.previous_present is False

    code, out, _err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert code == 0
    assert ["units", "7", "no data", "-", "n/a", "1/-", "0"] in table_of(out)
    assert "2026-45" in out and wr.NO_PRIOR in out
    assert "Nothing was carried over from an older week" in out
    assert "100" not in out.split("metric")[1].split("\n\n")[0], \
        "week 44 must not be used as if it were last week"


def test_the_year_boundary_is_crossed_by_calendar_not_by_string_tricks(tmp_path):
    """2025-12-23 is ISO 2025-52; 2025-12-30 is already ISO 2026-01."""
    path = write_csv(tmp_path, "date,units\n2025-12-23,10\n2025-12-30,15\n")
    report = report_of(path)
    assert report.current_label == "2026-01"
    assert report.previous_label == "2025-52"
    assert report.previous_present is True
    change, _reason = report.metrics[0].change_pct(True)
    assert change == 50.0


def test_previous_week_of_a_first_week_lands_on_the_last_week_of_last_year():
    assert wr.previous_week_of((2026, 1)) == (2025, 52)
    assert wr.previous_week_of((2026, 45)) == (2026, 44)
    assert wr.week_span((2026, 1)) == (date(2025, 12, 29), date(2026, 1, 4))


def test_auto_detect_totales_the_number_columns_and_says_why_it_skipped_the_rest(tmp_path):
    path = write_csv(tmp_path,
                     "date,day_name,units,mixed\n"
                     "2026-11-02,Monday,4,one\n"
                     "2026-11-03,Tuesday,5,two\n"
                     "2026-10-26,Monday,3,7\n"
                     "2026-10-27,Tuesday,2,four\n")
    report = report_of(path)
    assert [metric.name for metric in report.metrics] == ["units"]
    assert any(note.startswith("mixed (1 of 4") for note in report.unused_columns)
    assert any(note.startswith("day_name (0 of 4") for note in report.unused_columns)


def test_named_metrics_take_exactly_what_the_buyer_asks_for(tmp_path):
    path = write_csv(tmp_path, "date,units,revenue\n2026-11-02,4,9\n2026-10-26,3,8\n")
    report = report_of(path, metrics=["revenue"])
    assert [metric.name for metric in report.metrics] == ["revenue"]
    assert report.unused_columns == []


def test_the_date_column_cannot_also_be_billed_as_a_metric(tmp_path):
    path = write_csv(tmp_path, "date,units\n2026-11-02,4\n")
    with pytest.raises(wr.MissingColumnError) as caught:
        report_of(path, metrics=["date"])
    assert "that is the date column of this file" in str(caught.value)


def test_a_file_with_no_number_column_at_all_says_so(tmp_path):
    path = write_csv(tmp_path, "date,region\n2026-11-02,north\n2026-10-26,south\n")
    with pytest.raises(wr.InputFileError) as caught:
        report_of(path)
    assert "no numeric column to total" in str(caught.value)
    assert "units,revenue" in str(caught.value), "and it says what to do next"


def test_a_file_whose_dates_are_all_unreadable_says_so_with_the_values(tmp_path):
    path = write_csv(tmp_path, "date,units\nyesterday,4\nlast week,9\n")
    with pytest.raises(wr.InputFileError) as caught:
        report_of(path)
    message = str(caught.value)
    assert "none of its 2 data row(s) has a date" in message
    assert "row 2 held 'yesterday'" in message


def test_an_input_that_is_not_there_is_named(tmp_path):
    with pytest.raises(wr.InputFileError) as caught:
        report_of(tmp_path / "nowhere.csv")
    assert "nowhere.csv: no such file" in str(caught.value)


def test_a_blank_cell_is_no_figure_and_never_a_quiet_zero(tmp_path):
    path = write_csv(tmp_path, "date,units\n2026-11-02,5\n2026-11-03,\n"
                               "2026-10-26,2\n2026-10-27,0\n")
    report = report_of(path)
    metric = report.metrics[0]
    assert metric.current_total == 5 and metric.current_values == 1
    assert metric.previous_total == 2 and metric.previous_values == 2
    assert metric.blank == 1


def test_a_column_with_nothing_readable_in_the_current_week_is_not_called_zero(tmp_path):
    path = write_csv(tmp_path, "date,units\n2026-11-02,pending\n2026-10-26,7\n")
    report = report_of(path)
    metric = report.metrics[0]
    assert metric.has_current is False and metric.current_values == 0
    change, reason = metric.change_pct(True)
    assert change is None and "no readable value in 2026-45" in reason

    _code, out, _err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    assert ["units", "-", "7", "-", "n/a", "0/1", "1"] in table_of(out)


def test_the_title_names_the_file_by_default_and_the_buyer_wins_when_they_give_one(tmp_path):
    path = write_csv(tmp_path, "date,units\n2026-11-02,5\n2026-10-26,2\n")
    assert report_of(path).title == f"Weekly report from {path.name}"
    assert report_of(path, title="Monday numbers").title == "Monday numbers"
    _code, out, _err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out",
                                "--title", "Monday numbers")
    assert "Monday numbers" in (tmp_path / "out" / "report.txt").read_text(encoding="utf-8")
    # Measured 2026-11-10: the terminal summary is a column of labelled lines
    # and its first line is "report             : Monday numbers", so
    # out.startswith("Monday numbers") is false of every output this program
    # writes. What this test means - the summary leads with the buyer's title,
    # not the file's - is asserted on the first line's value.
    assert out.splitlines()[0].split(":", 1)[1].strip() == "Monday numbers"


def test_quiet_writes_both_reports_and_prints_nothing(tmp_path):
    path = write_csv(tmp_path, "date,units\n2026-11-02,5\n2026-10-26,2\n")
    code, out, err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out",
                              "--quiet")
    assert code == 0 and out == "" and err == ""
    assert (tmp_path / "out" / "report.txt").exists()
    assert (tmp_path / "out" / "report.html").exists()


def test_the_defaults_are_the_ones_the_setup_page_tells_a_buyer_to_expect():
    args = wr.build_parser().parse_args([])
    assert args.outdir == "out"
    assert args.date_column == "date"
    assert args.input == "samples/weekly_sales.csv"
    assert args.metrics == "" and args.title == "" and args.now == ""


# -------------------------------------------------- the cells and dates it reads


@pytest.mark.parametrize(("cell_value", "expected"), [
    ("1200", 1200.0), ("1200.5", 1200.5), ("0", 0.0), ("-12.5", -12.5),
    ("+7", 7.0), ("  42  ", 42.0), ("$1,200.50", 1200.50), ("1,234,567", 1234567.0),
    ("1 200", 1200.0), ("1 200,50", 1200.50), ("42 EUR", 42.0), ("\u20ac7.50", 7.50),
    ("0.00", 0.0), ("007", 7.0),
])
def test_figures_it_reads(cell_value, expected):
    assert wr.parse_number(cell_value) == pytest.approx(expected)


@pytest.mark.parametrize("cell_value", [
    "", "   ", None, "n/a", "-", "49,95", "1,20", "12%", "(500)", "nan", "inf",
    "-inf", "twelve", "1.2.3", "EUR", "1,2000",
])
def test_figures_it_refuses_rather_than_guesses(cell_value):
    """'49,95' is the one that matters: read as 4995 it is a wrong total, not a gap."""
    assert wr.parse_number(cell_value) is None


def test_a_comma_decimal_is_counted_and_named_instead_of_becoming_a_big_number(tmp_path):
    path = write_csv(tmp_path, 'date,revenue\n2026-11-02,"49,95"\n2026-10-26,20\n')
    _code, out, _err = run_main(tmp_path, "--input", path, "--outdir", tmp_path / "out")
    # Measured 2026-11-10: the blanket `assert "4995" not in out` was beaten by
    # the program's own honest explanation, which quotes the wrong reading on
    # purpose: "read as 4995 it would be a wrong number rather than a missing
    # one". Deleting that sentence to satisfy the test would hide from the
    # buyer exactly why their figure is missing. The figure must not reach a
    # TOTAL, so the table is searched - which is what this test's name is about.
    table = out.split("metric ", 1)[1].split("\n\n", 1)[0]
    assert "4,995" not in table and "4995" not in table
    assert "4995" not in out.split("WHAT WAS LEFT OUT", 1)[0].split("metric ", 1)[0]
    assert "row 2 held '49,95'" in out
    assert "deliberately not read" in out


@pytest.mark.parametrize(("cell_value", "expected", "shape"), [
    ("2026-11-02", date(2026, 11, 2), wr.SHAPE_ISO),
    ("2026-11-02T14:30:00", date(2026, 11, 2), wr.SHAPE_ISO),
    ("2026-11-02 09:15", date(2026, 11, 2), wr.SHAPE_ISO),
    ("2026/11/02", date(2026, 11, 2), wr.SHAPE_YMD),
    ("11/02/2026", date(2026, 11, 2), wr.SHAPE_MDY),
    ("25/11/2026", date(2026, 11, 25), wr.SHAPE_DMY),
    ("Nov 2, 2026", date(2026, 11, 2), wr.SHAPE_NAMED),
    ("2 November 2026", date(2026, 11, 2), wr.SHAPE_NAMED),
    ("September 9, 2026", date(2026, 9, 9), wr.SHAPE_NAMED),
])
def test_dates_it_reads(cell_value, expected, shape):
    assert wr.parse_date(cell_value) == (expected, shape)


@pytest.mark.parametrize(("cell_value", "expected_shape"), [
    ("", "blank"), ("  ", "blank"), (None, "blank"),
    ("31/11/2026", "unreadable"), ("2026-02-30", "unreadable"),
    ("yesterday", "unreadable"), ("Q4", "unreadable"), ("13/13/2026", "unreadable"),
    ("Novz 2, 2026", "unreadable"), ("week 45", "unreadable"),
])
def test_dates_it_refuses(cell_value, expected_shape):
    """None of these ten becomes a date; the two reasons stay told apart.

    The three empty cells failed the old single-shape expectation, and the
    module is the side that is right: parse_date has its own branch for them
    (``if not text: return None, "blank"``) because a cell holding nothing and
    a cell holding "Q4" need different sentences in the report - "cells were
    blank" against "could not be read as a number". Collapsing the two shapes
    would tell a buyer a date is MISSING where the file has one that is wrong.
    Refusal is still asserted for all ten: ``moment is None``.
    """
    moment, shape = wr.parse_date(cell_value)
    assert moment is None and shape == expected_shape


def test_the_same_week_is_found_whatever_shape_the_dates_came_in(tmp_path):
    path = write_csv(tmp_path,
                     "date,units\n"
                     "2026-11-02,1\n11/03/2026,1\n2026/11/04,1\n"
                     '"Nov 5, 2026",1\n25/11/2025,1\n')  # quoted: see below
    # Measured 2026-11-10: "Nov 5, 2026,1" is not one cell. An unquoted comma
    # makes csv read three fields, so the date the program actually saw was
    # "Nov 5", and rows_dated was 4, not 5. The fixture was the wrong side, not
    # the reader: this recipe's own sample writes the same shape quoted
    # ("Nov 7, 2026" in samples/weekly_sales.csv) and quoted is what parses.
    report = report_of(path)
    assert report.rows_dated == 5
    assert report.weeks["2026-45"] == 4, "four of them are the same ISO week"
    assert report.weeks["2025-48"] == 1
    shapes = ", ".join(f"{count} {shape}" for shape, count in report.date_shapes.items())
    assert "slash month first" in shapes and "named month" in shapes and "ISO" in shapes


def test_a_day_first_date_over_12_is_read_by_the_calendar_not_by_assumption():
    """25/11/2026 cannot be month 25, so it has to be the 25th of November."""
    assert wr.parse_date("25/11/2026") == (date(2026, 11, 25), wr.SHAPE_DMY)
    assert wr.parse_date("13/01/2026") == (date(2026, 1, 13), wr.SHAPE_DMY)


# ------------------------------------------------- the report as an artifact


def test_the_html_carries_nothing_it_could_fetch(tmp_path, monkeypatch):
    """Inline styles only: no script, no link, no font, no image, no remote call."""
    monkeypatch.chdir(HERE)
    run_main(tmp_path, "--input", "samples/weekly_sales.csv", "--outdir", tmp_path / "out")
    web = (tmp_path / "out" / "report.html").read_text(encoding="utf-8").lower()
    for absent in ("<script", "<link", "<img", "<iframe", "<object", "<embed", "<form",
                   "<style", "http:", "https:", "//", "url(", "@import", "fetch(",
                   "<base", "<meta http-equiv"):
        assert absent not in web, absent
    assert web.startswith("<!doctype html>")
    assert "read-date 2026-11-10" in web
    assert "what was left out" in web


def test_text_that_came_from_the_file_is_escaped_not_loosed(tmp_path):
    nasty = write_csv(tmp_path, 'date,units,notes\n2026-11-02,4,"<script>alert(1)</script>"\n'
                                '2026-10-26,3,plain\n', name="nasty.csv")
    run_main(tmp_path, "--input", nasty, "--outdir", tmp_path / "out")
    web = (tmp_path / "out" / "report.html").read_text(encoding="utf-8")
    assert "<script>alert(1)</script>" not in web

    # Measured 2026-11-10, two facts. (a) The escaped form the test demanded
    # cannot appear from this fixture at all: `notes` is not a metric column,
    # and a text column's values never reach a report of numbers - neither the
    # raw string nor its escaped twin is in the output, so that assertion asked
    # for the escaped form of text no code path emits. (b) The path that DOES
    # carry file-sourced text into the HTML is the file's own name, which
    # becomes the report title, and the module escapes it through
    # html.escape(..., quote=True): measured with --title, 2 escaped
    # occurrences and 0 raw. So the property is proved on the path that has it.
    run_main(tmp_path, "--input", write_csv(tmp_path, "date,units\n2026-11-02,4\n"
                                            "2026-10-26,3\n", name="titled.csv"),
             "--outdir", tmp_path / "out_t", "--title", "<script>alert(1)</script>")
    titled = (tmp_path / "out_t" / "report.html").read_text(encoding="utf-8")
    assert "&lt;script&gt;alert(1)&lt;/script&gt;" in titled
    assert "<script>alert(1)</script>" not in titled

    # And the column that is not a metric really is absent, not smuggled in:
    raw_notes = write_csv(tmp_path, 'date,units,notes\n2026-11-02,4,"<b>bold</b>"\n'
                                    '2026-10-26,3,plain\n', name="kept_out.csv")
    run_main(tmp_path, "--input", raw_notes, "--outdir", tmp_path / "out_k")
    web2 = (tmp_path / "out_k" / "report.html").read_text(encoding="utf-8")
    assert "<b>bold</b>" not in web2 and "&lt;b&gt;bold&lt;/b&gt;" not in web2


def test_both_reports_hold_the_same_numbers(tmp_path, monkeypatch):
    monkeypatch.chdir(HERE)
    run_main(tmp_path, "--input", "samples/weekly_sales.csv", "--outdir", tmp_path / "out")
    web = (tmp_path / "out" / "report.html").read_text(encoding="utf-8")
    for figure in ("3,546", "2,691", "+855", "+31.8%", "48", "39", "-33.3%", "2026-45"):
        assert figure in web, figure


# --------------------------------------------- nothing in it can reach a network


def module_tree() -> ast.Module:
    return ast.parse((HERE / "weekly_report.py").read_text(encoding="utf-8"))


def test_the_module_imports_only_the_standard_library_it_claims_to():
    roots = set()
    for node in ast.walk(module_tree()):
        if isinstance(node, ast.Import):
            roots.update(alias.name.split(".")[0] for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module:
            roots.add(node.module.split(".")[0])
    assert roots <= ALLOWED_IMPORTS, roots - ALLOWED_IMPORTS


def test_no_name_or_call_anywhere_in_the_module_can_reach_a_network():
    """Every attribute touched and every thing called, matched against the list."""
    seen = set()
    for node in ast.walk(module_tree()):
        if isinstance(node, ast.Attribute):
            seen.add(node.attr)
        elif isinstance(node, ast.Call):
            if isinstance(node.func, ast.Name):
                seen.add(node.func.id)
            elif isinstance(node.func, ast.Attribute):
                seen.add(node.func.attr)
    offenders = sorted(name for name in seen if FORBIDDEN_CAPABILITY.search(name))
    assert offenders == []


def test_the_recipe_holds_no_marker_of_work_left_short_and_carries_its_read_date():
    for path in sorted(HERE.rglob("*")):
        if not path.is_file() or path.suffix not in {".py", ".md", ".csv", ".html", ".txt"}:
            continue
        lines = path.read_text(encoding="utf-8").splitlines()
        for number, line in enumerate(lines, start=1):
            # Measured 2026-11-10: this scan walks every .py in the folder
            # including this file, and the words it hunts are the words of its
            # own WORK_MARKERS tuple, so it reported this file as carrying an
            # unfinished-work marker it does not carry. That one definition line
            # is exempt, and
            # ONLY it: every other line of this file, and every line of every
            # other file, is still searched for all six markers.
            if path.resolve() == SELF and line.startswith("WORK_MARKERS = ("):
                continue
            for marker in WORK_MARKERS:
                assert marker not in line, (path.name, marker, number)
        text = "\n".join(lines)
        if path.suffix in {".py", ".md"} or path.parent.name == "out":
            assert wr.READ_DATE in text, path.name


def test_the_folder_holds_the_recipe_and_nothing_else(tmp_path, monkeypatch):
    """No __pycache__, no stray run output beyond out/, no half-written file."""
    monkeypatch.chdir(HERE)
    # smoke.sh joins the set on 2026-11-10: the pack's own selfcheck.sh refuses
    # any recipe folder that ships no smoke.sh ("the recipe shipped no self-test
    # entry point"), so a folder WITHOUT one is the failure, not the contract.
    expected = {
        "weekly_report.py", "test_weekly_report.py", "SETUP.md", "RECIPE.md",
        "smoke.sh", "samples/weekly_sales.csv", "out/report.html", "out/report.txt",
    }
    present = {p.relative_to(HERE).as_posix() for p in HERE.rglob("*") if p.is_file()}
    assert present == expected
    assert list(HERE.rglob("__pycache__")) == []


def test_no_row_of_the_sample_is_a_real_person_or_a_real_shop():
    """The pack is handed to strangers: its only data is invented, and says so."""
    with SAMPLE.open(encoding="utf-8", newline="") as handle:
        rows = list(csv.DictReader(handle))
    assert len(rows) == 16
    # Measured 2026-11-10: this failed on the sample's own DATE column. The
    # strip set removed commas, dots, dollars, quotes and spaces but not "-" or
    # "/", so "2026-10-21" came out as "2026-10-21", is not alnum, and an
    # invented row was reported as a real one. A date separator is not a name.
    # The columns that could carry a name (region, export_email) are asserted
    # against fixed sets below, and that is the part that means something.
    separators = (",", ".", "$", "'", " ", "-", "/")
    for row in rows:
        for value in row.values():
            stripped = value
            for separator in separators:
                stripped = stripped.replace(separator, "")
            assert ("example.com" in value or stripped.isalnum()
                    or not value.strip()), row
    assert {row["export_email"] for row in rows} == {"exports@example.com"}
    assert {row["region"] for row in rows} == {"north", "south", "online"}
