#!/usr/bin/env python3
"""Writes the TV tables, the brand grid and the counts from each examples/<folder>/tv.json.

    gen_tables.py [--root DIR]                 rewrite README.md, the translated READMEs that carry its brand
                                               block, examples/README.md and tools/readme_stats.py
    gen_tables.py --check                      write nothing; exit 1 when a file is out of date
    gen_tables.py --font Fredoka.ttf           also redraw docs/stats.png and docs/card.jpg if a count moved
    gen_tables.py --adopt PATCH                write tv.json for folders that have none, from the table rows
                                               in PATCH (an old-style PR diff), then rewrite as above

Every TV-list PR used to add a row to the same two tables, so each merge made the other open list PRs
conflict. Now a list PR adds only its own folder, with a tv.json beside debloat.txt:

    {
      "tv": "Xiaomi Mi TV 4A (MiTV-MOOQ1)",
      "os": "Android TV 10",
      "brand": "Xiaomi",
      "notes": "What the list disables and keeps, one line of Markdown (links relative to examples/)"
    }

and a maintainer runs this after merging. "tv" and "os" fill the tables, "notes" the examples/README.md
one, "brand" the brand grid in the README (a brand whose every TV says "projector" goes on the projector
line). One optional key: "readme_os", a README table's OS cell when it is not "os". The tables list the
folders in the same order, alphabetical by "tv" (case ignored), so a brand's models sit together.

The full table lives in examples/README.md between <!-- tv-table:start ... --> and <!-- tv-table:end -->.
README.md has no table of its own: its brand grid (the TOP brands with the most lists, linked to the site's
search, and the rest in a <details>) sits between <!-- tv-brands:start ... --> and <!-- tv-brands:end -->.
README.nl.md and README.de.md carry the same brand block and the same counts in their own words ("N
tv-modellen · B merken · L talen · " and "N TV-Modelle · B Marken · L Sprachen · "), and a README that still
has tv-table markers gets the short table there too. Everything outside the markers is hand-written.
The counts are the card's alt text ("N TV models · B brands · L languages · "), the "| **L languages** |"
feature row and STATS in tools/readme_stats.py. Exit codes: 0 up to date (or written), 1 out of date
(--check), or a count moved and --font could not draw one of the pictures (the tables are written; the
picture is not - redraw it before committing), 2 what it was given cannot be read: a tv.json, a marker, or
the path in --root or --adopt (nothing is written then).
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote

KEYS = ("tv", "os", "brand", "notes")
OPTIONAL = ("readme_os",)
WIDTH = 120  # the brand lines under the grid wrap before this many characters
TOP = 30  # brands in the README's grid
COLUMNS = 6  # the grid's columns: 30 brands make 5 rows
SEARCH = "https://cleanyour.tv/tvs?q="  # the site's TV search; a brand cell links to its results
# The three words README.md states its counts in, in the order the card's alt text gives them.
WORDS = ("TV models", "brands", "languages")
README_HEADER = ["| TV | OS |", "|---|---|"]
EXAMPLES_HEADER = ["| Folder | TV | OS | Notes |", "|---|---|---|---|"]
SOURCE = "generated by tools/gen_tables.py from examples/*/tv.json; edit those, not this"
# A Markdown link's destination, to check that the ones in a cell lead somewhere.
LINK = re.compile(r"\]\(([^)\s]+)\)")


class Problem(Exception):
    """A tv.json or a marker the script cannot work with; the message says which and why."""


def folders(root):
    return sorted(p for p in (root / "examples").iterdir() if p.is_dir())


def check_cell(where, key, value):
    if not isinstance(value, str) or not value.strip():
        return [f"{where}: \"{key}\" must be a non-empty string"]
    out = []
    if value != value.strip() or "\n" in value:
        out.append(f"{where}: \"{key}\" must be one line with no space at either end")
    if re.search(r"(?<!\\)\|", value):
        out.append(f"{where}: \"{key}\" has a | that would split the table cell; write \\| instead")
    return out


def load(root, allow_missing=False):
    """[(folder name, tv.json dict)] for every folder in examples/; raises Problem listing every bad file.

    `--adopt` writes the tv.json of a folder that has none, so it reads the checkout with allow_missing:
    a folder without one is not a problem there, while every file that is there is judged by these same
    rules before the patch is applied.
    """
    problems, entries = [], []
    for folder in folders(root):
        where = f"examples/{folder.name}/tv.json"
        path = folder / "tv.json"
        if not path.is_file():
            if allow_missing and not path.exists():
                continue
            what = "is missing" if not path.exists() else "is not a file"
            problems.append(f"{where} {what}: it holds the folder's row in the README tables "
                            f"(see CONTRIBUTING.md, step 4)")
            continue
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except ValueError as e:
            problems.append(f"{where}: not valid JSON ({e})")
            continue
        if not isinstance(data, dict):
            problems.append(f"{where}: must be a JSON object")
            continue
        unknown = sorted(set(data) - set(KEYS) - set(OPTIONAL))
        if unknown:
            problems.append(f"{where}: unknown key(s) {unknown}; the keys are {list(KEYS + OPTIONAL)}")
        for key in KEYS:
            problems += check_cell(where, key, data.get(key))
        if "readme_os" in data:
            problems += check_cell(where, "readme_os", data["readme_os"])
        for key in ("tv", "notes"):
            value = data.get(key)
            # A value that is not a string has no link to read, and check_cell has already said it is wrong.
            for target in LINK.findall(value if isinstance(value, str) else ""):
                if re.match(r"^(https?:|mailto:|#)", target):
                    continue
                if not (root / "examples" / target.split("#")[0]).exists():
                    problems.append(f"{where}: the link {target} in \"{key}\" leads nowhere "
                                    f"(links are relative to examples/)")
        entries.append((folder.name, data))
    if problems:
        raise Problem("\n".join(problems))
    return entries


def sort_key(entry):
    """Table order: alphabetical by TV, case ignored; the folder name breaks a tie."""
    name, data = entry
    return data["tv"].casefold(), name


def ordered(entries):
    return sorted(entries, key=sort_key)


def readme_rows(entries):
    return README_HEADER + [f"| {d['tv']} | {d.get('readme_os', d['os'])} |"
                            for _, d in ordered(entries)]


def examples_rows(entries):
    return EXAMPLES_HEADER + [f"| `{n}/` | {d['tv']} | {d['os']} | {d['notes']} |"
                              for n, d in ordered(entries)]


def wrap(items):
    lines, cur = [], ""
    for item in items:
        piece = item + " ·"
        if cur and len(cur) + 1 + len(piece) > WIDTH:
            lines.append(cur)
            cur = piece
        else:
            cur = f"{cur} {piece}" if cur else piece
    return lines + ([cur] if cur else [])


def brands(entries):
    """{brand: [TV cells]}."""
    out = {}
    for _, d in entries:
        out.setdefault(d["brand"], []).append(d["tv"])
    return out


def brand_lines(entries):
    """The brand block: a grid of the TOP brands, then the rest in a <details>.

    Grid cells are "[Brand](search link) · lists", most lists first, alphabetical within a count, COLUMNS to
    a row. Under it the remaining brands keep the old line format: bold brands with their count, single-list
    brands, then the projector makers (a brand whose every TV says "projector")."""
    by = brands(entries)
    projectors = sorted((b for b, tvs in by.items() if all("projector" in t.lower() for t in tvs)),
                        key=str.casefold)
    tv = {b: len(tvs) for b, tvs in by.items() if b not in projectors}
    ranked = sorted(tv, key=lambda b: (-tv[b], b.casefold()))
    top, rest = ranked[:TOP], ranked[TOP:]
    out = []
    if top:
        cells = [f"[{b}]({SEARCH}{quote(b, safe='')}) · {tv[b]}" for b in top]
        cells += [""] * (-len(cells) % COLUMNS)
        out += ["|" + " |" * COLUMNS, "|" + "---|" * COLUMNS]
        out += ["| " + " | ".join(cells[i:i + COLUMNS]) + " |" for i in range(0, len(cells), COLUMNS)]
    if rest or projectors:
        more = ([f"{len(rest)} more brands"] if rest else []) + \
               ([f"{len(projectors)} projector brands"] if projectors else [])
        bold = [b for b in rest if tv[b] > 1]
        plain = sorted((b for b in rest if tv[b] == 1), key=str.casefold)
        lines = wrap([f"**{b}** {tv[b]}" for b in bold]) + wrap(plain)
        if projectors:
            names = ", ".join(projectors[:-1]) + " and " + projectors[-1] if len(projectors) > 1 else projectors[0]
            lines.append(names + " projectors")
        out += (([""] if out else []) + ["<details>", f"<summary>+{' and '.join(more)}</summary>", ""]
                + lines + ["", "</details>"])
    return out


def languages(root):
    """English plus every translated strings.xml of the launcher."""
    return len(list((root / "launcher/app/src/main/res").glob("values-*/strings.xml"))) + 1


def has_markers(text, name):
    return any(l.startswith(f"<!-- {name}:start") for l in text.split("\n"))


def between(text, name, body, path):
    """text with the lines between the `name` markers replaced by body (a list of lines), blank-padded."""
    lines = text.split("\n")
    start = [i for i, l in enumerate(lines) if l.startswith(f"<!-- {name}:start")]
    end = [i for i, l in enumerate(lines) if l.strip() == f"<!-- {name}:end -->"]
    if len(start) != 1 or len(end) != 1 or start[0] > end[0]:
        raise Problem(f"{path}: needs exactly one <!-- {name}:start ... --> line before one <!-- {name}:end --> line")
    return "\n".join(lines[:start[0] + 1] + [""] + body + [""] + lines[end[0]:])


def counts(text, entries, root, words=WORDS):
    """The three numbers a page states, in that page's own words: the card's alt text and the row."""
    n, b, l = len(entries), len(brands(entries)), languages(root)
    models, brand_word, language_word = words
    text = re.sub(rf"\b\d+ {re.escape(models)} · \d+ {re.escape(brand_word)} · \d+ {re.escape(language_word)} · ",
                  f"{n} {models} · {b} {brand_word} · {l} {language_word} · ", text)
    return re.sub(rf"^(\| \*\*)\d+ {re.escape(language_word)}(\*\* \|)",
                  rf"\g<1>{l} {language_word}\g<2>", text, flags=re.M)


def stats(text, entries, root):
    for value, label in ((len(entries), "TV MODELS"), (len(brands(entries)), "BRANDS"),
                         (languages(root), "LANGUAGES")):
        text = re.sub(rf'\("\d+", "{label}"\)', f'("{value}", "{label}")', text)
    return text


OWNED = ("README.md", "examples/README.md", "tools/readme_stats.py")
# The translated READMEs that carry the brand block too, written when the file is there - and the three
# words each of them states its counts in, for the card's alt text ("345 TV models · 93 brands · 29
# languages · ") and the "| **29 languages** |" row. The brand block is generated, so those numbers have
# to move with it: README.nl.md was added to this script as it stood and said "316 tv-modellen · 89
# merken · 28 talen" over a grid built from 345 lists in 93 brands, because counts() only knew the
# English words. A page listed here without its words would drift the same way, so
# tests/test_gen_tables_translated_counts.py reads them - and holds every page that carries the markers to
# being written, which is how README.de.md came here: it copied the block by hand when the German page
# landed, so its grid said "Xiaomi · 32" and "+49 more brands" over 97 brands while README.md said 34 and
# +53, and no run moved it.
TRANSLATIONS = {"README.nl.md": ("tv-modellen", "merken", "talen"),
                "README.de.md": ("TV-Modelle", "Marken", "Sprachen")}


def readme(text, entries, root, path, words=WORDS):
    """A README with its brand block (required), its short TV table (only if it has the markers) and counts."""
    text = between(text, "tv-brands", brand_lines(entries), path)
    if has_markers(text, "tv-table"):
        text = between(text, "tv-table", readme_rows(entries), path)
    return counts(text, entries, root, words)


def render(root, texts=None):
    """{relative path: the text it should have}, for the files this script owns parts of.

    texts, if given, stands in for what those files hold now (by the same paths)."""
    entries = load(root)
    paths = list(OWNED) + [p for p in TRANSLATIONS if (texts or {}).get(p) or (root / p).is_file()]
    now = {p: (texts or {}).get(p) or (root / p).read_text(encoding="utf-8") for p in paths}
    out = {p: readme(now[p], entries, root, p, TRANSLATIONS.get(p, WORDS))
           for p in ["README.md"] + paths[len(OWNED):]}
    out["examples/README.md"] = between(now["examples/README.md"], "tv-table", examples_rows(entries),
                                        "examples/README.md")
    out["tools/readme_stats.py"] = stats(now["tools/readme_stats.py"], entries, root)
    return out


def stale(root):
    """The files whose text differs from what render() gives."""
    return [p for p, text in render(root).items() if (root / p).read_text(encoding="utf-8") != text]


# --adopt: an old-style PR carries its rows in README.md and examples/README.md instead of a tv.json.
EXAMPLES_ROW = re.compile(r"^\| `([A-Za-z0-9][A-Za-z0-9._-]*)/` \| (.+?) \| (.+?) \| (.+) \|$")
README_ROW = re.compile(r"^\| (.+?) \| (.+?) \|$")


def slug(s):
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")


def guess_brand(folder, tv, known):
    """The known brand whose name the folder starts with (the longest), else the TV cell's first word."""
    hits = [b for b in known if folder == slug(b) or folder.startswith(slug(b) + "-")
            or folder.startswith(slug(b).replace("-", "") + "-")]
    if hits:
        return max(hits, key=len)
    return re.split(r"[\s,(]", tv)[0]


def write_json(path, data):
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def adopt(root, patch_text):
    """Writes examples/<folder>/tv.json from the rows an old-style PR added to the tables.

    Reads the added lines of a unified diff (or plain table rows): the examples/README.md row gives tv, os
    and notes, a README row with the same TV gives readme_os when its OS differs. A folder with no tv.json
    gets one. A folder that has one (a PR that edited its row) takes the new tv, os and notes only if the
    row the PR removed is what its tv.json still says; if main has changed the row since, nothing is
    written for it and Problem is raised once the rest are done. Returns [(folder, what happened)].

    The tv.json files already in the checkout are read through load(), so one this script cannot use is
    named in the words --check names it and Problem is raised before the first write: a patch is applied
    whole or not at all, which is what the module's exit-2 line says."""
    lines = patch_text.split("\n")
    added = [l[1:] if l.startswith("+") else l for l in lines if not l.startswith(("-", " "))]
    removed = [l[1:] for l in lines if l.startswith("-")]
    rows = {m.group(1): m.groups()[1:] for m in map(EXAMPLES_ROW.match, added) if m}
    before = {m.group(1): m.groups()[1:] for m in map(EXAMPLES_ROW.match, removed) if m}
    readme_os = {m.group(1): m.group(2) for m in map(README_ROW.match, added) if m}
    # Every file that is there, judged the way the tables are built from it. Reading them with a bare
    # json.loads made a trailing comma a JSONDecodeError traceback and a list at the root an
    # AttributeError, both exit 1 - the code --check keeps for "the tables are out of date" - and a
    # tv.json load() refuses was only found by the render step that follows, after this had written the
    # patch's new folders: the run then said nothing was written when some of it was.
    have = dict(load(root, allow_missing=True))
    known = sorted({d.get("brand", "") for d in have.values()} - {""})
    out, stale_rows = [], []
    for name, (tv, os_cell, notes) in sorted(rows.items()):
        folder = root / "examples" / name
        if not folder.is_dir():
            continue
        if name in have:
            now = tuple(have[name][k] for k in ("tv", "os", "notes"))
            if now == (tv, os_cell, notes):
                continue
            if before.get(name) != now:
                stale_rows.append(f"examples/{name}/tv.json: the row this PR edits has changed on main since; "
                                  f"move the PR's change into tv.json by hand")
                continue
            write_json(folder / "tv.json", dict(have[name], tv=tv, os=os_cell, notes=notes))
            out.append((name, "updated tv, os and notes from the PR's row"))
            continue
        brand = guess_brand(name, tv, known)
        data = {"tv": tv, "os": os_cell, "brand": brand, "notes": notes}
        if readme_os.get(tv, os_cell) != os_cell:
            data["readme_os"] = readme_os[tv]
        write_json(folder / "tv.json", data)
        out.append((name, f"new, brand {brand}" + ("" if brand in known else " (a new brand: check the spelling)")))
    if stale_rows:
        raise Problem("\n".join([f"examples/{n}/tv.json: {what}" for n, what in out] + stale_rows))
    return out


def read_inputs(root, patch):
    """(the patch's text or None, the reason this run cannot start or None).

    Nothing is read or written when this returns a reason. Exit 1 is the code main's "README tables" job
    reads as "the tables are out of date", so a path this script cannot read must not come back as 1: a
    `--root` that is not a checkout (a typo, a worktree that was removed) and an `--adopt` patch that is
    not there both ended in a FileNotFoundError traceback before, which is exit 1 - the one answer that
    says the generated files are stale when in fact nothing was read at all.
    """
    if not root.is_dir():
        return None, f"--root '{root}': not a folder. Nothing was read or written."
    # examples/ is what it reads; the three OWNED files are the parts of the checkout it writes.
    needs = ["examples"] + list(OWNED)
    missing = [f"{n}/" if n == "examples" else n for n in needs if not (root / n).exists()]
    if missing:
        return None, (f"--root '{root}': no {', no '.join(missing)} in it. Point --root at a checkout of this "
                      f"repository: nothing was read or written.")
    if patch is None:
        return None, None
    try:
        return Path(patch).read_text(encoding="utf-8"), None
    except OSError as e:
        # A path that is a directory is OSError too (IsADirectoryError), so this covers a patch folder.
        # The path is quoted so an empty --adopt ('' or a stray pair of quotes) is visible in the line.
        return None, f"--adopt: cannot read '{patch}': {e.strerror}. Nothing was written."


def redraw(root, font):
    """Draw docs/stats.png then docs/card.jpg through the two card scripts; a line saying what stopped, or None.

    This is the one place the script starts two other programs, and each of them answers a font that is not
    Fredoka's variable TTF - the Google Fonts zip renamed, a static single-weight file beside it - or a venv
    without Pillow (requirements.txt has none; docs/workflows/press.md installs requirements-perf.txt) with
    its own line and exit 1. With check=True that line was then followed by a CalledProcessError traceback
    that named the font path as a command, after the tables had already been rewritten: a run that read like
    "nothing was drawn" had changed two files, and the pictures it promised were not there.
    """
    for script, out in (("tools/readme_stats.py", "docs/stats.png"),
                        ("tools/readme_card.py", "docs/card.jpg")):
        done = subprocess.run([sys.executable, script, font, out], cwd=root)
        if done.returncode:
            return (f"{out} was not redrawn: {script} exited {done.returncode} and its line above says why. "
                    f"The tables are written; redraw the picture once that is fixed, with "
                    f"python3 {script} <Fredoka.ttf> {out}, and commit it with the tables.")
    return None


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--root", default=str(Path(__file__).resolve().parents[1]))
    ap.add_argument("--check", action="store_true", help="write nothing; exit 1 when a file is out of date")
    ap.add_argument("--font", help="Fredoka TTF (variable, from Google Fonts; not in this repo): redraw "
                                   "docs/stats.png and docs/card.jpg when a count changed")
    ap.add_argument("--adopt", metavar="PATCH", help="write missing tv.json files from the rows in PATCH first")
    a = ap.parse_args(argv)
    if a.check and a.adopt is not None:
        ap.error("--check and --adopt cannot be used together")
    if a.font and not Path(a.font).is_file():
        # The scripts this hands the font to exit with their own line, but subprocess.run(check=True) would
        # then turn that into a CalledProcessError traceback, which names neither the file nor where it is.
        ap.error(f"cannot open the font at {a.font}: Fredoka (variable TTF, Google Fonts) is not in this repo")
    root = Path(a.root)
    patch, problem = read_inputs(root, a.adopt)
    if problem:
        print(problem, file=sys.stderr)
        return 2
    try:
        if a.adopt:
            for name, what in adopt(root, patch):
                print(f"examples/{name}/tv.json: {what}")
        if a.check:
            out = stale(root)
            if out:
                print(f"Out of date: {', '.join(out)}. These tables are generated from examples/*/tv.json: "
                      f"a maintainer runs python3 tools/gen_tables.py after merging and commits the result.")
                return 1
            return 0
        changed = []
        for path, text in render(root).items():
            if (root / path).read_text(encoding="utf-8") != text:
                (root / path).write_text(text, encoding="utf-8")
                changed.append(path)
    except Problem as e:
        print(e, file=sys.stderr)
        return 2
    if "tools/readme_stats.py" in changed:
        if a.font:
            # The font was checked from where this runs; the scripts run in the checkout, so hand them the full path.
            font = str(Path(a.font).resolve())
            stopped = redraw(root, font)
            if stopped:
                print(stopped, file=sys.stderr)
                return 1
            changed += ["docs/stats.png", "docs/card.jpg"]
        else:
            print("A count changed: redraw docs/stats.png and docs/card.jpg (--font Fredoka.ttf, or "
                  "tools/readme_stats.py and tools/readme_card.py).")
    print("Updated: " + ", ".join(changed) if changed else "Up to date.")
    return 0


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