#!/usr/bin/env python3
"""R2 - weekly report: one CSV export in, a week-over-week report out.

What this is
------------
One recipe from the Back-Office Autopilot pack. You point it at the CSV you
already export every week - sales, signups, tickets, hours, ad spend; anything
with a date column and one or more number columns - and it works out the
latest ISO year-week in the file, the ISO week immediately before it, and for
every number column the total, the delta and the percent change between them.
It writes two reports you can read or forward, ``report.txt`` and
``report.html``, and prints the same numbers to your terminal.

What this is not
----------------
It does not send or upload anything. Not one report leaves this program, to
anything, ever: there is no mail, network or HTTP import anywhere in this file
(``test_weekly_report.py`` reads this module's own imports and call names to
prove that, rather than taking this sentence's word for it), and the only thing
it produces is two files in a folder you chose. A human reads them and decides
what happens next. That is a deliberate product decision, not a missing feature
- see ``RECIPE.md``.

It also does not guess. A cell that is not a number is counted and named in the
report, never quietly turned into one. A date it cannot read is counted and
named, never assumed. A missing week is reported as missing, with the words
"no prior week to compare", instead of being quietly filled in from an older
week - which is the failure that makes a Monday report lie.

Read-date: 2026-11-10.  Stdlib only (python 3.12): argparse, csv, html, math,
re, sys, collections, dataclasses, datetime, pathlib. Runs with no network, no
install and no paid service.
"""

from __future__ import annotations

import argparse
import csv
import html
import math
import re
import sys
from collections import Counter
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from pathlib import Path

READ_DATE = "2026-11-10"
RECIPE_NAME = "R2 weekly report - week over week from one CSV export"

#: The exact words the report says when the previous ISO week holds no row.
#: Kept in one constant so the tests and the report can never drift apart.
NO_PRIOR = "no prior week to compare"

#: Said in both reports and on the terminal, in the buyer's language.
LOCAL_ONLY = (
    "This recipe reads local files and writes local files. It has no way to "
    "send, upload or post anything: no mail, no network, no login."
)

#: A currency symbol or ISO code may sit in front of, or behind, a figure.
CURRENCY_SYMBOLS = "$\u20ac\u00a3\u00a5"
CURRENCY_CODES = ("usd", "cad", "eur", "gbp", "jpy")

#: Group-of-three separators only, written with a comma or with a space. This
#: is what stops "49,95" - a comma used as a decimal mark - from being read as
#: 4995. See parse_number.
THOUSANDS_RE = re.compile(r"^\d{1,3}(?:,\d{3})+(?:\.\d+)?$")
SPACE_THOUSANDS_RE = re.compile(r"^\d{1,3}(?: \d{3})+(?:[.,]\d+)?$")

#: The date shapes this recipe reads. Anything else is counted and named.
ISO_RE = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?$")
YMD_SLASH_RE = re.compile(r"^(\d{4})/(\d{1,2})/(\d{1,2})(?:[ T].*)?$")
SLASH_RE = re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{4})(?:[ T].*)?$")
MONTH_FIRST_RE = re.compile(r"^([A-Za-z]{3,9})\.?[- ]+(\d{1,2}),?[- ]+(\d{4})$")
DAY_FIRST_RE = re.compile(r"^(\d{1,2})[- ]+([A-Za-z]{3,9})\.?,?[- ]+(\d{4})$")

#: Month names, spelled out or abbreviated, as they arrive lower-cased.
MONTHS = {
    "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6,
    "july": 7, "august": 8, "september": 9, "october": 10, "november": 11,
    "december": 12, "jan": 1, "feb": 2, "mar": 3, "apr": 4, "jun": 6, "jul": 7,
    "aug": 8, "sep": 9, "sept": 9, "oct": 10, "nov": 11, "dec": 12,
}

#: Shape labels, as they appear in the report's "date shapes read" line.
SHAPE_ISO = "ISO (2026-11-02)"
SHAPE_YMD = "slash year first (2026/11/02)"
SHAPE_MDY = "slash month first (11/02/2026)"
SHAPE_DMY = "slash day first (25/11/2026)"
SHAPE_NAMED = "named month (Nov 2, 2026)"

#: How many examples of a bad value the report names before saying "and N more".
MAX_EXAMPLES = 10

#: A column counts as numeric, when --metrics is not given, when at least one
#: of its values reads as a number and at least this share of its filled cells
#: do. A text column with one stray number in it is not a metric.
NUMERIC_SHARE = 0.5


class RecipeError(Exception):
    """A file-level problem. The run stops, the message names it, nothing is written."""


class InputFileError(RecipeError):
    """The input CSV cannot be used at all: absent, headerless, or empty."""


class MissingColumnError(RecipeError):
    """A column this run was told to use is not in the file."""


@dataclass
class Metric:
    """One number column, totalled over the two weeks being compared.

    The quality counts (``unreadable``, ``blank``) are over the whole file, not
    only the reported weeks: a cell that could not be read anywhere in the
    export is a problem with the export, and the buyer should hear about it even
    if it happens to sit in a week nobody is reading.
    """

    name: str
    current_label: str = ""
    previous_label: str = ""
    current_total: float = 0.0
    previous_total: float = 0.0
    current_values: int = 0
    previous_values: int = 0
    unreadable: list[tuple[int, str]] = field(default_factory=list)
    blank: int = 0

    @property
    def has_current(self) -> bool:
        """False when nothing in this column was readable in the current week."""
        return self.current_values > 0

    @property
    def has_previous(self) -> bool:
        return self.previous_values > 0

    def delta(self, previous_present: bool) -> float | None:
        """The change in the unit, or None when there is nothing to subtract."""
        if not previous_present or not self.has_current or not self.has_previous:
            return None
        return self.current_total - self.previous_total

    def change_pct(self, previous_present: bool) -> tuple[float | None, str]:
        """(percent change, the reason it is n/a). It never divides by zero.

        Three different kinds of "cannot say" are kept apart, because they mean
        different things to the person reading the report: the previous week is
        not in the file at all, the base was zero, or this column held nothing
        readable. None of them is rendered as a number.
        """
        if not previous_present:
            return None, f"{NO_PRIOR}: no row dated in {self.previous_label}"
        if not self.has_current:
            return None, f"n/a: no readable value in {self.current_label}"
        if not self.has_previous:
            return None, f"n/a: no readable value in {self.previous_label}"
        if self.previous_total == 0:
            return None, "n/a: the previous week's total is 0, so a percent of it is undefined"
        return (self.current_total - self.previous_total) / abs(self.previous_total) * 100.0, ""


@dataclass
class Report:
    """What a run found, in the order a buyer reads it."""

    title: str
    input_path: Path
    now_iso: str
    date_column: str
    date_shapes: Counter
    rows_read: int
    rows_dated: int
    bad_dates: list[tuple[int, str]]
    weeks: Counter
    current_week: tuple[int, int]
    previous_week: tuple[int, int]
    previous_present: bool
    metrics: list[Metric]
    unused_columns: list[str]

    @property
    def current_label(self) -> str:
        return week_label(self.current_week)

    @property
    def previous_label(self) -> str:
        return week_label(self.previous_week)

    @property
    def rows_other_weeks(self) -> int:
        """Dated rows that fall in neither of the two weeks being compared."""
        outside = self.rows_dated - self.weeks.get(self.current_label, 0)
        if self.previous_present:
            outside -= self.weeks.get(self.previous_label, 0)
        return outside

    def summary(self) -> str:
        """The plain-text body: title, header lines, the table, what was left out."""
        lines = [f"{'report'.ljust(19)}: {self.title}"]
        for label, value in fact_rows(self):
            lines.append(f"{label.ljust(19)}: {value}")
        lines.append("")
        lines.append(align_table(*metric_table(self)))
        lines.append("")
        lines.append("WHAT WAS LEFT OUT, AND WHY")
        lines.extend(f"  - {note}" for note in note_rows(self))
        return "\n".join(lines)


# --------------------------------------------------------------------- reading


def parse_number(raw: object) -> float | None:
    """Read one cell as a number, or return None. It never guesses a figure.

    Read: 1200, 1200.5, -12.5, +7, 0, $1,200.50, a figure with a space in it,
    42 EUR. Not read, so that it can be counted and named in the report
    instead: a blank cell; "n/a"; "49,95", where the comma is a decimal mark
    and reading it as 4995 would be a wrong number rather than a missing one;
    "12%", because twelve percent of an unstated base is a judgement; "(500)",
    because parentheses standing for a negative is a house style this recipe
    does not assume; and "nan" or "inf", which would poison every total after
    them and are not figures anyone exported on purpose.
    """
    if raw is None:
        return None
    text = str(raw).replace("\u00a0", " ").strip()
    if not text:
        return None
    sign = 1.0
    if text[0] in "+-":
        sign = -1.0 if text[0] == "-" else 1.0
        text = text[1:].strip()
    lowered = text.lower()
    for code in CURRENCY_CODES:
        if lowered.startswith(code) or lowered.endswith(code):
            text = text[len(code):] if lowered.startswith(code) else text[: -len(code)]
            break
    text = text.strip().lstrip(CURRENCY_SYMBOLS).rstrip(CURRENCY_SYMBOLS).strip()
    if THOUSANDS_RE.match(text):
        text = text.replace(",", "")
    elif SPACE_THOUSANDS_RE.match(text):
        text = text.replace(" ", "").replace(",", ".")
    try:
        value = float(text)
    except ValueError:
        return None
    if not math.isfinite(value):
        return None
    return sign * value


def parse_date(raw: object) -> tuple[date | None, str]:
    """Read one cell as a date. Returns (the date or None, the shape it read).

    The shape comes back as well, and is printed in the report, because one
    shape here is genuinely ambiguous: 11/02/2026 is read as 2 November, the US
    reading. Printing how many dates of each shape were read is what lets a
    buyer whose export is day-first notice it, rather than quietly receiving a
    report shifted by a month. Where the first number cannot be a month,
    25/11/2026, the calendar settles it and it is read day first: that one is
    not an assumption.
    """
    text = str(raw if raw is not None else "").strip()
    if not text:
        return None, "blank"

    match = ISO_RE.match(text)
    if match:
        return _build(int(match[1]), int(match[2]), int(match[3]), SHAPE_ISO)
    match = YMD_SLASH_RE.match(text)
    if match:
        return _build(int(match[1]), int(match[2]), int(match[3]), SHAPE_YMD)
    match = SLASH_RE.match(text)
    if match:
        first, second, year = int(match[1]), int(match[2]), int(match[3])
        try:
            return date(year, first, second), SHAPE_MDY
        except ValueError:
            pass
        try:
            return date(year, second, first), SHAPE_DMY
        except ValueError:
            return None, "unreadable"
    match = MONTH_FIRST_RE.match(text)
    if match:
        month = MONTHS.get(match[1].lower())
        if month:
            return _build(int(match[3]), month, int(match[2]), SHAPE_NAMED)
        return None, "unreadable"
    match = DAY_FIRST_RE.match(text)
    if match:
        month = MONTHS.get(match[2].lower())
        if month:
            return _build(int(match[3]), month, int(match[1]), SHAPE_NAMED)
        return None, "unreadable"
    return None, "unreadable"


def _build(year: int, month: int, day: int, shape: str) -> tuple[date | None, str]:
    """Build a date, or report it unreadable when the calendar refuses it."""
    try:
        return date(year, month, day), shape
    except ValueError:
        return None, "unreadable"


def week_label(key: tuple[int, int]) -> str:
    """(2026, 45) becomes '2026-45': the label the report, and the tests, use."""
    year, week = key
    return f"{year}-{week:02d}"


def week_span(key: tuple[int, int]) -> tuple[date, date]:
    """The Monday and the Sunday of an ISO year-week."""
    monday = date.fromisocalendar(key[0], key[1], 1)
    return monday, monday + timedelta(days=6)


def previous_week_of(key: tuple[int, int]) -> tuple[int, int]:
    """The ISO week immediately before, by calendar arithmetic.

    Going back exactly seven days from that week's Monday and re-reading the
    ISO year-week of the result is what makes a December-to-January boundary
    come out right. It is also what makes a gap a gap: the week before 2026-46
    is 2026-45 whether or not the file holds a single row of it.
    """
    monday = date.fromisocalendar(key[0], key[1], 1) - timedelta(days=7)
    iso = monday.isocalendar()
    return (iso.year, iso.week)


def iso_key(moment: date) -> tuple[int, int]:
    """The ISO year-week a date belongs to."""
    iso = moment.isocalendar()
    return (iso.year, iso.week)


def read_rows(path: Path) -> tuple[list[str], list[tuple[int, dict]]]:
    """Return the header names and the data rows, each with its file row number.

    Row numbers start at 2, so they line up with what a buyer sees when they
    open the file in a spreadsheet: the header is row 1.
    """
    if not path.exists():
        raise InputFileError(
            f"{path}: no such file. Pass --input the path of your CSV export."
        )
    with path.open(encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        fieldnames = reader.fieldnames
        if not fieldnames or not any(str(name).strip() for name in fieldnames):
            raise InputFileError(
                f"{path}: no header row was found, so no column can be named. The first "
                f"line of the file has to name its columns (see the table in SETUP.md)."
            )
        header = [str(name).strip() for name in fieldnames]
        rows: list[tuple[int, dict]] = []
        for number, row in enumerate(reader, start=2):
            filled = [value for value in row.values() if value is not None]
            if not any(str(value).strip() for value in filled):
                continue
            rows.append((number, row))
    if not rows:
        raise InputFileError(
            f"{path}: it has a header line and no data rows at all, so there is nothing to "
            f"total. Its columns are {header}. An export that came out empty usually means "
            f"the export itself broke, which is worth checking before the report is."
        )
    return header, rows


def cell(row: dict, column: str) -> str:
    """One cell's text, tolerant of a short row and of a padded header name."""
    value = row.get(column)
    if value is None:
        for name, other in row.items():
            if name is not None and str(name).strip() == column:
                value = other
                break
    if value is None:
        return ""
    if isinstance(value, list):
        return ",".join(str(part) for part in value)
    return str(value).strip()


def option_name(what: str) -> str:
    """The flag a buyer would type to fix the column the error is about."""
    return "date-column" if what == "date" else "metrics"


def resolve_column(wanted: str, header: list[str], what: str) -> str:
    """Find a requested column in the header, or name it and list what is there.

    An exact match wins; a match ignoring case and padding is accepted next,
    because "Order Date" and "order date" are the same column to a person.
    """
    if wanted in header:
        return wanted
    folded = wanted.strip().lower()
    for name in header:
        if name.lower() == folded:
            return name
    raise MissingColumnError(
        f"{wanted}: no {what} column of that name in the file. Its columns are "
        f"{', '.join(header)}. Point at the right one with --{option_name(what)}."
    )


def detect_metrics(header: list[str], rows: list[tuple[int, dict]], date_column: str,
                   requested: list[str]) -> tuple[list[str], list[str]]:
    """Return (the columns to total, the columns left alone, with the reason)."""
    if requested:
        chosen: list[str] = []
        for wanted in requested:
            resolved = resolve_column(wanted, header, "metric")
            if resolved == date_column:
                raise MissingColumnError(
                    f"{resolved}: that is the date column of this file, so it cannot also "
                    f"be a metric. Drop it from --metrics."
                )
            if resolved not in chosen:
                chosen.append(resolved)
        return chosen, []

    chosen, unused = [], []
    for name in header:
        if name == date_column:
            continue
        filled = numeric = 0
        for _, row in rows:
            text = cell(row, name)
            if not text:
                continue
            filled += 1
            if parse_number(text) is not None:
                numeric += 1
        if numeric and filled and numeric / filled >= NUMERIC_SHARE:
            chosen.append(name)
        else:
            unused.append(f"{name} ({numeric} of {filled} filled value(s) read as a number)")
    return chosen, unused


# ---------------------------------------------------------------------- finding


def analyse(path: Path, date_column: str = "date", metrics: list[str] | None = None,
            title: str = "", now_iso: str = "") -> Report:
    """Read the file and work out everything the report says. Writes nothing."""
    header, rows = read_rows(path)
    date_name = resolve_column(date_column, header, "date")

    shapes: Counter = Counter()
    bad_dates: list[tuple[int, str]] = []
    entries: list[tuple[tuple[int, int] | None, int, dict]] = []
    weeks: Counter = Counter()
    for number, row in rows:
        raw = cell(row, date_name)
        moment, shape = parse_date(raw)
        if moment is None:
            bad_dates.append((number, raw))
            entries.append((None, number, row))
            continue
        shapes[shape] += 1
        key = iso_key(moment)
        entries.append((key, number, row))
        weeks[week_label(key)] += 1
    rows_dated = sum(1 for key, _, _ in entries if key is not None)
    if not rows_dated:
        examples = "; ".join(f"row {n} held {v!r}" for n, v in bad_dates[:MAX_EXAMPLES])
        raise InputFileError(
            f"{path}: none of its {len(rows)} data row(s) has a date this recipe can read, "
            f"so there is no week to report at all - {examples}. The shapes it reads are "
            f"listed in SETUP.md."
        )

    current = max(key for key, _, _ in entries if key is not None)
    previous = previous_week_of(current)
    previous_present = any(key == previous for key, _, _ in entries)

    chosen, unused = detect_metrics(header, rows, date_name, metrics or [])
    if not chosen:
        raise InputFileError(
            f"{path}: no numeric column to total. Its columns are {header}; the date lives "
            f"in {date_name}. Name the columns to total with --metrics units,revenue."
        )

    found: list[Metric] = []
    for name in chosen:
        metric = Metric(
            name=name,
            current_label=week_label(current),
            previous_label=week_label(previous),
        )
        for key, number, row in entries:
            text = cell(row, name)
            if not text:
                metric.blank += 1
                continue
            value = parse_number(text)
            if value is None:
                metric.unreadable.append((number, text))
                continue
            if key == current:
                metric.current_total += value
                metric.current_values += 1
            elif key == previous:
                metric.previous_total += value
                metric.previous_values += 1
        found.append(metric)

    return Report(
        title=title or f"Weekly report from {path.name}",
        input_path=path,
        now_iso=now_iso,
        date_column=date_name,
        date_shapes=shapes,
        rows_read=len(rows),
        rows_dated=rows_dated,
        bad_dates=bad_dates,
        weeks=weeks,
        current_week=current,
        previous_week=previous,
        previous_present=previous_present,
        metrics=found,
        unused_columns=unused,
    )


# -------------------------------------------------------------------- rendering


def fmt_number(value: float) -> str:
    """A figure with thousands separators, decimals only when it has any."""
    if abs(value - round(value)) < 0.0000001:
        return f"{value:,.0f}"
    return f"{value:,.2f}"


def fmt_delta(value: float | None) -> str:
    """The change in the unit, signed, or a dash when there is nothing to subtract."""
    if value is None:
        return "-"
    if abs(value) < 0.0000001:
        return "0"
    return ("+" if value > 0 else "-") + fmt_number(abs(value))


def fmt_change(change: float | None) -> str:
    """The percent change, or the word n/a. Never inf, never nan, never blank."""
    if change is None:
        return "n/a"
    if abs(change) < 0.05:
        return "0.0%"
    return f"{change:+.1f}%"


def week_phrase(key: tuple[int, int], rows: int) -> str:
    monday, sunday = week_span(key)
    return (f"{week_label(key)}  Mon {monday} to Sun {sunday}  "
            f"({rows} data row(s) dated in it)")


def fact_rows(report: Report) -> list[tuple[str, str]]:
    """The header of both reports: one line each, a label and then the value."""
    shapes = ", ".join(
        f"{count} {shape}" for shape, count in sorted(report.date_shapes.items())
    ) or "none"
    previous_rows = report.weeks.get(report.previous_label, 0)
    if report.previous_present:
        previous = week_phrase(report.previous_week, previous_rows)
    else:
        monday, sunday = week_span(report.previous_week)
        previous = (f"{week_label(report.previous_week)}  Mon {monday} to Sun {sunday} - "
                    f"no row dated in it, so {NO_PRIOR}")
    weeks_seen = ", ".join(f"{label} ({count})" for label, count in sorted(report.weeks.items()))
    return [
        ("recipe", f"{RECIPE_NAME}  (read-date {READ_DATE})"),
        ("input", f"{report.input_path}  ({report.rows_read} data row(s) read)"),
        ("generated", report.now_iso),
        ("date column", f"{report.date_column}  (date shapes read: {shapes})"),
        ("this week", week_phrase(report.current_week, report.weeks.get(report.current_label, 0))),
        ("last week", previous),
        ("weeks in file", weeks_seen),
        ("metrics totalled", ", ".join(metric.name for metric in report.metrics)),
        ("columns not totalled", "; ".join(report.unused_columns) or "none"),
    ]


def metric_table(report: Report) -> tuple[list[str], list[list[str]]]:
    """The numbers, as a header plus rows of text. Both renderers use it once."""
    header = ["metric", "this week", "last week", "delta", "change",
              "values used (this/last)", "not read as a number"]
    rows = []
    for metric in report.metrics:
        change, _reason = metric.change_pct(report.previous_present)
        if not report.previous_present:
            previous_cell = "no data"
        elif metric.has_previous:
            previous_cell = fmt_number(metric.previous_total)
        else:
            previous_cell = "-"
        rows.append([
            metric.name,
            fmt_number(metric.current_total) if metric.has_current else "-",
            previous_cell,
            fmt_delta(metric.delta(report.previous_present)),
            fmt_change(change),
            f"{metric.current_values}/"
            + (str(metric.previous_values) if report.previous_present else "-"),
            str(len(metric.unreadable)),
        ])
    return header, rows


def align_table(header: list[str], rows: list[list[str]]) -> str:
    """The table as fixed-width text, right-aligned on every number column."""
    widths = [max(len(row[i]) for row in [header] + rows) for i in range(len(header))]
    lines = ["  ".join(header[i].ljust(widths[i]) for i in range(len(header))).rstrip()]
    lines.append("  ".join("-" * widths[i] for i in range(len(header))))
    for row in rows:
        lines.append("  ".join(
            row[i].rjust(widths[i]) if i else row[i].ljust(widths[i])
            for i in range(len(header))
        ).rstrip())
    return "\n".join(lines)


def value_list(items: list[tuple[int, str]]) -> str:
    """"row 7 held 'n/a', row 9 held '49,95', and 3 more" - the bad cells, named."""
    named = ", ".join(f"row {n} held {v!r}" for n, v in items[:MAX_EXAMPLES])
    extra = len(items) - MAX_EXAMPLES
    return named + (f", and {extra} more" if extra > 0 else "")


def note_rows(report: Report) -> list[str]:
    """Everything the report left out, counted and named. The honest part."""
    notes: list[str] = []
    if not report.previous_present:
        notes.append(
            f"The week before {report.current_label} holds no row in this file, so "
            f"{NO_PRIOR}. Nothing was carried over from an older week to make a "
            f"comparison appear: every change column reads n/a."
        )
    for metric in report.metrics:
        _change, reason = metric.change_pct(report.previous_present)
        if reason and report.previous_present:
            notes.append(f"{metric.name}: {reason}.")
    for metric in report.metrics:
        if metric.unreadable:
            notes.append(
                f"{metric.name}: {len(metric.unreadable)} value(s) in this file could not be "
                f"read as a number, so they are in no total - {value_list(metric.unreadable)}."
            )
        if metric.blank:
            notes.append(
                f"{metric.name}: {metric.blank} cell(s) in this file were blank. A blank is "
                f"treated as no figure, not as 0, so it is in no total."
            )
    if report.bad_dates:
        notes.append(
            f"{len(report.bad_dates)} row(s) had a date this recipe could not read, so they "
            f"are in no week and no total - {value_list(report.bad_dates)}. Nothing was "
            f"dated by guess."
        )
    if report.rows_other_weeks:
        notes.append(
            f"{report.rows_other_weeks} dated row(s) fall in neither reported week. They are "
            f"counted in the weeks-in-file line and in no total."
        )
    if any(metric.unreadable for metric in report.metrics) or report.bad_dates:
        notes.append(
            "A value written with a comma as its decimal mark (49,95) is deliberately not "
            "read: read as 4995 it would be a wrong number rather than a missing one. Ask "
            "the export for a dot, fix the cells, and they will be counted."
        )
    notes.append(LOCAL_ONLY)
    return notes


def render_text(report: Report) -> str:
    """out/report.txt: the body a terminal gets, with a banner and the stamp."""
    rule = "=" * 78
    return "\n".join([
        rule,
        report.title,
        f"{RECIPE_NAME}   read-date {READ_DATE}",
        rule,
        report.summary(),
        "",
        f"Both reports were written to the folder you passed to --outdir: report.txt",
        f"(this file) and report.html (the same content, for reading or forwarding).",
        f"Generated {report.now_iso}.",
        "",
    ])


def render_html(report: Report) -> str:
    """out/report.html: inline styles only, no script, no font, no asset from anywhere.

    Every style here is a style attribute on the element it styles, and the
    document holds no link, img, iframe, object, form or external reference of
    any kind, which is what makes a report you forward a boring, safe attachment.
    """
    def esc(value: object) -> str:
        return html.escape(str(value), quote=True)

    body_font = ("font-family:system-ui,Segoe UI,Helvetica,Arial,sans-serif;font-size:15px;"
                 "line-height:1.45")
    parts = [
        "<!DOCTYPE html>",
        '<html lang="en">',
        "<head>",
        '<meta charset="utf-8">',
        f"<title>{esc(report.title)}</title>",
        "</head>",
        f'<body style="margin:0;padding:24px;background:#ffffff;color:#1b1f24;{body_font}">',
        f'<h1 style="margin:0 0 4px;font-size:22px">{esc(report.title)}</h1>',
        '<p style="margin:0 0 18px;color:#5a6270;font-size:13px">'
        f"{esc(RECIPE_NAME)} &nbsp;&middot;&nbsp; read-date {esc(READ_DATE)} "
        f"&nbsp;&middot;&nbsp; generated {esc(report.now_iso)}</p>",
        '<table style="border-collapse:collapse;margin:0 0 22px;font-size:14px">',
    ]
    for label, value in fact_rows(report):
        parts.append(
            "<tr>"
            '<td style="padding:3px 14px 3px 0;vertical-align:top;color:#5a6270;'
            f'white-space:nowrap">{esc(label)}</td>'
            f'<td style="padding:3px 0;vertical-align:top">{esc(value)}</td>'
            "</tr>"
        )
    parts.append("</table>")

    header, rows = metric_table(report)
    parts.append('<table style="border-collapse:collapse;font-size:14px">')
    parts.append("<tr>")
    for name in header:
        parts.append(
            '<th style="padding:6px 12px;border-bottom:2px solid #1b1f24;text-align:right;'
            f'font-size:13px">{esc(name)}</th>'
        )
    parts.append("</tr>")
    for row in rows:
        parts.append("<tr>")
        for index, value in enumerate(row):
            weight = "font-weight:600;" if index == 0 else ""
            parts.append(
                f'<td style="padding:6px 12px;border-bottom:1px solid #dfe3e8;'
                f'text-align:right;{weight}">{esc(value)}</td>'
            )
        parts.append("</tr>")
    parts.append("</table>")

    parts.append('<h2 style="margin:26px 0 8px;font-size:16px">What was left out, and why</h2>')
    parts.append('<ul style="margin:0;padding-left:20px;color:#3c434a">')
    for note in note_rows(report):
        parts.append(f'<li style="margin:0 0 7px">{esc(note)}</li>')
    parts.append("</ul>")
    parts.append("</body>")
    parts.append("</html>")
    return "\n".join(parts) + "\n"


# ----------------------------------------------------------------------- the CLI


def timestamp(text: str) -> str:
    """Resolve --now to a stamp like 2026-11-10T14:00:00Z (UTC)."""
    if not text:
        return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    try:
        moment = datetime.fromisoformat(text)
    except ValueError as exc:
        raise SystemExit(f"ERROR: --now {text!r} is not an ISO date-time ({exc}).")
    if moment.tzinfo is None:
        moment = moment.replace(tzinfo=timezone.utc)
    return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def write_reports(report: Report, outdir: Path) -> tuple[Path, Path]:
    """Write both reports into outdir and return their paths (html, text)."""
    outdir.mkdir(parents=True, exist_ok=True)
    text_path = outdir / "report.txt"
    html_path = outdir / "report.html"
    text_path.write_text(render_text(report), encoding="utf-8")
    html_path.write_text(render_html(report), encoding="utf-8")
    return html_path, text_path


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="weekly_report.py",
        description=(
            "R2: read a CSV export, total the latest ISO week against the week before it, "
            "and write report.txt and report.html. It reads local files and writes local "
            "files; it cannot send or upload anything."
        ),
    )
    parser.add_argument("--input", default="samples/weekly_sales.csv",
                        help="the CSV export to report on (default: %(default)s)")
    parser.add_argument("--outdir", default="out",
                        help="where report.txt and report.html are written (default: %(default)s)")
    parser.add_argument("--date-column", default="date",
                        help="the column holding the date (default: %(default)s)")
    parser.add_argument("--metrics", default="",
                        help="comma list of the number columns to total "
                             "(default: every column that reads as numbers)")
    parser.add_argument("--title", default="",
                        help="the heading on the report (default: names the input file)")
    parser.add_argument("--now", default="",
                        help="stamp the report with this ISO date-time (default: right now, UTC)")
    parser.add_argument("--quiet", action="store_true",
                        help="write the files but print nothing")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        report = analyse(
            path=Path(args.input),
            date_column=args.date_column,
            metrics=[part.strip() for part in args.metrics.split(",") if part.strip()],
            title=args.title,
            now_iso=timestamp(args.now),
        )
        html_path, text_path = write_reports(report, Path(args.outdir))
    except RecipeError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2
    if not args.quiet:
        print(report.summary())
        print()
        print(f"wrote {html_path}")
        print(f"wrote {text_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
