#!/usr/bin/env python3
"""Add explicit capture briefs without changing acceptance criteria or evidence states.

Usage after the canonical renderer:
    python3 plans/rita-journey-20261001/add_screenshot_evidence.py

Default output is contract-with-screenshots.html, leaving contract.html untouched.
Publish the augmented output to the EXISTING contract URL/document identity.
The helper is idempotent. It does not mark a criterion passed, capture screenshots,
send comments, open a panel, or publish anything.

Fill a slot's captures list in screenshot-evidence.json with actual evidence:
  {"url": "https://.../day-02--390--saved.png", "asset_id": "day-02",
   "state": "saved reflection", "viewport": "390x844 CSS px, DPR 1",
   "captured_at": "ISO timestamp", "deployed_url": "https://.../day-02/",
   "luna_observation": "What the independent reviewer actually saw",
   "luna_report_url": "/absolute/path/to/report.md"}
URLs, observations and reports must be real. A populated slot is captured, not
automatically verified. Retain separate source/interaction evidence in board.json.
If an executor rationally changes a criterion, update its capture brief too and
keep the original/revised criterion and proof rationale in criterion-changes.md.
"""

import argparse
import hashlib
import html
import json
from pathlib import Path

from bs4 import BeautifulSoup

ROOT = Path(__file__).resolve().parents[2]
PLAN = Path(__file__).resolve().parent
DEFAULT_SOURCE = ROOT / 'outputs/rita-journey-20261001/contract.html'
DEFAULT_OUTPUT = DEFAULT_SOURCE.with_name('contract-with-screenshots.html')

CSS = '''
.se-brief{margin-top:14px;padding-top:14px;border-top:1px solid #d7dfed;color:#172033}
.se-brief h5{margin:0 0 8px;font-size:15px;letter-spacing:0;text-transform:none}
.se-brief p,.se-brief li{font-size:14px;line-height:1.55}
.se-note{padding:10px 12px;background:#edf4ff;border-left:3px solid #1765d8;margin:10px 0}
.se-slots{display:grid;gap:14px}
.se-slot{padding:14px;border:1px solid #cdd8eb;border-radius:6px;background:#fff;min-width:0}
.se-slot-head{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:10px}
.se-slot-head strong{font-size:15px}.se-status{font-size:11px;font-weight:800;white-space:nowrap;color:#8a4b00;background:#fff1ce;padding:4px 7px;border-radius:4px}
.se-status.captured{color:#164787;background:#e8f0ff}
.se-placeholder{min-height:100px;border:2px dashed #9cadc8;border-radius:5px;background:repeating-linear-gradient(135deg,#f7f9fc,#f7f9fc 12px,#f1f5fb 12px,#f1f5fb 24px);display:flex;align-items:center;justify-content:center;text-align:center;padding:16px;color:#52627d;margin:0 0 12px}
.se-placeholder b{display:block;color:#23395b}.se-placeholder span{display:block;font-size:12px;margin-top:4px}
.se-fields{display:grid;grid-template-columns:145px 1fr;gap:5px 12px;font-size:14px;line-height:1.5}
.se-fields dt{font-weight:750}.se-fields dd{margin:0;min-width:0;overflow-wrap:anywhere}
.se-slot ul{padding-left:20px;margin:5px 0 10px}.se-reject{padding:8px 10px;background:#fff5ee;border-left:3px solid #cc7838;margin-top:10px}
.se-unfilled{font-size:12px;color:#65718a;margin-top:10px}.se-nonvisual{margin-top:14px;padding:10px 12px;background:#f3f6fa;border:1px solid #d7dfed}
.se-gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
.se-gallery figure{margin:0;min-width:0}.se-gallery img{width:100%;max-height:360px;object-fit:contain;border:1px solid #cad5e6;background:#edf1f6}
.se-gallery figcaption{font-size:12px;line-height:1.5;margin-top:6px;overflow-wrap:anywhere}
.se-crop{position:relative;overflow:hidden;background:#edf1f6;width:100%}.se-gallery .se-crop img{position:absolute;max-width:none;max-height:none;object-fit:initial;border:0}
.se-protocol{margin-top:16px}.se-protocol ol{padding-left:22px}.se-protocol li{margin:7px 0}
@media(max-width:640px){.se-fields{grid-template-columns:1fr;gap:3px}.se-fields dd{margin-bottom:7px}.se-slot-head{flex-direction:column;gap:6px}}
'''


def esc(value):
    return html.escape(str(value), quote=True)


def url(value):
    value = str(value or '')
    # Links are evidence references, never executable URLs.
    if value.startswith(('https://', 'http://', '/')) and not value.startswith('//'):
        return esc(value)
    raise ValueError(f'Invalid evidence URL: {value!r}')


def fragment(markup):
    return BeautifulSoup(markup, 'html.parser')


def render_slot(criterion_id, index, slot):
    key = f"screenshot-{criterion_id}-{slot['id']}"
    captures = slot.get('captures', [])
    result = f'<article class="se-slot" id="{esc(key)}" data-capture-slot="{esc(slot["id"])}">'
    reviewed = sum(bool(c.get('luna_observation') and c.get('luna_report_url')) for c in captures)
    result += f'<div class="se-slot-head"><strong>{index}. {esc(slot["title"])}</strong><span class="se-status {"captured" if captures else ""}">{len(captures)} captured · {reviewed} review record(s)</span></div>'
    if slot.get('coverage_index_url'):
        result += f'<p><a href="{url(slot["coverage_index_url"])}">Complete capture family and individual archive entries</a>'
        if slot.get('coverage_summary'):
            result += ' · ' + esc(slot['coverage_summary'])
        result += '</p>'
    if captures:
        result += '<div class="se-gallery">'
        for i, capture in enumerate(captures, 1):
            image_url = url(capture['url'])
            context = ' · '.join(str(capture.get(k, 'NOT RECORDED')) for k in ('asset_id', 'state', 'viewport', 'captured_at'))
            detail_url = url(capture.get('detail_url', capture['url']))
            result += f'<figure id="{esc(key)}-capture-{i}"><a href="{detail_url}">'
            if capture.get('sheet_bbox'):
                x1,y1,x2,y2 = [int(v) for v in capture['sheet_bbox']]
                width,height = x2-x1,y2-y1
                sheet_width,sheet_height = [int(v) for v in capture['sheet_pixels']]
                if min(width,height,sheet_width,sheet_height)<=0 or x1<0 or y1<0 or x2>sheet_width or y2>sheet_height:
                    raise ValueError('Invalid native screenshot crop')
                result += f'<div class="se-crop" style="aspect-ratio:{width}/{height}"><img src="{image_url}" loading="lazy" style="width:{sheet_width/width*100:.8f}%;left:{-x1/width*100:.8f}%;top:{-y1/height*100:.8f}%" alt="{esc(context)}"/></div>'
            else:
                result += f'<img src="{image_url}" loading="lazy" alt="{esc(context)}"/>'
            result += f'</a><figcaption>{esc(context)}'
            if capture.get('source_sha256'):
                result += f'<p>Original PNG SHA-256: {esc(capture["source_sha256"])}. Native pixels are cropped from a lossless analytical container. Exact original bytes remain in the linked capture-family archive.</p>'
            if capture.get('deployed_url'):
                result += f' · <a href="{url(capture["deployed_url"])}">Captured deployed screen</a>'
            result += f'<p><b>Actual Luna observation:</b> {esc(capture.get("luna_observation", "Pending. Image presence is not an independent verdict."))}</p>'
            if capture.get('luna_report_url'):
                result += f'<a href="{url(capture["luna_report_url"])}">Independent review record</a>'
            else:
                result += '<p>Independent review record: pending</p>'
            result += '</figcaption></figure>'
        result += '</div>'
    else:
        result += '<figure class="se-placeholder" aria-label="Screenshot evidence has not been captured"><div><b>SCREENSHOT PLACEHOLDER · NOT CAPTURED</b><span>Replace with genuine postdeployment captures of the screen and state below.</span><span>This is a capture brief, not proof or a passed check.</span></div></figure>'
    result += '<dl class="se-fields">'
    for label, field in [('Capture', 'target'), ('Set up / action', 'setup'), ('Viewport', 'viewport'), ('Required coverage', 'coverage')]:
        result += f'<dt>{label}</dt><dd>{esc(slot[field])}</dd>'
    result += '</dl><p><b>What independent Luna must be able to see/read:</b></p><ul>'
    result += ''.join(f'<li>{esc(item)}</li>' for item in slot['must_see'])
    result += '</ul><div class="se-reject"><b>Visible reasons to reject:</b> ' + esc(' '.join(slot['reject_if'])) + '</div>'
    result += '<p class="se-unfilled">Evidence fields to fill: screenshot URL(s) · asset/day ID · state · viewport · capture time · deployed screen URL · actual Luna observations · reviewer report.</p></article>'
    return result


def augment(source, output, spec_path):
    spec = json.loads(spec_path.read_text())
    source_bytes = source.read_bytes()
    soup = BeautifulSoup(source_bytes.decode('utf-8'), 'html.parser')
    original_criteria = {n['id']: str(n.select_one('h4')) + str(n.select_one('p')) + n.get('data-state', '') for n in soup.select('.detail[id^="criterion-"]')}
    original_proofs = {n['id']: str(n.select_one('details > .proof')) for n in soup.select('.detail[id^="criterion-"]')}
    for node in soup.select('[data-screenshot-evidence-generated="true"]'):
        node.decompose()
    style = soup.new_tag('style', id='screenshot-evidence-style')
    style['data-screenshot-evidence-generated'] = 'true'
    style.string = CSS
    soup.head.append(style)
    total_slots = sum(len(brief['slots']) for brief in spec['criteria'].values())
    populated_slots = sum(bool(slot.get('captures')) for brief in spec['criteria'].values() for slot in brief['slots'])
    protocol = f'<aside class="proof se-protocol" id="screenshot-capture-protocol" data-screenshot-evidence-generated="true"><b>Screenshot evidence: capture briefs and actual proof</b><p>{populated_slots} of {total_slots} capture groups contain actual screenshots. The linked complete families retain each original capture, context and archive entry. Independent observations and source or interaction receipts determine acceptance. One group may require every day or all four landing variants.</p><ol>'
    protocol += ''.join(f'<li>{esc(step)}</li>' for step in spec['review_protocol'])
    protocol += '</ol><p><b>Customer UI:</b> mobile only, 360/390/430 CSS px. Internal review canvas/document screenshots may use a normal desktop viewport. Record these separately.</p><p><b>Do not confuse evidence types:</b> screenshots show what was visible. Source fidelity, saved-state readback, real media playback and correct transitions also need their explicit source or interaction receipts. Screenshots do not prove conversions, client approval or real payment.</p></aside>'
    hero = soup.select_one('header.hero')
    (hero or soup.body).append(fragment(protocol))
    attached = []
    missing_briefs = []
    slot_count = 0
    for node in soup.select('.detail[id^="criterion-"]'):
        criterion_id = node['id'].removeprefix('criterion-')
        brief = spec['criteria'].get(criterion_id)
        if not brief:
            missing_briefs.append(criterion_id)
            continue
        details = node.find('details', recursive=False)
        if details is None:
            details = soup.new_tag('details')
            details.append(soup.new_tag('summary'))
            node.append(details)
        summary = details.find('summary', recursive=False)
        if summary is None:
            summary = soup.new_tag('summary')
            details.insert(0, summary)
        summary.string = f'Evidence and screenshot brief · {len(brief["slots"])} capture group(s)'
        markup = f'<section class="se-brief" id="capture-brief-{esc(criterion_id)}" data-screenshot-evidence-generated="true"><h5>Required screenshot evidence</h5><p class="se-note">Capture the state below at readable scale. Luna first gives a blind description, then checks the visible indicators. Judge all required coverage, not one attractive example.</p><div class="se-slots">'
        markup += ''.join(render_slot(criterion_id, i, s) for i, s in enumerate(brief['slots'], 1))
        markup += '</div><div class="se-nonvisual"><b>Also required. A screenshot cannot prove this:</b><ul>'
        markup += ''.join(f'<li>{esc(item)}</li>' for item in brief['additional_evidence'])
        markup += '</ul></div></section>'
        details.append(fragment(markup))
        attached.append(criterion_id)
        slot_count += len(brief['slots'])
    if missing_briefs:
        raise ValueError('No criterion-specific capture brief for: ' + ', '.join(missing_briefs) + '. Preserve changed criteria and update the spec before publishing.')
    after_criteria = {n['id']: str(n.select_one('h4')) + str(n.select_one('p')) + n.get('data-state', '') for n in soup.select('.detail[id^="criterion-"]')}
    after_proofs = {n['id']: str(n.select_one('details > .proof')) for n in soup.select('.detail[id^="criterion-"]')}
    if original_criteria != after_criteria or original_proofs != after_proofs:
        raise ValueError('Capture augmentation changed criterion wording, state or existing proof.')
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(str(soup))
    return {'source': str(source), 'output': str(output), 'spec': str(spec_path), 'source_sha256': hashlib.sha256(source_bytes).hexdigest(), 'output_sha256': hashlib.sha256(output.read_bytes()).hexdigest(), 'criterion_count': len(attached), 'capture_group_count': slot_count, 'criteria_and_states_preserved': True, 'existing_proof_preserved': True}


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--source', type=Path, default=DEFAULT_SOURCE)
    parser.add_argument('--output', type=Path, default=DEFAULT_OUTPUT)
    parser.add_argument('--spec', type=Path, default=PLAN / 'screenshot-evidence.json')
    args = parser.parse_args()
    print(json.dumps(augment(args.source, args.output, args.spec), ensure_ascii=False, indent=2))
