"""Build a local audio review page from a saved Oruk analysis response.

Python 3.10+; standard library only. No API requests or credentials are used.
  python call-review.py --response result.json --audio recording.wav --output review.html

For new analysis, first use https://oruk.ai/examples/analyze-file.py with the
oruk==0.2.6 SDK and --diarize. Keep the response and original audio together.
SPDX-License-Identifier: MIT. Copyright 2026 Oruk, Inc.
License: https://oruk.ai/examples/call-review.LICENSE.txt
"""
from __future__ import annotations

import argparse
import base64
import hashlib
from html import escape
import json
import math
import os
from pathlib import Path
import sys
from urllib.parse import quote


STYLE = """
:root{color-scheme:light;--ink:#172925;--muted:#4c605a;--line:#d2ddd6;--green:#075d42}
*{box-sizing:border-box}body{margin:0;background:#f4f6f2;color:var(--ink);font:16px/1.6 system-ui,sans-serif}
main{max-width:1060px;margin:auto;padding:40px 24px 64px}h1{font-size:clamp(2rem,5vw,3rem);line-height:1.1;letter-spacing:-.04em;margin:12px 0 18px}
h2{font-size:1.3rem;margin:0 0 16px}h3{font-size:.85rem;margin:0 0 12px}p{margin:0 0 16px}.eyebrow{color:var(--green);font-size:.8rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase}
.muted,.meta{color:var(--muted)}.meta,small{font-size:.85rem}code,.time{font: .85rem ui-monospace,monospace}.intro{max-width:74ch}.player{background:#fff;border:1px solid var(--line);border-radius:14px;padding:20px;margin:28px 0}
audio{display:block;width:100%;margin-bottom:12px}button,input{font:inherit}button{cursor:pointer;border:1px solid #a3b9ac;border-radius:7px;padding:8px 12px;background:#fff;color:var(--green)}button:hover{background:#ecf4ec}
button:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid var(--green);outline-offset:3px}.tools{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.tools label{flex:1;min-width:200px}
input{display:block;width:100%;border:1px solid #a3b9ac;border-radius:7px;padding:10px;margin-top:5px;background:white;color:var(--ink)}#status{margin:14px 0 0;min-height:1.6em}#warning{color:#8b2814;font-weight:600}
.segment{margin:18px 0;background:#fff;border:1px solid var(--line);border-left:4px solid var(--line);border-radius:10px;padding:22px}.segment.current{border-left-color:var(--green);background:#fbfefa}
.turn-head{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}.transcript{font-size:1.1rem;margin:20px 0;white-space:pre-wrap;overflow-wrap:anywhere}.score-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;border-top:1px solid var(--line);padding-top:18px}
.scores{list-style:none;padding:0;margin:0}.scores li{display:grid;grid-template-columns:minmax(0,1fr) 90px 3.5em;align-items:center;gap:8px;margin:7px 0;font-size:.9rem}.scores .label{overflow-wrap:anywhere}.scores output{text-align:right;font-variant-numeric:tabular-nums}
meter{width:100%;height:.6rem}details{margin-top:16px}summary{cursor:pointer}.word-list{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.word-list button{font-size:.85rem}.word-list button.current{background:#d9eddf}
.empty{color:var(--muted);font-size:.9rem}footer{margin-top:32px;border-top:1px solid var(--line);padding-top:22px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font: .8rem/1.6 ui-monospace,monospace}
[hidden]{display:none!important}#full-transcript{white-space:pre-wrap;overflow-wrap:anywhere}.metadata{overflow-wrap:anywhere}
@media(max-width:600px){main{padding:28px 16px 48px}.segment{padding:16px}.score-grid{grid-template-columns:1fr;gap:20px}.scores li{grid-template-columns:minmax(0,1fr) 90px 3.5em}}
"""

SCRIPT = """
const audio = document.querySelector('audio');
const status = document.querySelector('#status');
const warning = document.querySelector('#warning');
const cards = [...document.querySelectorAll('.segment')];
const words = [...document.querySelectorAll('.word-list button')];
const search = document.querySelector('#search');
const count = document.querySelector('#count');
let stopAt = null;
let startAt = 0;
let frame = 0;
let playEpoch = 0;

function highlight() {
  const time = audio.currentTime;
  for (const node of [...cards, ...words]) {
    node.classList.toggle('current', time >= Number(node.dataset.start) && time < Number(node.dataset.end));
  }
}
function stopFrame() { cancelAnimationFrame(frame); frame = 0; }
function checkBoundary() {
  frame = 0;
  if (stopAt !== null && audio.currentTime >= stopAt) {
    audio.pause();
    stopAt = null;
    status.textContent = 'Passage finished. Choose another passage or use the audio controls.';
  }
  if (!audio.paused && stopAt !== null) frame = requestAnimationFrame(checkBoundary);
}
async function playPassage(button) {
  const epoch = ++playEpoch;
  if (audio.readyState === 0) {
    status.textContent = 'Audio metadata is loading. Try again when the player is ready.';
    return;
  }
  stopAt = Number(button.dataset.end);
  startAt = Number(button.dataset.start);
  audio.currentTime = startAt;
  highlight();
  status.textContent = 'Playing the selected passage; playback will pause at its end.';
  try { await audio.play(); }
  catch {
    if (epoch !== playEpoch) return;
    stopAt = null;
    status.textContent = 'Playback could not start. Use the audio controls to try again.';
  }
  if (epoch !== playEpoch) return;
  stopFrame();
  if (!audio.paused && stopAt !== null) frame = requestAnimationFrame(checkBoundary);
}
document.querySelectorAll('button[data-start]').forEach(button => {
  button.addEventListener('click', () => playPassage(button));
});
document.querySelector('#continue').addEventListener('click', async () => {
  const epoch = ++playEpoch;
  stopAt = null;
  stopFrame();
  try { await audio.play(); if (epoch === playEpoch) status.textContent = 'Playing from the current position.'; }
  catch { if (epoch === playEpoch) status.textContent = 'Playback could not start. Use the audio controls to try again.'; }
});
audio.addEventListener('timeupdate', highlight);
audio.addEventListener('seeking', () => {
  if (stopAt !== null && (audio.currentTime < startAt || audio.currentTime > stopAt)) stopAt = null;
  highlight();
});
audio.addEventListener('play', () => {
  stopFrame();
  if (stopAt !== null) frame = requestAnimationFrame(checkBoundary);
});
audio.addEventListener('pause', stopFrame);
audio.addEventListener('ended', () => { stopAt = null; stopFrame(); highlight(); });
function checkDuration() {
  const expected = Number(audio.dataset.duration);
  if (Math.abs(audio.duration - expected) > 0.5) {
    warning.textContent = 'The audio duration differs from the saved response. Check that these files belong together before using the timestamps.';
  }
}
audio.addEventListener('loadedmetadata', checkDuration);
if (audio.readyState >= 1) checkDuration();
audio.addEventListener('error', () => {
  warning.textContent = 'The audio could not be loaded. Keep it at the relative path used when you generated this report, and use a browser-supported format.';
});
search.addEventListener('input', () => {
  const query = search.value.trim().toLocaleLowerCase();
  for (const card of cards) card.hidden = !card.textContent.toLocaleLowerCase().includes(query);
  const visible = cards.filter(card => !card.hidden).length;
  count.textContent = `${visible} of ${cards.length} passages shown`;
});
"""


def number(value: object, field: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
        raise ValueError(f"{field} must be a finite number")
    return float(value)


def interval(item: dict, field: str, *, allow_empty: bool = False) -> tuple[float, float]:
    start, end = number(item.get("start"), f"{field}.start"), number(item.get("end"), f"{field}.end")
    if start < 0 or end < start or (not allow_empty and end == start):
        raise ValueError(f"{field} must have 0 <= start < end")
    return start, end


def text(value: object, field: str) -> str:
    if value is None:
        return ""
    if not isinstance(value, str):
        raise ValueError(f"{field} must be text or null")
    return escape(value, quote=True)


def scores(values: object, field: str) -> str:
    if values is None:
        return '<p class="empty">Scores not provided.</p>'
    if not isinstance(values, list):
        raise ValueError(f"{field} must be an array")
    if not values:
        return '<p class="empty">No labels returned. This does not establish absence.</p>'
    rows = []
    for index, value in enumerate(values):
        if not isinstance(value, dict):
            raise ValueError(f"{field}[{index}] must be an object")
        score = number(value.get("score"), f"{field}[{index}].score")
        if not 0 <= score <= 1:
            raise ValueError(f"{field}[{index}].score must be between 0 and 1")
        label = text(value.get("label"), f"{field}[{index}].label")
        rows.append(f'<li><span class="label">{label}</span><meter min="0" max="1" value="{score}" aria-label="{label} model score">{score}</meter><output title="{score}">{score:.3f}</output></li>')
    return '<ul class="scores">' + "".join(rows) + '</ul>'


def csp_hash(source: str) -> str:
    return "'sha256-" + base64.b64encode(hashlib.sha256(source.encode()).digest()).decode() + "'"


def render_report(result: object, audio_href: str, title: str, attribution: str = "") -> str:
    if not isinstance(result, dict) or "error" in result:
        raise ValueError("expected a successful analysis JSON object, not an API error")
    if result.get("task") != "analysis":
        raise ValueError("use an analysis response: the review page needs both transcript and expression")
    duration = number(result.get("duration"), "duration")
    if duration <= 0:
        raise ValueError("duration must be positive")
    segments = result.get("segments")
    if not isinstance(segments, list):
        raise ValueError("segments must be an array")
    cards = []
    for index, segment in enumerate(segments):
        if not isinstance(segment, dict):
            raise ValueError(f"segments[{index}] must be an object")
        start, end = interval(segment, f"segments[{index}]")
        speaker = text(segment.get("speaker"), "speaker") or "Speaker not provided"
        transcript = text(segment.get("text"), "segment.text") or "Transcript not provided for this passage."
        returned_words = segment.get("words")
        if returned_words is None:
            returned_words = []
        if not isinstance(returned_words, list):
            raise ValueError("segment.words must be an array")
        word_buttons = []
        for word_index, word in enumerate(returned_words):
            if not isinstance(word, dict):
                raise ValueError("each word must be an object")
            word_start, word_end = interval(word, f"segments[{index}].words[{word_index}]", allow_empty=True)
            label = text(word.get("word"), "word")
            disabled = ' disabled title="This returned word has zero duration; use passage replay."' if word_start == word_end else ''
            word_buttons.append(f'<button type="button" data-start="{word_start}" data-end="{word_end}" aria-label="Play {label} at {word_start:.2f} seconds"{disabled}>{label} <span class="time">{word_start:.2f}s</span></button>')
        word_markup = (f'<details><summary>Returned word timings ({len(word_buttons)})</summary><div class="word-list">{"".join(word_buttons)}</div></details>'
                       if word_buttons else '<p class="meta">No word timings returned; replay uses the passage boundaries.</p>')
        cards.append(f'''<section class="segment" data-start="{start}" data-end="{end}" aria-label="Passage {index + 1}">
<div class="turn-head"><span><strong>{speaker}</strong> <span class="time">{start:.2f}–{end:.2f}s</span></span><button type="button" data-start="{start}" data-end="{end}">Play passage {index + 1}</button></div>
<p class="transcript">{transcript}</p>
<div class="score-grid"><div><h3>Vocal expression · emotion scores</h3>{scores(segment.get("emotions"), "emotions")}</div><div><h3>Speaking-style scores</h3>{scores(segment.get("styles"), "styles")}</div></div>{word_markup}</section>''')
    no_segments = '<p>No timed passages returned. The full transcript remains below; no timings have been invented.</p>' if not cards else ''
    policy = f"default-src 'none'; media-src 'self' file:; connect-src 'none'; script-src {csp_hash(SCRIPT)}; style-src {csp_hash(STYLE)}; base-uri 'none'; form-action 'none'"
    attribution_markup = f'<h2>Source and permissions</h2><pre>{escape(attribution)}</pre>' if attribution else ''
    return f'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="{escape(policy, quote=True)}"><title>{escape(title)}</title><style>{STYLE}</style></head>
<body><main><header><p class="eyebrow">Recording review / saved analysis</p><h1>{escape(title)}</h1>
<p class="intro">Replay the recording, read the words, and inspect the returned acoustic annotations separately. Speaker labels belong only to this response; they are not identities or job roles.</p>
<p class="metadata meta">Model: {text(result.get("model"), "model")} · {duration:.2f} seconds · {len(cards)} passages<br>Response: <code>{text(result.get("id"), "id")}</code></p></header>
<div class="player"><audio controls preload="metadata" src="{escape(audio_href, quote=True)}" data-duration="{duration}" aria-label="Original recording"></audio><button id="continue" type="button">Play from here without stopping</button><p id="status" role="status">Choose a passage to replay, or use the recording controls.</p><p id="warning" role="alert"></p></div>
<p class="intro muted">Scores are selected model outputs from 0 to 1, not calibrated probabilities of private feelings. Multiple labels can coexist; they need not sum to one. Emotion output has a highest-score fallback, and styles can be empty. Use these annotations to guide listening, not to determine intent or assess a person.</p>
<div class="tools"><label for="search">Find words, a speaker label, or an annotation<input id="search" type="search" placeholder="For example: happened or frustrated"></label><span id="count" class="meta" role="status">{len(cards)} of {len(cards)} passages shown</span></div>
{no_segments}{"".join(cards)}
<details><summary>Full returned transcript</summary><p id="full-transcript">{text(result.get("text"), "text") or 'No transcript returned.'}</p></details>
<footer>{attribution_markup}<p class="meta">Generated locally from a saved response. This page sends no analytics or API requests. It contains the transcript and links to the recording; treat both according to the recording’s permissions. Playback boundaries follow the browser’s media clock and are not sample-accurate editing cuts.</p></footer>
</main><script>{SCRIPT}</script></body></html>'''


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--response", required=True, type=Path, help="saved successful analysis JSON")
    parser.add_argument("--audio", required=True, type=Path, help="the exact local recording that was analyzed")
    parser.add_argument("--output", required=True, type=Path, help="HTML report; keep its relative audio path intact")
    parser.add_argument("--title", default="Review a conversation")
    parser.add_argument("--attribution-file", type=Path, help="UTF-8 source/license note to include in the report")
    args = parser.parse_args()
    try:
        sources = [args.response, args.audio] + ([args.attribution_file] if args.attribution_file else [])
        if any(args.output.resolve() == source.resolve() for source in sources):
            raise ValueError("output must not overwrite an input file")
        if not args.audio.is_file() or not args.audio.stat().st_size:
            raise ValueError("audio must be an existing, nonempty file")
        result = json.loads(args.response.read_text(encoding="utf-8"))
        attribution = args.attribution_file.read_text(encoding="utf-8") if args.attribution_file else ""
        relative_audio = os.path.relpath(args.audio.resolve(), args.output.resolve().parent)
        audio_href = quote(Path(relative_audio).as_posix(), safe="/")
        report = render_report(result, audio_href, args.title, attribution)
        args.output.write_text(report, encoding="utf-8")
        print(f"Created {args.output}. Open it in your browser; keep the referenced audio file in place.")
        return 0
    except (OSError, ValueError, TypeError) as error:
        print(f"Review not created: {error}", file=sys.stderr)
        return 1


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