#!/usr/bin/env python3
"""Turn loccount's static language report into an interactive web page."""

from __future__ import annotations

import argparse
from datetime import datetime, timezone
import html
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Sequence

#
HEADER_TEXT_HTML: Final[
    str
] = """\
<p>This is a searchable version of the quirks database used by
<a href="https://gitlab.com/esr/loccount/">loccount</a>, the
lines-of-code counter with the most comprehensive coverage.</p>

<p>The default view is a list of languages. Click on a language name to view
details about it, including a summary description and reference links.</p>
"""
FOOTER_TEXT_HTML: Final[
    str
] = """
<p class="webify-footer-text">Issued under CC0. Last updated: <time datetime="%s">%s</time></p>

<p class="webify-footer-text">Found an error or omission in this database? <a href="https://gitlab.com/esr/loccount/-/work_items">Report it!</a></p>
"""
POLICY_TEXT = """
We don't want to try carrying every single language and markup that
has ever existed. Table entries are cheap, but language names and
especially file extensions are a finite and rivalrous resource; what
we do will be seen by AIs, and we we want to avoid giving junk
languages an implicit claim on them.

The largest category of junk is esolangs. There are many hundreds
of these, threatening serious namespace pollution. We carry exactly
one, INTERCAL, because it is historically notable as the first
esolang. Beyond this we have to take a hard line:  do not send
us patches to support esolangs, they will be rejected.

Academic toy languages are also frowned upon. We filter them out
unless they're historically notable for having influenced production
languages. We believe we have complete coverage of the influential ones
already. If you're working on a research language, get to release
1.0 and build an active user community at multiple institutions,
then we can talk.

The most difficult category of judgment calls is modern proprietary
languages. We're distinguishing these from historical languages that
were built in closed source back in the twentieth century before open
source was a thing; those we have pretty complete coverage of clear
back to Algol-60.

The problem with modern proprietary languages is that while there
are a handful of ones sufficiently widespread and notable that any
respectable source-forensics tool should recognize them, there's
a long tail of niche languages and appalling junk that shouldn't
get any future claim on the namespaces.

We considered not carrying any of these at all, because "open source"
is a defensible bright-line criterion. We have not been quite that
exclusive, but the bar against future inclusions is high.
"""

HEAD_CLOSE_RE: Final[re.Pattern[str]] = re.compile(r"</head>", re.IGNORECASE)
BODY_OPEN_RE: Final[re.Pattern[str]] = re.compile(
    r"<body(?P<attrs>[^>]*)>", re.IGNORECASE
)
BODY_CLOSE_RE: Final[re.Pattern[str]] = re.compile(r"</body>", re.IGNORECASE)
WEBIFY_STYLE: Final[str] = (
    """
<style>
html, body {
  margin: 0;
  height: 100%;
}

body {
  background: #f4efe6;
  color: #1f1b16;
  font-family: Georgia, "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
}

.webify-page {
  display: grid;
  grid-template-rows: auto auto minmax(0, 1fr) auto;
  height: 100vh;
}

.webify-header-box,
.webify-panel,
.webify-footer {
  border-bottom: 1px solid #cbbca7;
  background: linear-gradient(180deg, #f9f5ee 0%, #efe6d7 100%);
  box-shadow: 0 1px 0 rgba(41, 31, 18, 0.08);
}

.webify-header-box {
  padding: 0.85rem 1rem;
}

.webify-header-text,
.webify-footer-text {
  color: #3f3428;
  line-height: 1.45;
  margin: 0 auto;
  max-width: 74rem;
}

.webify-panel-inner {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
  padding: 0.85rem 1rem;
  margin: 0 auto;
  max-width: 74rem;
}

.webify-mode-toggle,
.webify-policy-toggle,
.webify-clear-filter {
  appearance: none;
  border: 1px solid #7f6a50;
  background: #fffaf1;
  color: #2d241a;
  border-radius: 999px;
  cursor: pointer;
  font: inherit;
  padding: 0.45rem 0.9rem;
}

.webify-mode-toggle:hover,
.webify-policy-toggle:hover,
.webify-clear-filter:hover {
  background: #fff;
}

.webify-filter-group {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  font-size: 0.95rem;
}

.webify-filter-label {
  font-weight: 600;
  letter-spacing: 0.01em;
}

.webify-filter-input {
  min-width: 18rem;
  max-width: 32rem;
  width: min(50vw, 32rem);
  border: 1px solid #9c8a71;
  border-radius: 999px;
  background: #fff;
  color: inherit;
  font: inherit;
  padding: 0.5rem 0.85rem;
}

.webify-filter-input:focus {
  outline: 2px solid #ab5b16;
  outline-offset: 1px;
}

.webify-status {
  color: #5a4b38;
  font-size: 0.92rem;
  margin-left: auto;
  white-space: nowrap;
}

.webify-scroll {
  overflow: auto;
  padding: 1rem;
}

.webify-footer {
  border-bottom: 0;
  border-top: 1px solid #cbbca7;
  padding: 0.7rem 1rem;
}

.language-report {
  display: grid;
  gap: 0.9rem;
  margin: 0 auto;
  max-width: 74rem;
}

.language-report[data-webify-mode="names"] {
  gap: 0.15rem;
}

.language-entry {
  background: rgba(255, 252, 247, 0.98);
  border: 1px solid #d8cab6;
  border-radius: 0.75rem;
  box-shadow: 0 8px 24px rgba(64, 46, 24, 0.06);
  padding: 1rem 1.1rem;
}

.language-header {
  margin-bottom: 0.35rem;
}

.language-name {
  font-family: "Avenir Next Condensed", "Gill Sans", "Trebuchet MS", sans-serif;
  font-size: 1.45rem;
  letter-spacing: 0.02em;
  line-height: 1.05;
  margin: 0;
}

.language-name-toggle {
  appearance: none;
  background: none;
  border: 0;
  color: inherit;
  cursor: pointer;
  font: inherit;
  letter-spacing: inherit;
  line-height: inherit;
  padding: 0;
  text-align: left;
  width: 100%;
}

.language-name-toggle:hover {
  color: #7a2b11;
}

.language-details[hidden] {
  display: none !important;
}

.language-description-paragraph,
.language-link-value,
.language-trait {
  line-height: 1.45;
}

.language-links {
  column-gap: 0.6rem;
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  margin: 0.9rem 0;
  row-gap: 0.2rem;
}

.language-link-label {
  font-weight: 700;
}

.language-link-value {
  margin: 0;
}

.language-link {
  color: #7a2b11;
  text-decoration-thickness: 0.08em;
}

.language-variants {
  display: grid;
  gap: 0.75rem;
}

.language-variant {
  border-top: 1px solid #e3d8c8;
  padding-top: 0.75rem;
}

.language-variant-name {
  font-family: "Avenir Next Condensed", "Gill Sans", "Trebuchet MS", sans-serif;
  font-size: 1.05rem;
  letter-spacing: 0.04em;
  margin: 0 0 0.45rem 0;
  text-transform: uppercase;
}

.language-traits {
  margin: 0;
  padding-left: 1.25rem;
}

.language-entry[hidden] {
  display: none !important;
}

.webify-policy-dialog {
  background: #fffaf1;
  border: 1px solid #7f6a50;
  border-radius: 0.85rem;
  box-shadow: 0 24px 70px rgba(32, 24, 15, 0.28);
  color: #1f1b16;
  max-height: min(82vh, 48rem);
  max-width: 46rem;
  padding: 0;
  width: calc(100vw - 2rem);
}

.webify-policy-dialog::backdrop {
  background: rgba(31, 27, 22, 0.42);
}

.webify-policy-card {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  max-height: inherit;
}

.webify-policy-header {
  align-items: center;
  background: linear-gradient(180deg, #f9f5ee 0%, #efe6d7 100%);
  border-bottom: 1px solid #d8cab6;
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  padding: 0.9rem 1rem;
}

.webify-policy-title {
  font-family: "Avenir Next Condensed", "Gill Sans", "Trebuchet MS", sans-serif;
  font-size: 1.35rem;
  letter-spacing: 0.03em;
  margin: 0;
}

.webify-policy-close {
  appearance: none;
  background: #fffaf1;
  border: 1px solid #7f6a50;
  border-radius: 999px;
  color: inherit;
  cursor: pointer;
  font: inherit;
  padding: 0.35rem 0.75rem;
}

.webify-policy-close:hover {
  background: #fff;
}

.webify-policy-body {
  overflow: auto;
  padding: 1rem 1.15rem 1.2rem;
}

.webify-policy-paragraph {
  line-height: 1.5;
  margin: 0 0 0.9rem;
}

.webify-policy-paragraph:last-child {
  margin-bottom: 0;
}

.language-report[data-webify-mode="names"] .language-entry {
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  padding: 0;
}

.language-report[data-webify-mode="names"] .language-entry[data-webify-expanded="true"] {
  background: rgba(255, 252, 247, 0.98);
  border: 1px solid #d8cab6;
  border-radius: 0.75rem;
  box-shadow: 0 8px 24px rgba(64, 46, 24, 0.06);
  padding: 1rem 1.1rem;
}

.language-report[data-webify-mode="names"] .language-entry[data-webify-expanded="false"] .language-header {
  margin-bottom: 0;
}

@media (max-width: 720px) {
  .webify-panel-inner {
    align-items: stretch;
    flex-direction: column;
  }

  .webify-filter-group {
    flex-direction: column;
    align-items: stretch;
  }

  .webify-filter-input {
    max-width: none;
    min-width: 0;
    width: 100%;
  }

  .webify-status {
    margin-left: 0;
  }
}
</style>
""".strip()
)

WEBIFY_SCRIPT: Final[str] = (
    """
<script>
document.addEventListener("DOMContentLoaded", () => {
  const root = document.querySelector(".webify-page");
  const report = document.querySelector(".language-report");
  if (!root || !report) {
    return;
  }

  const entries = Array.from(report.querySelectorAll(".language-entry"));
  const modeToggle = root.querySelector(".webify-mode-toggle");
  const policyToggle = root.querySelector(".webify-policy-toggle");
  const policyDialog = root.querySelector(".webify-policy-dialog");
  const policyClose = root.querySelector(".webify-policy-close");
  const filterInput = root.querySelector(".webify-filter-input");
  const clearFilter = root.querySelector(".webify-clear-filter");
  const status = root.querySelector(".webify-status");

  let currentMode = "names";

  function ensureEntryStructure(entry, index) {
    const header = entry.querySelector(".language-header");
    const heading = header?.querySelector(".language-name");
    if (!header || !heading) {
      return;
    }

    const entryId = entry.id || `language-entry-${index + 1}`;
    entry.id = entryId;
    entry.dataset.webifyExpanded = "false";

    const details = document.createElement("div");
    details.className = "language-details";
    details.id = `${entryId}-details`;

    for (const child of Array.from(entry.children)) {
      if (child !== header) {
        details.appendChild(child);
      }
    }
    entry.appendChild(details);

    const button = document.createElement("button");
    button.type = "button";
    button.className = "language-name-toggle";
    button.textContent = (heading.textContent || entry.dataset.name || "").trim();
    button.setAttribute("aria-controls", details.id);
    button.setAttribute("aria-expanded", "false");
    button.addEventListener("click", () => {
      if (currentMode === "full") {
        return;
      }
      setEntryExpanded(entry, entry.dataset.webifyExpanded !== "true");
    });

    heading.textContent = "";
    heading.appendChild(button);
  }

  function setEntryExpanded(entry, expanded) {
    entry.dataset.webifyExpanded = expanded ? "true" : "false";
    const details = entry.querySelector(".language-details");
    const button = entry.querySelector(".language-name-toggle");
    const showDetails = currentMode === "full" || expanded;
    if (details) {
      details.hidden = !showDetails;
    }
    if (button) {
      button.setAttribute("aria-expanded", showDetails ? "true" : "false");
    }
  }

  function setMode(mode) {
    currentMode = mode;
    const fullMode = mode === "full";
    report.dataset.webifyMode = mode;
    modeToggle.textContent = fullMode ? "Names Only" : "Show Details";
    modeToggle.setAttribute("aria-pressed", fullMode ? "true" : "false");
    for (const entry of entries) {
      setEntryExpanded(entry, entry.dataset.webifyExpanded === "true");
    }
  }

  function searchableText(entry) {
    return `${entry.dataset.name || ""} ${entry.dataset.aliases || ""}`.toLowerCase();
  }

  function updateStatus(visibleCount, query) {
    const total = entries.length;
    if (query) {
      status.textContent = `${visibleCount} of ${total} languages`;
    } else {
      status.textContent = `${total} languages`;
    }
  }

  function applyFilter() {
    const query = filterInput.value.trim().toLowerCase();
    let visibleCount = 0;
    for (const entry of entries) {
      const matches = query === "" || searchableText(entry).includes(query);
      entry.hidden = !matches;
      if (matches) {
        visibleCount += 1;
      }
    }
    updateStatus(visibleCount, query);
  }

  modeToggle.addEventListener("click", () => {
    setMode(currentMode === "names" ? "full" : "names");
  });

  filterInput.addEventListener("input", () => {
    applyFilter();
  });

  clearFilter.addEventListener("click", () => {
    filterInput.value = "";
    filterInput.focus();
    applyFilter();
  });

  function openPolicyDialog() {
    if (!policyDialog) {
      return;
    }
    if (typeof policyDialog.showModal === "function") {
      policyDialog.showModal();
    } else {
      policyDialog.setAttribute("open", "");
    }
  }

  function closePolicyDialog() {
    if (!policyDialog) {
      return;
    }
    if (typeof policyDialog.close === "function") {
      policyDialog.close();
    } else {
      policyDialog.removeAttribute("open");
    }
  }

  if (policyToggle && policyDialog) {
    policyToggle.addEventListener("click", openPolicyDialog);
    policyDialog.addEventListener("click", (event) => {
      if (event.target === policyDialog) {
        closePolicyDialog();
      }
    });
  }

  if (policyClose) {
    policyClose.addEventListener("click", closePolicyDialog);
  }

  entries.forEach((entry, index) => {
    ensureEntryStructure(entry, index);
  });
  setMode("names");
  applyFilter();
});
</script>
""".strip()
)


@dataclass(frozen=True)
class WebifyOptions:
    input_path: str | None
    output_path: str | None


def parse_args(argv: Sequence[str]) -> WebifyOptions:
    parser = argparse.ArgumentParser(
        prog="webify",
        description="Inject interactive controls into loccount's HTML language report.",
    )
    parser.add_argument(
        "input",
        nargs="?",
        help="Input HTML file. Use '-' or omit to read from stdin.",
    )
    parser.add_argument(
        "-o",
        "--output",
        help="Output file. Defaults to stdout.",
    )
    ns = parser.parse_args(argv)
    return WebifyOptions(input_path=ns.input, output_path=ns.output)


def read_html(path: str | None) -> str:
    if path is None or path == "-":
        return sys.stdin.read()
    return Path(path).read_text(encoding="utf-8")


def write_html(path: str | None, content: str) -> None:
    if path is None:
        sys.stdout.write(content)
        return
    Path(path).write_text(content, encoding="utf-8")


def render_timestamp() -> str:
    return (
        datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
    )


def render_policy_dialog() -> str:
    paragraphs = [
        paragraph.strip()
        for paragraph in re.split(r"\n\s*\n", POLICY_TEXT.strip())
        if paragraph.strip()
    ]
    body = "\n".join(
        f'      <p class="webify-policy-paragraph">{html.escape(paragraph)}</p>'
        for paragraph in paragraphs
    )
    return (
        '  <dialog class="webify-policy-dialog" aria-labelledby="webify-policy-title">\n'
        '    <section class="webify-policy-card">\n'
        '      <header class="webify-policy-header">\n'
        '        <h2 id="webify-policy-title" class="webify-policy-title">Inclusion Policy</h2>\n'
        '        <button type="button" class="webify-policy-close" aria-label="Close inclusion policy">Close</button>\n'
        "      </header>\n"
        '      <div class="webify-policy-body">\n'
        + body
        + "\n      </div>\n"
        "    </section>\n"
        "  </dialog>"
    )


def render_control_panel() -> str:
    return (
        '<div class="webify-page">\n'
        '  <div class="webify-header-box">\n'
        '    <div class="webify-header-text">' + HEADER_TEXT_HTML + "</div>\n"
        "  </div>\n"
        '  <header class="webify-panel">\n'
        '    <div class="webify-panel-inner">\n'
        '      <button type="button" class="webify-mode-toggle" aria-pressed="false">Show Details</button>\n'
        '      <button type="button" class="webify-policy-toggle" aria-haspopup="dialog">Inclusion Policy</button>\n'
        '      <div class="webify-filter-group">\n'
        '        <span class="webify-filter-label">Filter</span>\n'
        '        <input type="search" class="webify-filter-input" placeholder="Type a language name or alias" spellcheck="false" />\n'
        '        <button type="button" class="webify-clear-filter">Clear</button>\n'
        "      </div>\n"
        '      <output class="webify-status" aria-live="polite"></output>\n'
        "    </div>\n"
        "  </header>\n"
        + render_policy_dialog()
        + "\n"
        '  <div class="webify-scroll">'
    )


def render_body_close_wrapper() -> str:
    timestamp = render_timestamp()
    escaped_timestamp = html.escape(timestamp)
    return (
        "  </div>\n"
        '  <footer class="webify-footer">\n'
        + (FOOTER_TEXT_HTML % (escaped_timestamp, escaped_timestamp))
        + "  </footer>\n"
        "</div>"
    )


def inject_head(document: str) -> str:
    replacement = "\n" + WEBIFY_STYLE + "\n" + WEBIFY_SCRIPT + "\n</head>"
    updated, count = HEAD_CLOSE_RE.subn(replacement, document, count=1)
    if count != 1:
        raise ValueError("input HTML does not contain a closing </head> tag")
    return updated


def inject_body(document: str) -> str:
    updated, count = BODY_OPEN_RE.subn(
        lambda match: f"<body{match.group('attrs')}>\n{render_control_panel()}\n",
        document,
        count=1,
    )
    if count != 1:
        raise ValueError("input HTML does not contain an opening <body> tag")

    updated, count = BODY_CLOSE_RE.subn(
        "\n" + render_body_close_wrapper() + "\n</body>",
        updated,
        count=1,
    )
    if count != 1:
        raise ValueError("input HTML does not contain a closing </body> tag")
    return updated


def transform(document: str) -> str:
    if 'class="webify-page"' in document:
        raise ValueError("input already appears to have been processed by webify")
    updated = inject_head(document)
    updated = inject_body(updated)
    return updated


def main(argv: Sequence[str]) -> int:
    options = parse_args(argv)
    try:
        source = read_html(options.input_path)
        if not source.strip():
            raise ValueError(
                "no input HTML supplied; pipe loccount -W into webify or pass a file"
            )
        output = transform(source)
        write_html(options.output_path, output)
    except (OSError, ValueError) as exc:
        print(f"webify: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
