import html as html_module
import markdown
from common import METRIC_IDS, RETRIEVAL_RESULT, STATUS_PASS, STATUS_FAIL, STATUS_ERROR, STATUS_PARTIAL, STATUS_UNKNOWN, pascal_case_to_title
from datetime import datetime, timezone
from evaluator_resolver import EVALUATOR_REGISTRY

def calculate_aggregate_statistics(results):
    """Calculate aggregate statistics across all evaluation results.

    Scans ALL results (not just the first) to discover which metrics were used,
    correctly handling per-prompt evaluator variation. Each metric reports
    prompts_evaluated (how many prompts it actually ran on) and total_prompts.
    """
    if not results:
        return {}

    # Flatten: multi-turn threads contribute each turn as a separate item
    flat_results = []
    for result in results:
        if result.get("type") == "multi_turn":
            for turn in result.get("turns", []):
                flat_results.append(turn)
        else:
            flat_results.append(result)

    # Discover all metric keys across all results
    all_metric_keys = set()
    for result in flat_results:
        all_metric_keys.update(result.get('results', {}).keys())

    aggregates = {}

    for eval_name in sorted(all_metric_keys):
        display_name = pascal_case_to_title(eval_name)
        metric_id = METRIC_IDS.get(eval_name, eval_name)

        scores = []
        pass_count = 0
        fail_count = 0
        error_count = 0
        threshold_value = None
        prompts_evaluated = 0

        for result in flat_results:
            parsed_data = result.get('results', {}).get(eval_name)
            if parsed_data is None:
                continue  # This metric did not run for this prompt
            if not isinstance(parsed_data, dict):
                continue

            prompts_evaluated += 1
            try:
                score = parsed_data.get(metric_id)
                result_status = parsed_data.get('result')
                threshold = parsed_data.get('threshold')

                if score is not None:
                    scores.append(float(score))

                if result_status:
                    status = str(result_status).lower()
                    if status == STATUS_PASS:
                        pass_count += 1
                    elif status == STATUS_FAIL:
                        fail_count += 1
                    elif status == STATUS_ERROR:
                        error_count += 1

                if threshold is not None and threshold_value is None:
                    threshold_value = threshold

            except (ValueError, TypeError):
                continue

        # Surface evaluators that ran in any form — including those whose only
        # attempts errored. Suppressing error-only evaluators would hide them
        # from the aggregate report (SC-001).
        if scores or pass_count > 0 or fail_count > 0 or error_count > 0:
            avg_score = sum(scores) / len(scores) if scores else 0
            # Per-evaluator pass rate is "agreement among completed evaluations" —
            # errors are surfaced separately as a count, not folded into the rate.
            total_evaluated = pass_count + fail_count
            pass_rate = (pass_count / total_evaluated * 100) if total_evaluated > 0 else 0

            # Defensive fallback: if no per-entry threshold was recorded
            # (shouldn't happen — both successful and errored runtime entries
            # carry it — but guard against malformed input), use the registry
            # default. Evaluators with no registry default (e.g. ExactMatch)
            # legitimately have threshold=None.
            if threshold_value is None:
                registry_entry = EVALUATOR_REGISTRY.get(eval_name)
                if registry_entry is not None:
                    threshold_value = registry_entry.default_threshold

            aggregates[display_name] = {
                'total_prompts': len(flat_results),
                'prompts_evaluated': prompts_evaluated,
                'total_evaluated': total_evaluated,
                'pass_count': pass_count,
                'fail_count': fail_count,
                'error_count': error_count,
                'pass_rate': pass_rate,
                'avg_score': avg_score,
                'threshold': threshold_value,
                'scores': scores
            }

    return aggregates

def format_score(score):
  try:
    val = float(score)
  except (TypeError, ValueError):
    return score
  if val.is_integer():
    return str(int(val))
  s = f"{val:.3f}".rstrip('0').rstrip('.')
  return s or "0"

# Evaluators whose ``reason`` is authored as Markdown and should be rendered as such.
_MARKDOWN_REASON_EVALUATORS = frozenset({RETRIEVAL_RESULT})


def extract_metric_rows(entry):
    """
    Build generic metric rows from evaluation results.
    Each row has: Metric, Result, Score, Threshold, Reason, IsReasonMarkdown.
    Omits metrics that did not run (None values) for this prompt.
    """
    rows = []

    def pick(d, candidates):
        for k in candidates:
            if k in d and d[k] not in (None, ''):
                return d[k]
        return ''

    results_container = entry.get('results', {}) if isinstance(entry, dict) else {}

    for eval_name, metric_obj in results_container.items():
        if metric_obj is None:
            continue  # Skip metrics that did not run for this prompt
        if not isinstance(metric_obj, dict):
            continue

        display_name = pascal_case_to_title(eval_name)
        metric_id = METRIC_IDS.get(eval_name, eval_name)

        # Candidate key patterns inside the parsed metric object
        score_val = pick(metric_obj, [metric_id])
        result_val = pick(metric_obj, ['result'])
        threshold_val = pick(metric_obj, ['threshold'])
        # Errored entries carry the per-evaluator failure description in `error`
        # (e.g. "Evaluator failed: Connection timeout"). Surface it in the Reason
        # column so HTML readers see why the evaluator couldn't produce a result.
        if result_val == STATUS_ERROR:
            reason_val = metric_obj.get('error', '')
            # Prefix a machine-readable code (e.g. "emptyAgentResponse") so HTML
            # readers can distinguish the failure category at a glance.
            err_code = metric_obj.get('code')
            if err_code:
                reason_val = f'[{err_code}] {reason_val}' if reason_val else f'[{err_code}]'
        else:
            reason_val = pick(metric_obj, [f'{metric_id}_reason', 'reason'])

        rows.append({
            'Metric': display_name,
            'Result': str(result_val).lower() if isinstance(result_val, str) else result_val,
            'Score': format_score(score_val),
            'Threshold': format_score(threshold_val),
            'Reason': reason_val,
            'IsReasonMarkdown': eval_name in _MARKDOWN_REASON_EVALUATORS and result_val != STATUS_ERROR,
        })
    return rows

_CHIP_CLASSES = {
    STATUS_PASS: "status-pass",
    STATUS_FAIL: "status-fail",
    STATUS_PARTIAL: "status-partial",
    STATUS_ERROR: "status-error",
}


def _chip_class(status):
    """Map a status value to its chip CSS class. Unknown statuses fall back to status-error."""
    return _CHIP_CLASSES.get(status, "status-error")


def classify_attempt(entry):
    """Return one of {pass, fail, partial, error} for an attempt or a thread.

    For an un-flattened multi-turn thread, returns the thread's overall_status.
    For a single-turn item or a per-turn entry (from a flattened thread), returns
    the entry's status — which is set authoritatively by the runner.
    """
    if entry.get("type") == "multi_turn":
        return entry.get("summary", {}).get("overall_status", STATUS_UNKNOWN)
    return entry.get("status", STATUS_UNKNOWN)

def _escape(text):
    """HTML-escape user-controlled content to prevent XSS."""
    if text is None:
        return ""
    return html_module.escape(str(text))


_CELL_CLASSES = {
    STATUS_PASS: "cell-pass",
    STATUS_FAIL: "cell-fail",
    STATUS_ERROR: "cell-error",
}


def _render_metric_table(html, rows):
    """Append a metric-table block to ``html`` (no-op if rows is empty)."""
    if not rows:
        return
    html.append('            <table class="metric-table">')
    html.append('              <tr><th>Metric</th><th>Result</th><th>Score</th><th>Threshold</th><th>Reason</th></tr>')
    for row in rows:
        result_val = str(row.get("Result", "")).lower()
        cell_class = _CELL_CLASSES.get(result_val)
        result_attr = f' class="{cell_class}"' if cell_class else ""
        reason = _escape(str(row.get("Reason", "")))
        if row.get("IsReasonMarkdown"):
            reason_body = markdown.markdown(reason)
        else:
            # Plain reasons render verbatim.
            reason_body = reason.replace("\n", "<br>")
        reason_cell = f'<div class="md-content">{reason_body}</div>'
        html.append(
            '<tr>'
            f'<td>{_escape(row.get("Metric", ""))}</td>'
            f'<td{result_attr}>{_escape(str(row.get("Result", "")))}</td>'
            f'<td>{_escape(str(row.get("Score", "")))}</td>'
            f'<td>{_escape(str(row.get("Threshold", "")))}</td>'
            f'<td>{reason_cell}</td>'
            '</tr>'
        )
    html.append('            </table>')


def _render_response_table(html, response, expected_response):
    """Append the response pair as an accessible, Markdown-aware table."""
    response_html = markdown.markdown(_escape(response))
    expected_html = markdown.markdown(_escape(expected_response))
    html.append('            <table class="response-table" aria-label="Response comparison">')
    html.append('              <tbody>')
    html.append('                <tr>')
    html.append('                  <th scope="row">Response</th>')
    html.append(f'                  <td><div class="md-content">{response_html}</div></td>')
    html.append('                </tr>')
    html.append('                <tr>')
    html.append('                  <th scope="row">Expected Response</th>')
    html.append(f'                  <td><div class="md-content">{expected_html}</div></td>')
    html.append('                </tr>')
    html.append('              </tbody>')
    html.append('            </table>')


def _render_tags(html, entry):
    """Append inline first-class ``tags`` chips if present.

    Tags are surfaced as lightweight inline chips so the HTML report echoes the
    same values carried in the JSON output.
    """
    tags = entry.get("tags")
    if not tags:
        return
    chips = ''.join(f'<span class="tag-chip">{_escape(str(t))}</span>' for t in tags)
    html.append(f'            <div class="tags"><span class="tag-label"><strong>Tags:</strong> {chips}</span></div>')


def _render_diagnostics(html, entry):
    """Append a collapsible Diagnostics section for ``entry`` if present.

    Renders the internal ``_diagnostics`` container. Only the
    ``retrieval_executions`` category exists today; the loop tolerates future
    sibling categories by rendering retrieval executions when present and
    skipping unknown keys. No-op when diagnostics are absent.
    """
    diagnostics = entry.get("_diagnostics") if isinstance(entry, dict) else None
    if not diagnostics:
        return

    executions = diagnostics.get("retrieval_executions") or []
    html.append('            <details class="diagnostics">')
    html.append('              <summary>Retrieval Diagnostics</summary>')
    if not executions:
        html.append('              <p>No retrieval activity captured.</p>')
    for ex in executions:
        if not isinstance(ex, dict):
            continue
        cap = _escape(ex.get("capability"))
        status = _escape(ex.get("status"))
        total = ex.get("totalResultCount")
        total_str = "" if total is None else f" &middot; {_escape(total)} result(s)"
        # Each execution is its own collapsible block.
        html.append('              <details class="execution">')
        html.append(
            f'                <summary><strong>{cap}</strong> '
            f'<span class="status-chip">{status}</span>{total_str}</summary>'
        )
        for q in ex.get("queries") or []:
            if not isinstance(q, dict):
                continue
            qs = _escape(q.get("queryString"))
            fe = q.get("filterExpression")
            fe_str = f' <span class="filter">(<strong>filter:</strong> {_escape(fe)})</span>' if fe else ""
            html.append(f'                <div class="diag-query"><strong>Query:</strong> {qs}{fe_str}</div>')
        hits = ex.get("retrievalHits") or []
        if hits:
            html.append('                <table class="metric-table">')
            html.append('                  <tr><th>Rank</th><th>URL</th><th>Extract</th></tr>')
            for hit in hits:
                if not isinstance(hit, dict):
                    continue
                rank = _escape(hit.get("rank"))
                url = _escape(hit.get("webUrl"))
                extracts = hit.get("extracts") or []
                text = _escape("\n\n".join(
                    e.get("text", "") for e in extracts
                    if isinstance(e, dict) and isinstance(e.get("text"), str)
                ))
                html.append(
                    f'                  <tr><td>{rank}</td><td>{url}</td>'
                    f'<td><div class="extract-cell">{text}</div></td></tr>'
                )
            html.append('                </table>')
        html.append('              </details>')
    html.append('            </details>')


def generate_html_report(results, agent_name=None, agent_id=None, cli_version=None, judge=None):
    aggregates = calculate_aggregate_statistics(results)

    # Flatten multi-turn threads for banner counts (consistent with aggregate stats)
    flat_items = []
    for entry in results:
        if entry.get("type") == "multi_turn":
            flat_items.extend(entry.get("turns", []))
        else:
            flat_items.append(entry)
    total_prompts = len(flat_items)

    counts = {STATUS_PASS: 0, STATUS_FAIL: 0, STATUS_PARTIAL: 0, STATUS_ERROR: 0}
    for item in flat_items:
        c = classify_attempt(item)
        if c in counts:
            counts[c] += 1
    incomplete_count = counts[STATUS_PARTIAL] + counts[STATUS_ERROR]
    decisive_count = counts[STATUS_PASS] + counts[STATUS_FAIL]
    overall_pass_rate = (counts[STATUS_PASS] / decisive_count * 100) if decisive_count else 0
    generated_utc = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")

    html = [
        '<!DOCTYPE html>',
        '<html lang="en">',
        '<head>',
        '  <meta charset="UTF-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1.0">',
        '  <title>M365 Copilot Agents Evaluation Scores Report</title>',
        '  <style>',
        '    :root {',
        '      --bg: #f6f7f9;',
        '      --panel: #ffffff;',
        '      --ink: #1f2937;',
        '      --muted: #5b6473;',
        '      --ok-bg: #e7f7ed;',
        '      --ok-ink: #15603a;',
        '      --bad-bg: #fdecec;',
        '      --bad-ink: #8b1e2f;',
        '      --warn-bg: #fff4e0;',
        '      --warn-ink: #8a5a00;',
        '      --neutral-bg: #ececec;',
        '      --neutral-ink: #4a4a4a;',
        '      --border: #dde2ea;',
        '      --bar-track: #e8edf5;',
        '      --bar-fill: #2b6cb0;',
        '    }',
        '    * { box-sizing: border-box; }',
        '    body { margin: 0; background: radial-gradient(circle at top right, #eef3ff 0%, var(--bg) 45%); color: var(--ink); font-family: "Segoe UI", Tahoma, sans-serif; }',
        '    .container { max-width: 1100px; margin: 0 auto; padding: 24px 18px 40px; }',
        '    h1 { margin: 0 0 8px; }',
        '    .meta { color: var(--muted); margin-bottom: 20px; }',
        '    .summary-banner { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 12px; margin: 16px 0 24px; }',
        '    .summary-tile { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 14px; }',
        '    .summary-label { display: block; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }',
        '    .summary-value { display: block; margin-top: 6px; font-size: 24px; font-weight: 700; }',
        '    .section { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 18px; }',
        '    .evaluator-row { margin: 12px 0; }',
        '    .evaluator-head { display: flex; justify-content: space-between; gap: 8px; font-size: 14px; margin-bottom: 6px; }',
        '    .progress-track { width: 100%; background: var(--bar-track); border-radius: 999px; overflow: hidden; height: 12px; }',
        '    .progress-fill { height: 100%; background: var(--bar-fill); }',
        '    .prompt-result-cards { display: grid; gap: 14px; }',
        '    .prompt-card { border: 1px solid var(--border); border-radius: 12px; background: var(--panel); padding: 14px; overflow: hidden; overflow-wrap: break-word; }',
        '    .entry-progress { display: inline-block; color: var(--muted); font-size: 13px; font-weight: 600; letter-spacing: .02em; margin-right: 8px; }',
        '    .entry-separator { border: 0; border-top: 1px solid var(--border); margin: 18px 0 4px; }',
        '    .status-chip { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; margin-bottom: 10px; }',
        '    .status-pass { background: var(--ok-bg); color: var(--ok-ink); }',
        '    .status-fail { background: var(--bad-bg); color: var(--bad-ink); }',
        '    .status-partial { background: var(--warn-bg); color: var(--warn-ink); }',
        '    .status-error { background: var(--neutral-bg); color: var(--neutral-ink); }',
        '    .prompt-card h3 { margin: 0 0 8px; font-size: 16px; }',
        '    .kv { margin: 8px 0; }',
        '    .kv > strong { display: block; min-width: 130px; color: var(--muted); margin-bottom: 4px; }',
        '    .md-content { padding-left: 4px; font-size: 14px; line-height: 1.5; }',
        '    .md-content p { margin: 4px 0; }',
        '    .md-content h1, .md-content h2, .md-content h3, .md-content h4 { font-size: 14px; margin: 8px 0 4px; }',
        '    .md-content ul, .md-content ol { margin: 4px 0; padding-left: 20px; }',
        '    .md-content li { margin: 2px 0; }',
        '    .md-content pre { background: #f4f6fa; padding: 8px; border-radius: 4px; overflow-x: auto; font-size: 13px; margin: 4px 0; }',
        '    .md-content code { font-size: 13px; background: #f4f6fa; padding: 1px 4px; border-radius: 3px; }',
        '    .md-content pre code { padding: 0; background: none; }',
        '    .md-content hr { border: none; border-top: 1px solid var(--border); margin: 6px 0; }',
        '    .response-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; table-layout: fixed; }',
        '    .response-table th, .response-table td { border: 1px solid var(--border); padding: 10px; text-align: left; vertical-align: top; }',
        '    .response-table th { width: 170px; background: var(--neutral-bg); color: var(--ink); }',
        '    .evaluator-results { border-top: 2px solid var(--border); margin-top: 16px; padding-top: 12px; }',
        '    .metric-table { width: 100%; border-collapse: collapse; margin-top: 10px; table-layout: fixed; }',
        '    .metric-table th, .metric-table td { border: 1px solid var(--border); padding: 8px; text-align: left; vertical-align: top; }',
        '    .metric-table th { background: #f4f6fa; }',
        '    .metric-table .cell-pass { background: var(--ok-bg); color: var(--ok-ink); font-weight: 600; }',
        '    .metric-table .cell-fail { background: var(--bad-bg); color: var(--bad-ink); font-weight: 600; }',
        '    .metric-table .cell-error { background: var(--neutral-bg); color: var(--neutral-ink); font-weight: 600; }',
        '    .evaluator-badge { display: inline-block; padding: 2px 8px; margin: 2px; border-radius: 4px; font-size: 0.85em; background: #e8eaf6; color: #283593; }',
        '    .diagnostics { margin-top: 12px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; }',
        '    .diagnostics summary { cursor: pointer; font-weight: 600; color: var(--muted); }',
        '    .diagnostics .metric-table td { word-break: break-word; vertical-align: top; }',
        '    .diagnostics .execution { margin: 8px 0; border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; }',
        '    .diagnostics .execution summary { cursor: pointer; font-weight: 500; }',
        '    .diagnostics .diag-query { margin: 6px 0; }',
        '    .diagnostics .diag-query strong { color: var(--muted); }',
        '    .tags { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; }',
        '    .tags strong { color: var(--muted); }',
        '    .tags .tag-chip { display: inline-block; background: #eef2fb; color: #2c3e75; border-radius: 12px; padding: 2px 10px; margin-left: 6px; font-size: 12px; }',
        '    .extract-cell { max-height: 180px; overflow-y: auto; white-space: pre-wrap; display: block; }',
        '    .footer { margin-top: 20px; color: var(--muted); font-size: 13px; }',
        '    @media (max-width: 760px) { .summary-banner { grid-template-columns: repeat(2, minmax(120px, 1fr)); } .response-table tr, .response-table th, .response-table td { display: block; width: 100%; } .response-table th, .response-table td { padding: 8px; } .response-table tr + tr { margin-top: 8px; } }',
        '  </style>',
        '</head>',
        '<body>',
        '  <div class="container">',
        '    <h1>M365 Copilot Agents Evaluation Report</h1>',
    ]

    metadata_items = []
    if agent_name:
        metadata_items.append(f'<strong>Agent Name:</strong> {_escape(agent_name)}')
    if agent_id:
        metadata_items.append(f'<strong>Agent ID:</strong> {_escape(agent_id)}')
    if cli_version:
        metadata_items.append(f'<strong>CLI Version:</strong> {_escape(cli_version)}')
    if judge:
        metadata_items.append(f'<strong>Judge:</strong> {_escape(judge)}')
    if metadata_items:
        html.append(f'    <p class="meta">{" | ".join(metadata_items)}</p>')

    html.append('    <section class="summary-banner" aria-label="summary banner">')
    html.append(f'      <div class="summary-tile"><span class="summary-label">Total</span><span class="summary-value">{total_prompts}</span></div>')
    html.append(f'      <div class="summary-tile"><span class="summary-label">Passed</span><span class="summary-value">{counts[STATUS_PASS]}</span></div>')
    html.append(f'      <div class="summary-tile"><span class="summary-label">Failed</span><span class="summary-value">{counts[STATUS_FAIL]}</span></div>')
    html.append(f'      <div class="summary-tile"><span class="summary-label">Incomplete</span><span class="summary-value">{incomplete_count}</span></div>')
    html.append(f'      <div class="summary-tile"><span class="summary-label">Pass Rate</span><span class="summary-value">{overall_pass_rate:.1f}%</span></div>')
    html.append('    </section>')

    html.append('    <section class="section">')
    html.append('      <h2>Aggregate Evaluator Statistics</h2>')
    if aggregates:
        for metric_name, stats in aggregates.items():
            pass_rate = stats.get('pass_rate', 0)
            prompts_evaluated = stats.get('prompts_evaluated', stats.get('total_evaluated', 0))
            html.append('<div class="evaluator-row">')
            avg_score = stats.get('avg_score', 0)
            threshold_val = stats.get('threshold')
            threshold_str = "N/A" if threshold_val is None else str(threshold_val)
            error_count = stats.get('error_count', 0)
            error_clause = f' / {error_count} error' if error_count else ''
            html.append(
                f'<div class="evaluator-head"><strong>{_escape(metric_name)}</strong>'
                f'<span>{pass_rate:.1f}% ({stats.get("pass_count", 0)} pass / {stats.get("fail_count", 0)} fail{error_clause}, {prompts_evaluated}/{total_prompts} prompts)'
                f' &middot; Avg Score: {avg_score:.2f} &middot; Threshold: {_escape(threshold_str)}</span></div>'
            )
            html.append('<div class="progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{:.1f}" aria-label="{} pass rate">'.format(pass_rate, _escape(metric_name)))
            html.append(f'<div class="progress-fill" style="width:{pass_rate:.1f}%"></div></div>')
            html.append('</div>')
    else:
        html.append('      <p>No evaluator aggregates available.</p>')
    html.append('    </section>')

    html.append('    <section class="section">')
    html.append('      <h2>Prompt Results</h2>')
    html.append('      <div class="prompt-result-cards">')

    for idx, entry in enumerate(results, 1):
        if idx > 1:
            html.append('        <hr class="entry-separator" aria-hidden="true">')
        if entry.get("type") == "multi_turn":
            # Multi-turn thread card
            thread_name = _escape(entry.get("name", "Unnamed Thread"))
            summary = entry.get("summary", {})
            thread_status = summary.get("overall_status", STATUS_UNKNOWN)

            html.append('        <article class="prompt-card">')
            html.append(f'          <span class="entry-progress">Entry {idx}/{len(results)}</span>')
            html.append(f'          <span class="status-chip {_chip_class(thread_status)}">{thread_status.upper()}</span>')
            html.append(f'          <h3>Thread {idx}: {thread_name}</h3>')
            html.append(f'          <p>{summary.get("turns_passed", 0)}/{summary.get("turns_total", 0)} turns passed</p>')

            for t_idx, turn in enumerate(entry.get("turns", []), 1):
                turn_status = turn.get("status", STATUS_UNKNOWN)

                html.append(f'          <div style="margin-left:16px;padding:8px 0;border-top:1px solid var(--border);">')
                html.append(f'            <span class="status-chip {_chip_class(turn_status)}">{turn_status.upper()}</span>')
                html.append(f'            <strong>Turn {t_idx}:</strong> {_escape(turn.get("prompt", ""))}')

                turn_evaluators = turn.get('evaluators_ran', [])
                if turn_evaluators:
                    badges = ''.join(f'<span class="evaluator-badge">{_escape(e)}</span>' for e in turn_evaluators)
                    html.append(f'            <p>Evaluators: {badges}</p>')

                _render_response_table(
                    html,
                    turn.get("response", ""),
                    turn.get("expected_response", ""),
                )
                html.append(
                    f'            <section class="evaluator-results" aria-label="Evaluator results for entry {idx}, turn {t_idx}">'
                )
                turn_error = turn.get("error")
                if turn_error:
                    html.append(
                        f'            <p class="kv" data-error-code="{_escape(turn_error.get("code", ""))}">'
                        f'<strong>Error:</strong> {_escape(turn_error.get("message", ""))}</p>'
                    )

                _render_metric_table(html, extract_metric_rows(turn))
                _render_diagnostics(html, turn)
                _render_tags(html, turn)
                html.append('            </section>')

                html.append('          </div>')

            _render_tags(html, entry)
            html.append('        </article>')
        else:
            score_rows = extract_metric_rows(entry)
            item_status = classify_attempt(entry)

            html.append('        <article class="prompt-card">')
            html.append(f'          <span class="entry-progress">Entry {idx}/{len(results)}</span>')
            html.append(f'          <span class="status-chip {_chip_class(item_status)}">{item_status.upper()}</span>')
            html.append(f'          <h3>Prompt {idx}: {_escape(entry.get("prompt", ""))}</h3>')

            evaluators_ran = entry.get('evaluators_ran', [])
            if evaluators_ran:
                badges = ''.join(f'<span class="evaluator-badge">{_escape(e)}</span>' for e in evaluators_ran)
                html.append(f'          <p>Evaluators: {badges}</p>')

            _render_response_table(
                html,
                entry.get("response", ""),
                entry.get("expected_response", ""),
            )
            html.append(
                f'          <section class="evaluator-results" aria-label="Evaluator results for entry {idx}">'
            )
            item_error = entry.get('error')
            if item_error:
                html.append(
                    f'          <p class="kv" data-error-code="{_escape(item_error.get("code", ""))}">'
                    f'<strong>Error:</strong> {_escape(item_error.get("message", ""))}</p>'
                )

            _render_metric_table(html, score_rows)
            _render_diagnostics(html, entry)
            _render_tags(html, entry)
            html.append('          </section>')

            html.append('        </article>')

    if not results:
        html.append('        <p>No prompt results found.</p>')

    html.append('      </div>')
    html.append('    </section>')

    html.append(f'    <p class="footer">Generated by M365 Copilot Agents Evaluation CLI &mdash; <time datetime="{generated_utc}">{generated_utc} UTC</time></p>')
    html.append('  </div>')
    html.append('</body>')
    html.append('</html>')

    return '\n'.join(html)
