"""Scan History Analytics for MCP servers.

Advanced analytics over scan history: MTTR, recurrence rate, fix velocity,
trend predictions, and persistent findings analysis.
Generates HTML dashboards with CSS-only charts.

Zero external dependencies. Python 3.11+ stdlib only.
"""

from __future__ import annotations

import json
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any


# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------

@dataclass
class FindingRecord:
    """A finding tracked across scans."""

    rule_id: str
    first_seen: str = ""
    last_seen: str = ""
    occurrences: int = 0
    resolved: bool = False
    resolved_at: str = ""
    severity: str = "MEDIUM"

    @property
    def time_to_resolve_days(self) -> float | None:
        """Days from first seen to resolved. None if not resolved."""
        if not self.resolved or not self.first_seen or not self.resolved_at:
            return None
        try:
            t0 = _parse_ts(self.first_seen)
            t1 = _parse_ts(self.resolved_at)
            return max(0.0, (t1 - t0).total_seconds() / 86400)
        except (ValueError, TypeError):
            return None

    def to_dict(self) -> dict[str, Any]:
        return {
            "rule_id": self.rule_id,
            "first_seen": self.first_seen,
            "last_seen": self.last_seen,
            "occurrences": self.occurrences,
            "resolved": self.resolved,
            "resolved_at": self.resolved_at,
            "severity": self.severity,
            "time_to_resolve_days": self.time_to_resolve_days,
        }


@dataclass
class CategoryTrend:
    """Trend data for a severity category."""

    category: str
    values: list[int] = field(default_factory=list)
    timestamps: list[str] = field(default_factory=list)

    @property
    def direction(self) -> str:
        """Determine if trending up, down, or stable."""
        if len(self.values) < 2:
            return "stable"
        recent = self.values[-3:] if len(self.values) >= 3 else self.values
        first_half = recent[: len(recent) // 2 + 1]
        second_half = recent[len(recent) // 2:]
        avg_first = sum(first_half) / len(first_half) if first_half else 0
        avg_second = sum(second_half) / len(second_half) if second_half else 0
        diff = avg_second - avg_first
        if diff > 0.5:
            return "degrading"
        if diff < -0.5:
            return "improving"
        return "stable"

    @property
    def change_pct(self) -> float:
        """Percentage change from first to last value."""
        if len(self.values) < 2:
            return 0.0
        first = self.values[0]
        last = self.values[-1]
        if first == 0:
            return 100.0 if last > 0 else 0.0
        return round((last - first) / first * 100, 1)

    def to_dict(self) -> dict[str, Any]:
        return {
            "category": self.category,
            "values": self.values,
            "timestamps": self.timestamps,
            "direction": self.direction,
            "change_pct": self.change_pct,
        }


@dataclass
class Prediction:
    """Prediction for when a target score will be reached."""

    target_score: int
    current_score: int
    predicted_date: str = ""
    days_remaining: int = -1
    confidence: str = "low"  # low, medium, high
    achievable: bool = True
    trend_slope: float = 0.0

    def to_dict(self) -> dict[str, Any]:
        return {
            "target_score": self.target_score,
            "current_score": self.current_score,
            "predicted_date": self.predicted_date,
            "days_remaining": self.days_remaining,
            "confidence": self.confidence,
            "achievable": self.achievable,
            "trend_slope": round(self.trend_slope, 4),
        }


@dataclass
class HistoryAnalytics:
    """Complete analytics over scan history."""

    total_scans: int = 0
    date_range_start: str = ""
    date_range_end: str = ""
    mttr_days: float = 0.0  # Mean Time To Resolve
    recurrence_rate: float = 0.0  # % of findings that reappear
    fix_velocity: float = 0.0  # Findings fixed per scan
    current_score: int = 0
    current_grade: str = "?"
    best_grade: str = "?"
    worst_grade: str = "?"
    total_unique_findings: int = 0
    total_resolved: int = 0
    total_open: int = 0
    category_trends: list[CategoryTrend] = field(default_factory=list)
    persistent_findings: list[FindingRecord] = field(default_factory=list)
    finding_records: list[FindingRecord] = field(default_factory=list)
    predictions: list[Prediction] = field(default_factory=list)
    score_history: list[int] = field(default_factory=list)
    grade_history: list[str] = field(default_factory=list)
    timestamps: list[str] = field(default_factory=list)

    @property
    def top_persistent(self) -> list[FindingRecord]:
        """Top 10 most persistent findings."""
        return sorted(
            [f for f in self.finding_records if not f.resolved],
            key=lambda x: x.occurrences,
            reverse=True,
        )[:10]

    def to_dict(self) -> dict[str, Any]:
        return {
            "total_scans": self.total_scans,
            "date_range_start": self.date_range_start,
            "date_range_end": self.date_range_end,
            "mttr_days": round(self.mttr_days, 1),
            "recurrence_rate": round(self.recurrence_rate, 1),
            "fix_velocity": round(self.fix_velocity, 2),
            "current_score": self.current_score,
            "current_grade": self.current_grade,
            "best_grade": self.best_grade,
            "worst_grade": self.worst_grade,
            "total_unique_findings": self.total_unique_findings,
            "total_resolved": self.total_resolved,
            "total_open": self.total_open,
            "category_trends": [t.to_dict() for t in self.category_trends],
            "persistent_findings": [
                f.to_dict() for f in self.top_persistent
            ],
            "predictions": [p.to_dict() for p in self.predictions],
            "score_history": self.score_history,
            "grade_history": self.grade_history,
        }


# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------

def _parse_ts(ts: str) -> datetime:
    """Parse a timestamp string into a datetime."""
    # Try common formats
    for fmt in (
        "%Y%m%dT%H%M%SZ",
        "%Y-%m-%dT%H:%M:%SZ",
        "%Y-%m-%dT%H:%M:%S",
        "%Y-%m-%d",
    ):
        try:
            return datetime.strptime(ts, fmt).replace(tzinfo=timezone.utc)
        except ValueError:
            continue
    raise ValueError(f"Cannot parse timestamp: {ts}")


_GRADE_ORDER = {"A+": 0, "A": 1, "B": 2, "C": 3, "D": 4, "F": 5}


def _grade_to_score(grade: str) -> int:
    """Convert grade to numeric score (lower = better)."""
    return _GRADE_ORDER.get(grade, 5)


def _best_grade(grades: list[str]) -> str:
    """Return the best (lowest index) grade."""
    if not grades:
        return "?"
    return min(grades, key=lambda g: _grade_to_score(g))


def _worst_grade(grades: list[str]) -> str:
    """Return the worst (highest index) grade."""
    if not grades:
        return "?"
    return max(grades, key=lambda g: _grade_to_score(g))


# ---------------------------------------------------------------------------
# Core Functions
# ---------------------------------------------------------------------------

def analyze_history(scans: list[dict[str, Any]]) -> HistoryAnalytics:
    """Analyze scan history for trends and metrics.

    Args:
        scans: List of history entries from history.load_history().
               Each entry: {"timestamp": str, "label": str,
                            "scan": ScanSummary.to_dict()}

    Returns:
        Complete HistoryAnalytics with metrics and trends.
    """
    analytics = HistoryAnalytics()

    if not scans:
        return analytics

    analytics.total_scans = len(scans)

    # Extract time-series data
    timestamps: list[str] = []
    scores: list[int] = []
    grades: list[str] = []
    severity_series: dict[str, list[int]] = {
        "CRITICAL": [], "HIGH": [], "MEDIUM": [], "LOW": [], "INFO": [],
    }
    findings_per_scan: list[int] = []
    rule_sets: list[set[str]] = []

    for entry in scans:
        ts = entry.get("timestamp", "")
        timestamps.append(ts)
        scan = entry.get("scan", {})
        summary = scan.get("summary", {})
        grade = summary.get("overall_grade", "?")
        grades.append(grade)

        total_findings = summary.get("total_findings", 0)
        findings_per_scan.append(total_findings)

        # Compute weighted score from severity counts
        sev_counts = summary.get("severity_counts", {})
        ws = (
            sev_counts.get("CRITICAL", 0) * 10
            + sev_counts.get("HIGH", 0) * 5
            + sev_counts.get("MEDIUM", 0) * 2
            + sev_counts.get("LOW", 0) * 1
        )
        scores.append(ws)

        for cat in severity_series:
            severity_series[cat].append(sev_counts.get(cat, 0))

        # Track unique rules per scan
        rules: set[str] = set()
        for r in scan.get("results", []):
            for f in r.get("findings", []):
                rules.add(f.get("rule_id", ""))
        rule_sets.append(rules)

    analytics.timestamps = timestamps
    analytics.score_history = scores
    analytics.grade_history = grades
    analytics.date_range_start = timestamps[0] if timestamps else ""
    analytics.date_range_end = timestamps[-1] if timestamps else ""
    analytics.current_score = scores[-1] if scores else 0
    analytics.current_grade = grades[-1] if grades else "?"
    analytics.best_grade = _best_grade(grades)
    analytics.worst_grade = _worst_grade(grades)

    # Build finding records by tracking rules across scans
    all_rules: dict[str, FindingRecord] = {}
    for idx, rules in enumerate(rule_sets):
        ts = timestamps[idx] if idx < len(timestamps) else ""
        for rule in rules:
            if not rule:
                continue
            if rule not in all_rules:
                all_rules[rule] = FindingRecord(
                    rule_id=rule, first_seen=ts, severity="MEDIUM"
                )
            all_rules[rule].occurrences += 1
            all_rules[rule].last_seen = ts

    # Mark resolved findings (not in last scan)
    last_rules = rule_sets[-1] if rule_sets else set()
    for rule_id, record in all_rules.items():
        if rule_id not in last_rules and record.occurrences > 0:
            record.resolved = True
            record.resolved_at = record.last_seen

    analytics.finding_records = list(all_rules.values())
    analytics.total_unique_findings = len(all_rules)
    analytics.total_resolved = sum(1 for f in all_rules.values() if f.resolved)
    analytics.total_open = analytics.total_unique_findings - analytics.total_resolved

    # MTTR calculation
    resolve_times: list[float] = []
    for record in all_rules.values():
        ttr = record.time_to_resolve_days
        if ttr is not None:
            resolve_times.append(ttr)
    analytics.mttr_days = (
        sum(resolve_times) / len(resolve_times) if resolve_times else 0.0
    )

    # Recurrence rate: findings that disappear and reappear
    recurrences = 0
    for rule_id, record in all_rules.items():
        if record.occurrences >= 2:
            # Check if there's a gap (appeared, disappeared, reappeared)
            appeared = [rule_id in rs for rs in rule_sets]
            for i in range(1, len(appeared) - 1):
                if appeared[i - 1] and not appeared[i] and appeared[i + 1]:
                    recurrences += 1
                    break
    analytics.recurrence_rate = (
        (recurrences / len(all_rules) * 100) if all_rules else 0.0
    )

    # Fix velocity: average findings fixed per scan transition
    fixes = 0
    transitions = 0
    for i in range(1, len(rule_sets)):
        prev = rule_sets[i - 1]
        curr = rule_sets[i]
        fixed = prev - curr
        fixes += len(fixed)
        transitions += 1
    analytics.fix_velocity = fixes / transitions if transitions > 0 else 0.0

    # Category trends
    for cat, values in severity_series.items():
        analytics.category_trends.append(CategoryTrend(
            category=cat,
            values=values,
            timestamps=timestamps,
        ))

    # Persistent findings (top 10 from top_persistent property)
    analytics.persistent_findings = analytics.top_persistent

    return analytics


def predict_target_date(
    analytics: HistoryAnalytics,
    target_score: int,
) -> Prediction:
    """Predict when a target weighted score will be reached.

    Uses linear regression on score history to estimate.

    Args:
        analytics: HistoryAnalytics from analyze_history().
        target_score: Target weighted score to reach.

    Returns:
        Prediction with estimated date and confidence.
    """
    scores = analytics.score_history
    current = scores[-1] if scores else 0

    # Already at or below target
    if current <= target_score:
        return Prediction(
            target_score=target_score,
            current_score=current,
            predicted_date=analytics.date_range_end,
            days_remaining=0,
            confidence="high",
            achievable=True,
            trend_slope=0.0,
        )

    # Need at least 2 data points for regression
    if len(scores) < 2:
        return Prediction(
            target_score=target_score,
            current_score=current,
            achievable=False,
            confidence="low",
        )

    # Linear regression: score vs scan index
    n = len(scores)
    x_vals = list(range(n))
    x_mean = sum(x_vals) / n
    y_mean = sum(scores) / n

    numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_vals, scores))
    denominator = sum((x - x_mean) ** 2 for x in x_vals)

    if denominator == 0:
        slope = 0.0
    else:
        slope = numerator / denominator

    intercept = y_mean - slope * x_mean

    # If slope is not decreasing, target may not be achievable
    if slope >= 0:
        return Prediction(
            target_score=target_score,
            current_score=current,
            achievable=False,
            confidence="low",
            trend_slope=slope,
        )

    # Estimate scan index where score reaches target
    # target = slope * x + intercept  =>  x = (target - intercept) / slope
    target_x = (target_score - intercept) / slope
    remaining_scans = max(0, target_x - (n - 1))

    # Estimate days between scans
    if len(analytics.timestamps) >= 2:
        try:
            first_ts = _parse_ts(analytics.timestamps[0])
            last_ts = _parse_ts(analytics.timestamps[-1])
            total_days = max(1, (last_ts - first_ts).days)
            days_per_scan = total_days / (len(analytics.timestamps) - 1)
        except (ValueError, ZeroDivisionError):
            days_per_scan = 7  # default
    else:
        days_per_scan = 7

    days_remaining = int(math.ceil(remaining_scans * days_per_scan))

    # Predict date
    try:
        last_date = _parse_ts(analytics.timestamps[-1])
        pred_date = last_date + timedelta(days=days_remaining)
        pred_date_str = pred_date.strftime("%Y-%m-%d")
    except (ValueError, IndexError):
        pred_date_str = ""

    # Confidence based on data points and fit quality
    if n >= 10:
        confidence = "high"
    elif n >= 5:
        confidence = "medium"
    else:
        confidence = "low"

    return Prediction(
        target_score=target_score,
        current_score=current,
        predicted_date=pred_date_str,
        days_remaining=days_remaining,
        confidence=confidence,
        achievable=True,
        trend_slope=slope,
    )


# ---------------------------------------------------------------------------
# Formatters
# ---------------------------------------------------------------------------

def format_analytics_text(analytics: HistoryAnalytics) -> str:
    """Format analytics as human-readable text.

    Args:
        analytics: HistoryAnalytics result.

    Returns:
        Formatted text string.
    """
    lines: list[str] = []
    lines.append(f"\n{'=' * 70}")
    lines.append("  Scan History Analytics")
    lines.append(f"{'=' * 70}")
    lines.append(f"  Total Scans: {analytics.total_scans}")
    lines.append(
        f"  Date Range: {analytics.date_range_start} - {analytics.date_range_end}"
    )
    lines.append(f"  Current Grade: {analytics.current_grade}")
    lines.append(
        f"  Best: {analytics.best_grade} | Worst: {analytics.worst_grade}"
    )
    lines.append("")

    # Metrics
    lines.append("  Key Metrics:")
    lines.append(f"    MTTR: {analytics.mttr_days:.1f} days")
    lines.append(f"    Recurrence Rate: {analytics.recurrence_rate:.1f}%")
    lines.append(f"    Fix Velocity: {analytics.fix_velocity:.2f} findings/scan")
    lines.append(
        f"    Unique Findings: {analytics.total_unique_findings} "
        f"(Open: {analytics.total_open}, Resolved: {analytics.total_resolved})"
    )
    lines.append("")

    # Category trends
    lines.append("  Category Trends:")
    for trend in analytics.category_trends:
        arrow = {
            "improving": "v",
            "degrading": "^",
            "stable": "=",
        }.get(trend.direction, "?")
        lines.append(
            f"    {trend.category:<10} {arrow} {trend.direction:<12} "
            f"({trend.change_pct:+.1f}%)"
        )
    lines.append("")

    # Top persistent findings
    if analytics.top_persistent:
        lines.append("  Top 10 Persistent Findings:")
        for f in analytics.top_persistent:
            lines.append(
                f"    {f.rule_id:<15} seen {f.occurrences}x  "
                f"since {f.first_seen[:10]}"
            )
    lines.append("")

    # Predictions
    if analytics.predictions:
        lines.append("  Predictions:")
        for p in analytics.predictions:
            if p.achievable:
                lines.append(
                    f"    Target score {p.target_score}: "
                    f"~{p.days_remaining} days ({p.predicted_date}) "
                    f"[{p.confidence}]"
                )
            else:
                lines.append(
                    f"    Target score {p.target_score}: not achievable "
                    f"with current trend"
                )

    lines.append(f"\n{'=' * 70}\n")
    return "\n".join(lines)


def format_analytics_json(analytics: HistoryAnalytics) -> str:
    """Format analytics as JSON.

    Args:
        analytics: HistoryAnalytics result.

    Returns:
        JSON string.
    """
    return json.dumps(analytics.to_dict(), indent=2)


def format_analytics_html(analytics: HistoryAnalytics) -> str:
    """Format analytics as HTML dashboard with CSS charts.

    Args:
        analytics: HistoryAnalytics result.

    Returns:
        Complete HTML document string.
    """
    # Score chart bars (CSS-only)
    max_score = max(analytics.score_history) if analytics.score_history else 1
    if max_score == 0:
        max_score = 1
    score_bars = ""
    for i, score in enumerate(analytics.score_history):
        pct = min(100, score / max_score * 100)
        ts = analytics.timestamps[i][:10] if i < len(analytics.timestamps) else ""
        bar_color = _score_color(score)
        score_bars += (
            f'<div class="bar-col">'
            f'<div class="bar" style="height:{pct}%;background:{bar_color}"></div>'
            f'<div class="bar-label">{ts}</div>'
            f'</div>'
        )

    # Grade history
    grade_items = ""
    for i, grade in enumerate(analytics.grade_history):
        gc = _grade_color(grade)
        ts = analytics.timestamps[i][:10] if i < len(analytics.timestamps) else ""
        grade_items += (
            f'<span class="grade-chip" style="background:{gc}">'
            f'{grade}</span> '
        )

    # Category trend rows
    trend_rows = ""
    for trend in analytics.category_trends:
        dir_color = {
            "improving": "#22c55e",
            "degrading": "#ef4444",
            "stable": "#3b82f6",
        }.get(trend.direction, "#6b7280")
        dir_arrow = {
            "improving": "&#8595;",
            "degrading": "&#8593;",
            "stable": "&#8596;",
        }.get(trend.direction, "?")
        last_val = trend.values[-1] if trend.values else 0
        trend_rows += (
            f'<tr>'
            f'<td>{trend.category}</td>'
            f'<td>{last_val}</td>'
            f'<td style="color:{dir_color}">{dir_arrow} {trend.direction}</td>'
            f'<td>{trend.change_pct:+.1f}%</td>'
            f'</tr>'
        )

    # Persistent findings rows
    persistent_rows = ""
    for f in analytics.top_persistent:
        persistent_rows += (
            f'<tr>'
            f'<td>{_escape_html(f.rule_id)}</td>'
            f'<td>{f.occurrences}</td>'
            f'<td>{f.first_seen[:10] if f.first_seen else "-"}</td>'
            f'<td>{f.last_seen[:10] if f.last_seen else "-"}</td>'
            f'<td>{f.severity}</td>'
            f'</tr>'
        )

    # Prediction rows
    prediction_rows = ""
    for p in analytics.predictions:
        if p.achievable:
            prediction_rows += (
                f'<tr>'
                f'<td>{p.target_score}</td>'
                f'<td>{p.current_score}</td>'
                f'<td>{p.predicted_date}</td>'
                f'<td>{p.days_remaining}d</td>'
                f'<td>{p.confidence}</td>'
                f'</tr>'
            )
        else:
            prediction_rows += (
                f'<tr>'
                f'<td>{p.target_score}</td>'
                f'<td>{p.current_score}</td>'
                f'<td colspan="3" style="color:#ef4444">Not achievable</td>'
                f'</tr>'
            )

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scan History Analytics</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
       background: #0f172a; color: #e2e8f0; padding: 24px; line-height: 1.6; }}
h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
h2 {{ font-size: 1.2rem; margin: 24px 0 12px; color: #94a3b8; }}
.subtitle {{ color: #94a3b8; margin-bottom: 24px; }}
.cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
          gap: 12px; margin-bottom: 24px; }}
.card {{ background: #1e293b; border-radius: 10px; padding: 16px; text-align: center; }}
.card-label {{ font-size: 0.7rem; text-transform: uppercase; color: #94a3b8; }}
.card-value {{ font-size: 1.8rem; font-weight: 700; margin-top: 4px; }}
.chart {{ background: #1e293b; border-radius: 10px; padding: 16px;
          margin-bottom: 24px; }}
.bar-chart {{ display: flex; align-items: flex-end; gap: 4px; height: 200px;
              padding: 0 8px; }}
.bar-col {{ flex: 1; display: flex; flex-direction: column; align-items: center;
            height: 100%; justify-content: flex-end; }}
.bar {{ width: 100%; min-width: 8px; border-radius: 3px 3px 0 0;
        transition: height 0.3s; }}
.bar-label {{ font-size: 0.6rem; color: #94a3b8; margin-top: 4px;
              writing-mode: vertical-lr; text-orientation: mixed; }}
.grade-chip {{ display: inline-block; padding: 2px 8px; border-radius: 4px;
               color: #fff; font-size: 0.75rem; font-weight: 700;
               margin: 2px; }}
table {{ width: 100%; border-collapse: collapse; background: #1e293b;
         border-radius: 10px; overflow: hidden; margin-bottom: 16px; }}
th {{ text-align: left; padding: 10px 14px; background: #334155;
      font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; }}
td {{ padding: 8px 14px; border-bottom: 1px solid #334155; font-size: 0.85rem; }}
tr:hover {{ background: #1e3a5f; }}
.footer {{ text-align: center; color: #475569; margin-top: 32px; font-size: 0.8rem; }}
</style>
</head>
<body>
<h1>Scan History Analytics</h1>
<p class="subtitle">
    {analytics.total_scans} scans |
    {analytics.date_range_start[:10]} - {analytics.date_range_end[:10]}
</p>

<div class="cards">
    <div class="card">
        <div class="card-label">Current Grade</div>
        <div class="card-value" style="color:{_grade_color(analytics.current_grade)}">
            {analytics.current_grade}</div>
    </div>
    <div class="card">
        <div class="card-label">MTTR</div>
        <div class="card-value">{analytics.mttr_days:.1f}d</div>
    </div>
    <div class="card">
        <div class="card-label">Recurrence</div>
        <div class="card-value">{analytics.recurrence_rate:.1f}%</div>
    </div>
    <div class="card">
        <div class="card-label">Fix Velocity</div>
        <div class="card-value">{analytics.fix_velocity:.1f}/scan</div>
    </div>
    <div class="card">
        <div class="card-label">Open</div>
        <div class="card-value" style="color:#f97316">{analytics.total_open}</div>
    </div>
    <div class="card">
        <div class="card-label">Resolved</div>
        <div class="card-value" style="color:#22c55e">{analytics.total_resolved}</div>
    </div>
</div>

<h2>Score History</h2>
<div class="chart">
    <div class="bar-chart">{score_bars}</div>
</div>

<h2>Grade History</h2>
<div class="chart" style="padding:16px">
    {grade_items}
</div>

<h2>Category Trends</h2>
<table>
<thead><tr>
    <th>Category</th><th>Current</th><th>Trend</th><th>Change</th>
</tr></thead>
<tbody>{trend_rows}</tbody>
</table>

<h2>Top Persistent Findings</h2>
<table>
<thead><tr>
    <th>Rule</th><th>Occurrences</th><th>First Seen</th>
    <th>Last Seen</th><th>Severity</th>
</tr></thead>
<tbody>{persistent_rows}</tbody>
</table>

<h2>Predictions</h2>
<table>
<thead><tr>
    <th>Target Score</th><th>Current</th><th>Predicted Date</th>
    <th>Days</th><th>Confidence</th>
</tr></thead>
<tbody>{prediction_rows}</tbody>
</table>

<p class="footer">Generated by mcpaudit history analytics</p>
</body>
</html>"""


def _score_color(score: int) -> str:
    """Color for a weighted score."""
    if score <= 2:
        return "#22c55e"
    if score <= 5:
        return "#eab308"
    if score <= 10:
        return "#f97316"
    return "#ef4444"


def _grade_color(grade: str) -> str:
    """Color for a grade."""
    return {
        "A+": "#22c55e", "A": "#22c55e", "B": "#eab308",
        "C": "#f97316", "D": "#ef4444", "F": "#dc2626",
    }.get(grade, "#6b7280")


def _escape_html(text: str) -> str:
    """Escape HTML special characters."""
    return (
        text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
    )
