#!/usr/bin/env python3
"""Render the cover letter from a YAML source into HTML (print layout) + Markdown.

    python3 profile/cover-letter-build.py                  # default source
    python3 profile/cover-letter-build.py --data applications/cleo/cover-letter.yaml
    python3 profile/cover-letter-build.py --check          # outputs up to date?

Default source is profile/cover-letter.yaml; outputs land next to the source as
cover-letter.html / cover-letter.md. The HTML feeds build-cover-letter-pdf.sh.
Both outputs are generated files: edit the YAML, never the outputs.

Two visual styles (YAML key `style`):
    plain      base system font, no headings, no footer - a clean letter body
               with a minimal plain-text header (name, role, contacts).
    editorial  the designed version: Inter, one accent colour, "Cover Letter"
               title, a restrained "What I bring" block and a footer colophon.
"""
from __future__ import annotations

import argparse
import datetime as dt
import html
import os
import re
from pathlib import Path

import yaml

ROOT = Path(__file__).resolve().parent
DEFAULT_DATA = ROOT / "cover-letter.yaml"
FONT_FILE = ROOT / "assets" / "fonts" / "Inter-Variable.ttf"

# Plain-ASCII normalisation for the Markdown mirror (HTML keeps typography).
ASCII_MAP = {
    "\u2014": "-",
    "\u2013": "-",
    "\u2192": "->",
    "\u2026": "...",
    "\u00a0": " ",
    "\u00b7": "|",
    "\u2018": "'", "\u2019": "'",
    "\u201c": '"', "\u201d": '"',
}

# Build time is injected by build-cover-letter-pdf.sh (invisible in the PDF,
# visible in the page source). Kept out of the printed footer on purpose: a
# sent letter should not carry a build stamp.
GEN_STAMP = "__GEN_STAMP__"

MONTHS = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
]

CSS_EDITORIAL = """\
  /* ============ PAGE ============ */
  @page { size: A4; margin: 0; }
  * { box-sizing: border-box; }
  :root { --accent: {accent}; }
  html, body {
    margin: 0; padding: 0;
    background: #ffffff;
    color: #14161a;
    font-family: "Inter", "Helvetica Neue", Helvetica, Arial, "DejaVu Sans", sans-serif;
    -webkit-font-smoothing: antialiased;
    text-rendering: geometricPrecision;
    font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
  }
  @font-face {
    font-family: "Inter";
    src: url("__FONT_URL__") format("truetype");
    font-weight: 100 900;
    font-style: normal;
    font-display: block;
  }

  /* A4 sheet: 210x297mm, ~64px side margins, footer pinned to the bottom. */
  .sheet {
    width: 210mm;
    min-height: 297mm;
    padding: 58px 65px 40px;
    margin: 0 auto;
    display: flex;
    flex-direction: column;
  }
  .sheet > * { flex: 0 0 auto; }

  /* ============ PERSONAL HEADER ============ */
  .masthead-name {
    margin: 0;
    font-size: 30px;
    font-weight: 640;
    line-height: 1.08;
    letter-spacing: -0.021em;
  }
  .masthead-role {
    margin: 6px 0 0;
    font-size: 13px;
    font-weight: 560;
    letter-spacing: 0.015em;
    color: #5b6270;
  }
  .masthead-contacts {
    margin: 14px 0 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    column-gap: 10px;
    row-gap: 3px;
    font-size: 12px;
    letter-spacing: 0.005em;
    color: #6b7280;
  }
  .masthead-contacts li { display: flex; align-items: baseline; column-gap: 10px; }
  .masthead-contacts li + li::before { content: "\\00b7"; color: #c9cdd4; }
  .masthead-contacts a { color: inherit; text-decoration: none; }
  /* Hairline + short accent segment. Drawn with borders, not backgrounds:
     background graphics are dropped by default when printing to PDF
     (Playwright's printBackground defaults to false). */
  .masthead-rule {
    position: relative;
    height: 0;
    margin-top: 19px;
    border-top: 1px solid #e7e9ed;
  }
  .masthead-rule::before {
    content: "";
    position: absolute;
    left: 0; top: -1px;
    width: 46px;
    border-top: 2px solid var(--accent);
  }

  /* ============ DOCUMENT TITLE ============ */
  .doc-title {
    margin: 30px 0 0;
    font-size: 22px;
    font-weight: 620;
    letter-spacing: -0.012em;
    line-height: 1.2;
  }
  .doc-meta {
    margin: 9px 0 0;
    font-size: 12px;
    letter-spacing: 0.01em;
    color: #6b7280;
    display: flex;
    flex-wrap: wrap;
    column-gap: 9px;
  }
  .doc-meta .subject { color: #14161a; font-weight: 560; }

  /* ============ RECIPIENT (optional) ============ */
  .recipient {
    margin: 26px 0 0;
    font-size: 13px;
    line-height: 1.5;
    color: #5b6270;
  }
  .recipient p { margin: 0; }
  .recipient p:first-child { color: #14161a; font-weight: 560; }

  /* ============ LETTER BODY ============ */
  .salutation { margin: 26px 0 0; font-size: 15.5px; line-height: 1.52; }
  .body p {
    margin: 13px 0 0;
    font-size: 15.5px;
    line-height: 1.52;
    color: #1c1f26;
    text-align: left;
    hyphens: none;
  }
  .body p:first-child { margin-top: 16px; }

  /* ============ HIGHLIGHT (the only accent block) ============ */
  .highlight {
    margin: 21px 0 2px;
    padding: 3px 0 3px 18px;
    border-left: 2px solid var(--accent);
    page-break-inside: avoid;
    break-inside: avoid;
  }
  .highlight .highlight-label {
    margin: 0;
    font-size: 10.5px;
    font-weight: 660;
    letter-spacing: 0.15em;
    text-transform: uppercase;
    color: var(--accent);
  }
  .highlight-list { margin: 9px 0 0; padding: 0; list-style: none; }
  .highlight-list li {
    font-size: 13.5px;
    line-height: 1.5;
    color: #4b515c;
  }
  .highlight-list li:first-child { color: #14161a; font-weight: 560; }

  /* ============ CLOSING ============ */
  .signoff { margin: 22px 0 0; font-size: 15.5px; line-height: 1.52; color: #1c1f26; }
  .signature { margin: 7px 0 0; font-size: 15.5px; font-weight: 620; letter-spacing: -0.005em; }

  /* ============ FOOTER ============ */
  .colophon {
    margin-top: auto;
    padding-top: 14px;
    border-top: 1px solid #e7e9ed;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    column-gap: 10px;
    row-gap: 4px;
    font-size: 10px;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: #8a9099;
  }
  .colophon .name { color: #4b515c; font-weight: 620; }
  .colophon .descriptor { margin-left: auto; text-align: right; }
"""

CSS_PLAIN = """\
  /* ============ PLAIN STYLE: base font, no headings, no footer ============ */
  @page { size: A4; margin: 0; }
  * { box-sizing: border-box; }
  html, body {
    margin: 0; padding: 0;
    background: #ffffff;
    color: #000000;
    font-family: sans-serif;      /* base system font, no custom typeface */
    font-size: 15px;
    line-height: 1.45;
  }
  .sheet { padding: 16mm 20mm; }

  /* Minimal plain-text header: name, role and contacts as ordinary text. */
  .sender p { margin: 0; }
  .sender .name { font-weight: 700; }
  .sender .role { margin-top: 2px; }
  .sender .contacts { margin-top: 4px; }

  .meta { margin-top: 16px; }
  .meta p { margin: 0; }
  .meta p + p { margin-top: 2px; }

  .recipient { margin-top: 14px; }
  .recipient p { margin: 0; }

  .salutation { margin-top: 16px; }

  .body p { margin: 9px 0 0; }
  .highlight { margin-top: 12px; }
  .highlight p { margin: 0 0 3px 0; }
  .highlight .highlight-label { font-weight: 700; }

  .signoff { margin-top: 18px; }
  .signature { margin-top: 2px; }
"""

HTML_EDITORIAL = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{name} - Cover Letter</title>
<meta name="generator" content="cover-letter-build.py; built {stamp}">
<meta name="document-style" content="editorial">
<!--
  GENERATED FILE - do not edit. Source: {source}
  Regenerate: python3 profile/cover-letter-build.py
  PDF:        bash profile/build-cover-letter-pdf.sh

  Design: minimal technical editorial. A4, ~64px margins, Inter, near-black
  body, neutral gray metadata, ONE accent colour used only for the short
  header rule and the highlight label. No cards, icons, sidebars or photos.
-->
<style>
{css}</style>
</head>
<body>
<div class="sheet">

  <!-- ============ PERSONAL HEADER ============ -->
  <header class="masthead">
    <h1 class="masthead-name">{name}</h1>
    <p class="masthead-role">{position}</p>
    <ul class="masthead-contacts">
{contacts}
    </ul>
    <div class="masthead-rule"></div>
  </header>

  <!-- ============ DOCUMENT TITLE ============ -->
  <h2 class="doc-title">{doc_title}</h2>
  <div class="doc-meta">{doc_meta}</div>
{recipient}
  <!-- ============ LETTER ============ -->
  <p class="salutation">{salutation}</p>
  <div class="body">
{body}
  </div>
{closing}
  <!-- ============ FOOTER ============ -->
  <footer class="colophon">
    <span class="name">{name}</span>
    <span class="position">{position}</span>
    <span class="descriptor">{descriptor}</span>
  </footer>

</div>
</body>
</html>
"""

HTML_PLAIN = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{name} - Cover Letter</title>
<meta name="generator" content="cover-letter-build.py; built {stamp}">
<meta name="document-style" content="plain">
<!--
  GENERATED FILE - do not edit. Source: {source}
  Regenerate: python3 profile/cover-letter-build.py
  PDF:        bash profile/build-cover-letter-pdf.sh

  Plain style: base system font, no headings, no footer.
-->
<style>
{css}</style>
</head>
<body>
<div class="sheet">

  <div class="sender">
    <p class="name">{name}</p>
    <p class="role">{position}</p>
    <p class="contacts">{contacts}</p>
  </div>

  <div class="meta">
    <p>{date}</p>
{subject_line}
  </div>
{recipient}
  <p class="salutation">{salutation}</p>

  <div class="body">
{body}
  </div>
{closing}
</div>
</body>
</html>
"""


def plain(text: str) -> str:
    """Typographic characters -> plain ASCII (Markdown mirror only)."""
    for src, dst in ASCII_MAP.items():
        text = text.replace(src, dst)
    return text


def esc(text: object) -> str:
    return html.escape(str(text))


def as_text(value: object, where: str) -> str:
    """YAML data must be plain strings; guard against accidental maps/lists.

    A bare YAML scalar containing ": " is silently parsed as a mapping, which
    used to end up in the PDF as a Python dict repr. Fail loudly instead.
    """
    if isinstance(value, str):
        return value
    if isinstance(value, (dict, list)):
        raise SystemExit(
            f"ERROR: {where} must be a string, got {type(value).__name__}: {value!r}\n"
            '       Hint: in YAML, wrap the text in quotes when it contains ": ".'
        )
    return str(value)


def fill(text: object, ctx: dict) -> str:
    """Substitute {{placeholders}} from the context map."""
    out = str(text)
    for key, value in ctx.items():
        out = out.replace("{{" + key + "}}", str(value))
    return out


def inline(text: object, ctx: dict, *, md: bool = False) -> str:
    """Render **bold** for the target format, escaping HTML when needed."""
    value = fill(text, ctx)
    if md:
        return plain(value)
    return re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html.escape(value))


def format_date(value: str) -> str:
    if value:
        return str(value)
    today = dt.date.today()
    return f"{today.day} {MONTHS[today.month - 1]} {today.year}"


def _ctx(doc: dict) -> dict:
    ctx = dict(doc.get("context") or {})
    ctx.setdefault("date", format_date((doc.get("letter") or {}).get("date", "")))
    return ctx


def render_html(doc: dict, out_dir: Path | None = None) -> str:
    """Dispatch to the plain or editorial renderer based on `style`."""
    style = str(doc.get("style") or "plain").strip().lower()
    if style == "editorial":
        return _render_editorial(doc, out_dir)
    return _render_plain(doc)


def _render_editorial(doc: dict, out_dir: Path | None) -> str:
    ctx = _ctx(doc)

    sender = doc["sender"]
    doc_title = (doc.get("document") or {}).get("title", "Cover Letter")
    letter = doc["letter"]
    footer = doc.get("footer") or {}

    # The generated HTML may live outside profile/, so point at the font
    # relatively: it keeps the file portable and works from any output dir.
    font_url = os.path.relpath(FONT_FILE, out_dir or ROOT).replace(os.sep, "/")

    contacts = []
    for c in sender["contacts"]:
        label = esc(fill(as_text(c["label"], "sender.contacts[].label"), ctx))
        if c.get("href"):
            contacts.append(f'      <li><a href="{esc(c["href"])}">{label}</a></li>')
        else:
            contacts.append(f"      <li>{label}</li>")
    contacts_html = "\n".join(contacts)

    meta_parts = [f'<span class="date">{esc(ctx["date"])}</span>']
    if letter.get("subject"):
        meta_parts.append('<span class="sep">·</span>')
        meta_parts.append(f'<span class="subject">Re: {esc(fill(letter["subject"], ctx))}</span>')
    doc_meta = "".join(meta_parts)

    recipient_html = ""
    if letter.get("recipient"):
        lines = "\n".join(
            f"    <p>{inline(as_text(line, f'letter.recipient[{i}]'), ctx)}</p>"
            for i, line in enumerate(letter["recipient"], start=1)
        )
        recipient_html = f'\n  <div class="recipient">\n{lines}\n  </div>'

    paragraphs = [
        as_text(p, f"letter.paragraphs[{i}]")
        for i, p in enumerate(letter.get("paragraphs") or [], start=1)
    ]
    highlight = letter.get("highlight") or {}
    after = int(highlight.get("after", 0) or 0)
    if after <= 0 or after > len(paragraphs):
        after = len(paragraphs)

    highlight_html = ""
    if highlight.get("lines"):
        items = "\n".join(
            f"      <li>{inline(as_text(line, f'highlight.lines[{i}]'), ctx)}</li>"
            for i, line in enumerate(highlight["lines"], start=1)
        )
        highlight_html = (
            '  <aside class="highlight">\n'
            f'    <p class="highlight-label">{esc(fill(highlight.get("label", ""), ctx))}</p>\n'
            '    <ul class="highlight-list">\n'
            f"{items}\n"
            "    </ul>\n"
            "  </aside>\n"
        )

    body_parts: list[str] = []
    for index, para in enumerate(paragraphs, start=1):
        body_parts.append(f"    <p>{inline(para, ctx)}</p>")
        if index == after and highlight_html:
            body_parts.append(highlight_html.rstrip("\n"))

    body_html = "\n".join(part for part in body_parts if part)

    closing_html = ""
    if letter.get("signoff") or letter.get("signature"):
        closing_html = "\n"
        if letter.get("signoff"):
            closing_html += f'  <p class="signoff">{inline(letter["signoff"], ctx)}</p>\n'
        if letter.get("signature"):
            closing_html += f'  <p class="signature">{inline(letter["signature"], ctx)}</p>\n'

    return HTML_EDITORIAL.format(
        css=CSS_EDITORIAL.replace("{accent}", str(doc.get("accent", "#DC2020"))).replace(
            "__FONT_URL__", font_url
        ),
        name=esc(sender["name"]),
        position=esc(sender["position"]),
        contacts=contacts_html,
        doc_title=esc(doc_title),
        doc_meta=doc_meta,
        recipient=recipient_html,
        salutation=inline(letter.get("salutation", ""), ctx),
        body=body_html,
        closing=closing_html,
        descriptor=esc(fill(footer.get("descriptor", ""), ctx)),
        stamp=GEN_STAMP,
        source=esc(doc.get("_source", DEFAULT_DATA.relative_to(ROOT.parent))),
    )


def _render_plain(doc: dict) -> str:
    ctx = _ctx(doc)

    sender = doc["sender"]
    letter = doc["letter"]

    contacts = " · ".join(
        fill(as_text(c["label"], "sender.contacts[].label"), ctx)
        for c in sender["contacts"]
    )

    subject_line = ""
    if letter.get("subject"):
        subject_line = f'    <p>Re: {esc(fill(letter["subject"], ctx))}</p>'

    recipient_html = ""
    if letter.get("recipient"):
        lines = "\n".join(
            f'    <p>{inline(as_text(line, f"letter.recipient[{i}]"), ctx)}</p>'
            for i, line in enumerate(letter["recipient"], start=1)
        )
        recipient_html = f'\n  <div class="recipient">\n{lines}\n  </div>'

    paragraphs = [
        as_text(p, f"letter.paragraphs[{i}]")
        for i, p in enumerate(letter.get("paragraphs") or [], start=1)
    ]
    highlight = letter.get("highlight") or {}
    after = int(highlight.get("after", 0) or 0)
    if after <= 0 or after > len(paragraphs):
        after = len(paragraphs)

    highlight_html = ""
    if highlight.get("lines"):
        label = fill(highlight.get("label", ""), ctx)
        parts = ['  <div class="highlight">']
        if label:
            parts.append(f'    <p class="highlight-label">{esc(label)}</p>')
        parts += [
            f'    <p>{inline(as_text(line, f"highlight.lines[{i}]"), ctx)}</p>'
            for i, line in enumerate(highlight["lines"], start=1)
        ]
        parts.append("  </div>")
        highlight_html = "\n".join(parts)

    body_parts: list[str] = []
    for index, para in enumerate(paragraphs, start=1):
        body_parts.append(f"    <p>{inline(para, ctx)}</p>")
        if index == after and highlight_html:
            body_parts.append(highlight_html)

    body_html = "\n".join(part for part in body_parts if part)

    closing_html = ""
    if letter.get("signoff") or letter.get("signature"):
        closing_html = "\n"
        if letter.get("signoff"):
            closing_html += f'  <p class="signoff">{inline(letter["signoff"], ctx)}</p>\n'
        if letter.get("signature"):
            closing_html += f'  <p class="signature">{inline(letter["signature"], ctx)}</p>\n'

    return HTML_PLAIN.format(
        css=CSS_PLAIN,
        name=esc(sender["name"]),
        position=esc(sender["position"]),
        contacts=esc(contacts),
        date=esc(ctx["date"]),
        subject_line=subject_line,
        recipient=recipient_html,
        salutation=inline(letter.get("salutation", ""), ctx),
        body=body_html,
        closing=closing_html,
        stamp=GEN_STAMP,
        source=esc(doc.get("_source", DEFAULT_DATA.relative_to(ROOT.parent))),
    )


def render_md(doc: dict) -> str:
    ctx = _ctx(doc)
    style = str(doc.get("style") or "plain").strip().lower()

    sender = doc["sender"]
    doc_title = (doc.get("document") or {}).get("title", "Cover Letter")
    letter = doc["letter"]
    footer = doc.get("footer") or {}

    out: list[str] = [
        "<!-- GENERATED FILE - do not edit.",
        f"     Source: {doc.get('_source', DEFAULT_DATA.relative_to(ROOT.parent))} (cover-letter-build.py)",
        "     PDF:    bash profile/build-cover-letter-pdf.sh -->",
        "",
        plain(sender["name"]),
        plain(sender["position"]),
        " | ".join(plain(fill(c["label"], ctx)) for c in sender["contacts"]),
        "",
    ]
    if style == "editorial":
        out.append(f"{plain(doc_title)} - {plain(ctx['date'])}")
    else:
        out.append(plain(ctx["date"]))

    if letter.get("subject"):
        out.append(f"Re: {plain(fill(letter['subject'], ctx))}")

    if letter.get("recipient"):
        out.append("")
        out += [
            plain(fill(as_text(line, f"letter.recipient[{i}]"), ctx))
            for i, line in enumerate(letter["recipient"], start=1)
        ]

    out += ["", plain(fill(letter.get("salutation", ""), ctx)), ""]
    for i, para in enumerate(letter.get("paragraphs") or [], start=1):
        out += [plain(fill(as_text(para, f"letter.paragraphs[{i}]"), ctx)), ""]

    highlight = letter.get("highlight") or {}
    if highlight.get("lines"):
        out += [plain(fill(highlight.get("label", ""), ctx))]
        out += [
            f"- {plain(fill(as_text(line, f'highlight.lines[{i}]'), ctx))}"
            for i, line in enumerate(highlight["lines"], start=1)
        ]
        out.append("")

    if letter.get("signoff"):
        out.append(plain(fill(letter["signoff"], ctx)))
    if letter.get("signature"):
        out.append(plain(fill(letter["signature"], ctx)))

    if style == "editorial" and footer.get("descriptor"):
        out += ["", plain(fill(footer["descriptor"], ctx))]

    return "\n".join(out).rstrip() + "\n"


def load(path: Path) -> dict:
    try:
        doc = yaml.safe_load(path.read_text(encoding="utf-8"))
    except yaml.YAMLError as exc:
        raise SystemExit(
            f"ERROR: cannot parse {path}:\n{exc}\n"
            '       Hint: in YAML, wrap a value in quotes when it contains ": ".'
        ) from exc
    if not isinstance(doc, dict):
        raise SystemExit(f"ERROR: {path} must contain a YAML mapping")
    doc["_source"] = path
    return doc


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--data", default=str(DEFAULT_DATA), help="YAML source file")
    parser.add_argument("--out-dir", default=None, help="output directory (default: next to the source)")
    parser.add_argument("--check", action="store_true", help="verify outputs are up to date")
    args = parser.parse_args()

    data = Path(args.data).resolve()
    out_dir = Path(args.out_dir).resolve() if args.out_dir else data.parent
    out_html = out_dir / "cover-letter.html"
    out_md = out_dir / "cover-letter.md"

    doc = load(data)
    rendered = {out_html: render_html(doc, out_dir), out_md: render_md(doc)}

    if args.check:
        stale = [
            p.name
            for p, text in rendered.items()
            if not p.exists() or p.read_text(encoding="utf-8") != text
        ]
        if stale:
            print("STALE: " + ", ".join(stale))
            return 1
        print("OK: generated files are up to date")
        return 0

    for path, text in rendered.items():
        path.write_text(text, encoding="utf-8")
        try:
            shown = path.relative_to(ROOT.parent)
        except ValueError:
            shown = path
        print(f"OK: {shown}")
    return 0


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