#!/usr/bin/env python3
"""Replace BWG-SHOT markers in a guide draft with real core/image blocks.

Usage:
  embed-shots.py <draft.html> <shots.json> <out.html> [--keep-frontmatter]

shots.json maps a marker id to {"id": <attachment id>, "url": ..., "alt": ...,
"caption": ...}. A marker whose id is absent from the map is REMOVED entirely
(with a warning) — never left in the output, because a stray HTML comment would
ship the internal capture recipe to client sites.

By default the leading `---` frontmatter block is stripped.

WHY THIS EXISTS
---------------
This logic has been re-implemented ad hoc several times, and got the marker
regex subtly wrong more than once. Two traps, both hit in practice:

  1. Markers carry an OPEN-ENDED attribute set. A pattern that hardcodes the
     order (`id="..." path="..." region="..." caption="..."`) silently stops
     matching the moment a new attribute like `actions="..."` is added — and a
     non-match means the marker (and its internal capture script) ships to
     clients verbatim.
  2. `[^>]*` inside a marker pattern breaks on any attribute VALUE containing
     `>`, which CSS child selectors in an `actions="click:.a > .b"` recipe
     naturally do.

So: match the whole comment lazily, then pull attributes out of the captured
blob with a generic scan. Always assert afterwards that no marker survived.
"""
import json
import re
import sys

ATTR = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
# Lazy body match, no character-class assumptions about what's inside.
MARKER = re.compile(r'<!--\s*BWG-SHOT\b(.*?)-->', re.S)

BLOCK = (
    '<!-- wp:image {{"id":{id},"sizeSlug":"large"}} -->\n'
    '<figure class="wp-block-image size-large">'
    '<img src="{url}" alt="{alt}" class="wp-image-{id}"/>'
    '<figcaption class="wp-element-caption">{caption}</figcaption>'
    '</figure>\n'
    '<!-- /wp:image -->'
)


def esc(s):
    """Escape for use inside an HTML attribute / text node."""
    return (str(s).replace('&', '&amp;').replace('<', '&lt;')
            .replace('>', '&gt;').replace('"', '&quot;'))


def main():
    args = [a for a in sys.argv[1:] if not a.startswith('--')]
    flags = {a for a in sys.argv[1:] if a.startswith('--')}
    if len(args) != 3:
        sys.exit(__doc__)
    draft, shots_path, out = args

    shots = json.load(open(shots_path))
    html = open(draft).read()

    if '--keep-frontmatter' not in flags:
        html = re.sub(r'\A---\n.*?\n---\n', '', html, count=1, flags=re.S)

    total = len(MARKER.findall(html))
    used, missing = [], []

    def sub(m):
        attrs = dict(ATTR.findall(m.group(1)))
        sid = attrs.get('id', '')
        if sid not in shots:
            missing.append(sid or '(no id attribute)')
            return ''
        s = shots[sid]
        used.append(sid)
        # Caption/alt come from the MARKER when present (the draft is the source
        # of truth for wording); the map supplies them only as a fallback.
        caption = attrs.get('caption') or s.get('caption', '')
        alt = s.get('alt') or caption
        return BLOCK.format(id=s['id'], url=s['url'], alt=esc(alt), caption=esc(caption))

    html = MARKER.sub(sub, html)
    html = re.sub(r'\n{3,}', '\n\n', html)

    # Hard guarantees: nothing internal may survive into published content.
    assert 'BWG-SHOT' not in html, 'a marker survived the substitution'
    assert 'actions=' not in html, 'capture-recipe text leaked into output'

    open(out, 'w').write(html)

    o, c = html.count('<!-- wp:'), html.count('<!-- /wp:')
    print(f'markers={total} embedded={len(used)} removed={len(missing)}')
    if used:
        print('  embedded: ' + ', '.join(used))
    if missing:
        print('  WARNING removed (no shot supplied): ' + ', '.join(missing))
    print(f'  block balance: open={o} close={c} {"OK" if o == c else "MISMATCH"}')
    if o != c:
        sys.exit(1)


if __name__ == '__main__':
    main()
