#!/usr/bin/env python3
"""Stamp the CHANGELOG.md at the root into the packages that publish it.

That file is the only changelog written by hand. The two places a
marketplace reads one from have to be inside the package they ship in, so
they are copies, and — like the version — they are copies only for as long
as a package build takes:

- `editors/vscode/CHANGELOG.md` — what the .vsix carries and the Visual
  Studio Marketplace and Open VSX show on their Changelog tab. Markdown,
  so it is the source verbatim.
- the `<change-notes>` block of the JetBrains plugin's `plugin.xml` —
  HTML, and only the most recent releases, which is what that marketplace
  shows next to the version.

Both hold a placeholder in the repository and are stamped at package time,
so a release is one commit to CHANGELOG.md and nothing else. The Makefile's
editor targets stamp and put the placeholder back around the build; the
publish workflows only stamp, on a checkout they throw away.

  packaging/changelog/generate                  stamp the changelog in
  packaging/changelog/generate --placeholder    put the placeholders back
  packaging/changelog/generate --release-notes VERSION
                                                print one release's section,
                                                which release.yml gives the
                                                GitHub release as its body
"""

from __future__ import annotations

import argparse
import html
import re
import sys
from dataclasses import dataclass
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent.parent
SOURCE = ROOT / "CHANGELOG.md"
VSCODE = ROOT / "editors" / "vscode" / "CHANGELOG.md"
PLUGIN_XML = ROOT / "editors" / "idea" / "src" / "main" / "resources" / "META-INF" / "plugin.xml"

# How many releases the JetBrains change notes carry. The Marketplace shows
# them under one version, so it is a "what's new lately", not the history.
CHANGE_NOTES_RELEASES = 3

# Where the changelog is when a package carries only the placeholder.
SOURCE_URL = "https://github.com/ArkadyBuryakov/workforest/blob/main/CHANGELOG.md"

BANNER = (
    "Generated by packaging/changelog/generate from the CHANGELOG.md at the\n"
    "repository root. Do not edit."
)

PLACEHOLDER_MARKDOWN = f"""# Changelog

Placeholder: the changelog is stamped in at package time, the way the
version is. Until then it lives [at the repository root]({SOURCE_URL}).
"""

PLACEHOLDER_NOTES = (
    "<p>Placeholder: the change notes are stamped in at package time, the way the "
    f'version is. Until then they live <a href="{SOURCE_URL}">at the repository root</a>.</p>'
)

INDENT = " " * 4


@dataclass(slots=True, frozen=True)
class Release:
    """One `## VERSION` section of the changelog."""

    version: str
    body: str


def parse(text: str) -> list[Release]:
    """The releases of a changelog, newest first."""
    parts = re.split(r"^## +(\S+) *$", text, flags=re.MULTILINE)
    return [
        Release(version, body.strip("\n"))
        for version, body in zip(parts[1::2], parts[2::2], strict=True)
    ]


def _link(match: re.Match[str]) -> str:
    text, url = match.groups()
    return f'<a href="{html.escape(url, quote=True)}">{text}</a>'


def _inline(text: str) -> str:
    """The inline markdown of one block, as HTML."""
    out = html.escape(" ".join(text.split()), quote=False)
    out = re.sub(r"`([^`]+)`", r"<code>\1</code>", out)
    out = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", out)
    return re.sub(r"\[([^]]+)\]\(([^)]+)\)", _link, out)


def _bullets(block: str) -> list[str]:
    """The items of a `- ` list, each with its continuation lines folded in."""
    items: list[str] = []
    for line in block.splitlines():
        if line.startswith("- "):
            items.append(line[2:])
        else:
            items[-1] += f" {line.strip()}"
    return items


def to_html(body: str) -> str:
    """A release body — paragraphs and `- ` lists — as HTML lines."""
    out: list[str] = []
    for block in re.split(r"\n{2,}", body.strip("\n")):
        if block.startswith("- "):
            out.append("<ul>")
            out += [f"  <li>{_inline(item)}</li>" for item in _bullets(block)]
            out.append("</ul>")
        else:
            out.append(f"<p>{_inline(block)}</p>")
    return "\n".join(out)


def change_notes(releases: list[Release]) -> str:
    """The JetBrains `<change-notes>` body: the recent releases, as HTML."""
    return "\n".join(
        f"<p><b>{release.version}</b></p>\n{to_html(release.body)}"
        for release in releases[:CHANGE_NOTES_RELEASES]
    )


def write_vscode(markdown: str) -> None:
    VSCODE.write_text(f"<!--\n{BANNER}\n-->\n\n{markdown}")


def write_change_notes(notes: str) -> None:
    body = "\n".join(f"{INDENT}{line}" for line in notes.splitlines())
    block = (
        f"{INDENT}<change-notes><![CDATA[\n"
        f"{INDENT}<!-- {BANNER.replace(chr(10), ' ')} -->\n"
        f"{body}\n"
        f"{INDENT}]]></change-notes>"
    )
    patched, count = re.subn(
        rf"^{INDENT}<change-notes>.*?</change-notes>",
        lambda _: block,
        PLUGIN_XML.read_text(),
        flags=re.MULTILINE | re.DOTALL,
    )
    if count != 1:
        sys.exit(f"{PLUGIN_XML}: expected exactly one <change-notes> block, found {count}")
    PLUGIN_XML.write_text(patched)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "--placeholder",
        action="store_true",
        help="put the placeholders back, the state the repository keeps",
    )
    group.add_argument(
        "--release-notes",
        metavar="VERSION",
        help="print that release's section on stdout instead of writing anything",
    )
    args = parser.parse_args()

    if args.release_notes:
        for release in parse(SOURCE.read_text()):
            if release.version == args.release_notes:
                print(release.body)
                return
        sys.exit(f"{SOURCE}: no section for {args.release_notes}")

    if args.placeholder:
        write_vscode(PLACEHOLDER_MARKDOWN)
        write_change_notes(PLACEHOLDER_NOTES)
    else:
        source = SOURCE.read_text()
        write_vscode(source)
        write_change_notes(change_notes(parse(source)))

    for path in (VSCODE, PLUGIN_XML):
        print(path.relative_to(ROOT))


if __name__ == "__main__":
    main()
