"""Generates a self-contained HTML dashboard from pipeline data.

The dashboard is a single static file with data baked in as a JSON blob and
client-side rendering via Chart.js. It's intended to be regenerated by the
weekly cron alongside the Sheets writes — open the file in any browser.
"""

import json
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from zoneinfo import ZoneInfo

from models import Person


def write_dashboard(people: list[Person], config: dict, tz: ZoneInfo) -> str:
    """Render the dashboard HTML and write it to dashboard.output_path. Returns the path."""
    dash_config = config.get("dashboard", {}) or {}
    output_path = dash_config.get("output_path", "dashboard.html")
    deal_label = dash_config.get("deal_label", "deal")
    deal_launch = _parse_launch_date(dash_config.get("deal_launch_date"), tz)

    data = _build_data(people, deal_launch, deal_label, tz)
    html = _render(data)
    Path(output_path).write_text(html, encoding="utf-8")
    return output_path


def _parse_launch_date(value: Any, tz: ZoneInfo) -> Optional[datetime]:
    if not value:
        return None
    if isinstance(value, datetime):
        return value if value.tzinfo else value.replace(tzinfo=tz)
    if isinstance(value, date):
        return datetime(value.year, value.month, value.day, tzinfo=tz)
    if isinstance(value, str):
        return datetime.fromisoformat(value).replace(tzinfo=tz)
    return None


def _build_data(
    people: list[Person],
    deal_launch: Optional[datetime],
    deal_label: str,
    tz: ZoneInfo,
) -> dict[str, Any]:
    now = datetime.now(timezone.utc)

    has_newsletter = sum(1 for p in people if p.newsletter_subscribed_at)
    has_newspaper = sum(1 for p in people if p.newspaper_subscribed_at)
    both = sum(
        1 for p in people if p.newsletter_subscribed_at and p.newspaper_subscribed_at
    )
    active_newspaper = sum(
        1 for p in people if p.newspaper_subscription_status == "active"
    )
    nl_to_np_rate = (100 * both / has_newsletter) if has_newsletter else 0.0

    deal_people_all = [p for p in people if p.deal_started_at is not None]
    if deal_launch:
        deal_cohort = [
            p for p in deal_people_all if p.deal_started_at >= deal_launch
        ]
        pre_launch = [
            p for p in deal_people_all if p.deal_started_at < deal_launch
        ]
    else:
        deal_cohort = deal_people_all
        pre_launch = []

    in_trial = [p for p in deal_cohort if p.deal_outcome == "in_trial"]
    converted = [p for p in deal_cohort if p.deal_outcome == "converted"]
    lost = [p for p in deal_cohort if p.deal_outcome == "lost"]
    completed = [
        p for p in deal_cohort if p.deal_trial_end and p.deal_trial_end < now
    ]
    completed_converted = [p for p in completed if p.deal_outcome == "converted"]
    trial_conversion_rate = (
        100 * len(completed_converted) / len(completed) if completed else 0.0
    )

    today_local = datetime.now(tz).date()
    end_week = _iso_week_start(today_local)
    start_week = end_week - timedelta(weeks=52)

    weekly: dict[date, dict[str, int]] = defaultdict(
        lambda: {"newsletter": 0, "newspaper": 0, "deal": 0}
    )
    for p in people:
        for field, key in (
            (p.newsletter_subscribed_at, "newsletter"),
            (p.newspaper_subscribed_at, "newspaper"),
            (p.deal_started_at, "deal"),
        ):
            if field is None:
                continue
            wk = _iso_week_start(field.astimezone(tz).date())
            if start_week <= wk <= end_week:
                weekly[wk][key] += 1

    weeks_data = []
    cur = start_week
    while cur <= end_week:
        d = weekly[cur]
        weeks_data.append(
            {
                "week": cur.isoformat(),
                "newsletter": d["newsletter"],
                "newspaper": d["newspaper"],
                "deal": d["deal"],
            }
        )
        cur += timedelta(weeks=1)

    deal_launch_week = (
        _iso_week_start(deal_launch.astimezone(tz).date()).isoformat()
        if deal_launch
        else None
    )

    table_rows = []
    for p in people:
        table_rows.append(
            {
                "e": p.email,
                "ft": _iso(p.first_touch_at),
                "fs": p.first_touch_source,
                "nl": _iso(p.newsletter_subscribed_at),
                "np": _iso(p.newspaper_subscribed_at),
                "ns": p.newspaper_subscription_status or "",
                "dn": (
                    round(p.days_to_newspaper, 1)
                    if p.days_to_newspaper is not None
                    else None
                ),
                "ds": _iso(p.deal_started_at),
                "do": p.deal_outcome or "",
            }
        )

    return {
        "generated_at": now.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S %Z"),
        "deal_label": deal_label,
        "deal_launch": deal_launch.strftime("%Y-%m-%d") if deal_launch else None,
        "deal_launch_week": deal_launch_week,
        "kpis": {
            "newsletter": has_newsletter,
            "newspaper": has_newspaper,
            "active_newspaper": active_newspaper,
            "newsletter_to_newspaper_rate": round(nl_to_np_rate, 1),
            "deal_total": len(deal_cohort),
            "deal_in_trial": len(in_trial),
            "deal_converted": len(converted),
            "deal_lost": len(lost),
            "deal_trial_conversion_rate": round(trial_conversion_rate, 1),
            "deal_completed_trials": len(completed),
            "deal_pre_launch": len(pre_launch),
        },
        "weekly": weeks_data,
        "people": table_rows,
    }


def _iso_week_start(d: date) -> date:
    return d - timedelta(days=d.weekday())


def _iso(dt: Optional[datetime]) -> Optional[str]:
    return dt.isoformat() if dt else None


def _render(data: dict) -> str:
    payload = json.dumps(data, default=str).replace("</", "<\\/")
    return _TEMPLATE.replace("__DATA__", payload)


_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Funnel Tracker - Lewiston Tribune</title>
<style>
  * { box-sizing: border-box; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    max-width: 1400px;
    margin: 0 auto;
    padding: 24px;
    color: #1a1a1a;
    background: #fafafa;
  }
  header { border-bottom: 2px solid #d0d0d0; padding-bottom: 12px; margin-bottom: 24px; }
  h1 { font-size: 26px; margin: 0; font-weight: 600; }
  h2 { font-size: 18px; margin: 32px 0 12px; font-weight: 600; }
  .muted { color: #666; font-size: 13px; }
  .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; }
  .kpi {
    background: #fff;
    padding: 16px;
    border-radius: 6px;
    border: 1px solid #e0e0e0;
    box-shadow: 0 1px 2px rgba(0,0,0,0.04);
  }
  .kpi-label { font-size: 11px; text-transform: uppercase; color: #666; letter-spacing: 0.5px; }
  .kpi-value { font-size: 30px; font-weight: 700; margin-top: 4px; line-height: 1.1; }
  .kpi-sub { font-size: 11px; color: #888; margin-top: 4px; }
  .chart-container { background: #fff; padding: 16px; border-radius: 6px; border: 1px solid #e0e0e0; height: 380px; }
  .filters { display: flex; gap: 14px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
  .filters input[type="date"], .filters button { padding: 6px 10px; font-size: 13px; border: 1px solid #ccc; border-radius: 4px; background: #fff; }
  .filters button { cursor: pointer; }
  .filters button:hover { background: #f0f0f0; }
  table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; }
  th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eee; }
  th { background: #f4f4f4; font-weight: 600; font-size: 12px; }
  tbody tr:hover { background: #f9f9f9; }
  .pagination { margin: 14px 0; display: flex; gap: 12px; align-items: center; }
  .pagination button { padding: 6px 14px; font-size: 13px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
  .pagination button:disabled { opacity: 0.4; cursor: default; }
  .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }
  .badge-converted { background: #d4edda; color: #155724; }
  .badge-in_trial { background: #fff3cd; color: #856404; }
  .badge-lost { background: #f8d7da; color: #721c24; }
  .badge-other { background: #e2e3e5; color: #383d41; }
  .badge-active { background: #d4edda; color: #155724; }
  .badge-canceled { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<header>
  <h1>Lewiston Tribune Funnel Tracker</h1>
  <div class="muted" id="last-updated"></div>
</header>

<section>
  <h2>Two-week deal performance</h2>
  <div class="muted" id="deal-context" style="margin-bottom:10px"></div>
  <div class="kpis">
    <div class="kpi"><div class="kpi-label">Deal signups</div><div class="kpi-value" id="kpi-deal-total"></div><div class="kpi-sub">Since launch</div></div>
    <div class="kpi"><div class="kpi-label">Currently in trial</div><div class="kpi-value" id="kpi-deal-in-trial"></div><div class="kpi-sub">Within 14-day window</div></div>
    <div class="kpi"><div class="kpi-label">Converted</div><div class="kpi-value" id="kpi-deal-converted"></div><div class="kpi-sub">Past trial, still active</div></div>
    <div class="kpi"><div class="kpi-label">Lost</div><div class="kpi-value" id="kpi-deal-lost"></div><div class="kpi-sub">Canceled</div></div>
    <div class="kpi"><div class="kpi-label">Trial conversion rate</div><div class="kpi-value" id="kpi-deal-rate"></div><div class="kpi-sub" id="kpi-deal-rate-sub"></div></div>
  </div>
</section>

<section>
  <h2>Overall funnel</h2>
  <div class="kpis">
    <div class="kpi"><div class="kpi-label">Newsletter subscribers</div><div class="kpi-value" id="kpi-newsletter"></div></div>
    <div class="kpi"><div class="kpi-label">Newspaper subscribers (all-time)</div><div class="kpi-value" id="kpi-newspaper"></div></div>
    <div class="kpi"><div class="kpi-label">Active newspaper subscribers</div><div class="kpi-value" id="kpi-active-newspaper"></div></div>
    <div class="kpi"><div class="kpi-label">Newsletter -> newspaper rate</div><div class="kpi-value" id="kpi-conversion"></div></div>
  </div>
</section>

<section>
  <h2>Weekly signups (last 12 months)</h2>
  <div class="chart-container"><canvas id="weekly-chart"></canvas></div>
</section>

<section>
  <h2>People</h2>
  <div class="filters">
    <label>From <input type="date" id="from-date"></label>
    <label>To <input type="date" id="to-date"></label>
    <label><input type="checkbox" id="deal-only"> Deal subscribers only</label>
    <button id="reset-filter">Reset</button>
    <span id="filter-count" class="muted"></span>
  </div>
  <table id="people-table">
    <thead>
      <tr>
        <th>Email</th>
        <th>First touch</th>
        <th>Source</th>
        <th>Newsletter</th>
        <th>Newspaper</th>
        <th>Status</th>
        <th>Days to newspaper</th>
        <th>Deal outcome</th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>
  <div class="pagination">
    <button id="prev-page">Previous</button>
    <span id="page-info" class="muted"></span>
    <button id="next-page">Next</button>
  </div>
</section>

<script id="data" type="application/json">__DATA__</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script>
<script>
  const DATA = JSON.parse(document.getElementById('data').textContent);
  const k = DATA.kpis;

  document.getElementById('last-updated').textContent = 'Last updated: ' + DATA.generated_at;
  document.getElementById('deal-context').textContent =
    'Tracking ' + (DATA.deal_label || 'deal') +
    (DATA.deal_launch ? ' - launched ' + DATA.deal_launch + '. KPIs below count subscribers since launch only.' : '') +
    (k.deal_pre_launch > 0 ? ' Note: ' + k.deal_pre_launch + ' subscriber(s) on this price predate the launch and are excluded from KPIs (visible on the chart).' : '');

  const fmt = n => Number(n).toLocaleString();
  document.getElementById('kpi-deal-total').textContent = fmt(k.deal_total);
  document.getElementById('kpi-deal-in-trial').textContent = fmt(k.deal_in_trial);
  document.getElementById('kpi-deal-converted').textContent = fmt(k.deal_converted);
  document.getElementById('kpi-deal-lost').textContent = fmt(k.deal_lost);
  document.getElementById('kpi-deal-rate').textContent =
    k.deal_completed_trials > 0 ? k.deal_trial_conversion_rate + '%' : '-';
  document.getElementById('kpi-deal-rate-sub').textContent =
    'Among ' + k.deal_completed_trials + ' completed trials';

  document.getElementById('kpi-newsletter').textContent = fmt(k.newsletter);
  document.getElementById('kpi-newspaper').textContent = fmt(k.newspaper);
  document.getElementById('kpi-active-newspaper').textContent = fmt(k.active_newspaper);
  document.getElementById('kpi-conversion').textContent = k.newsletter_to_newspaper_rate + '%';

  const annotations = {};
  if (DATA.deal_launch_week) {
    annotations.dealLaunch = {
      type: 'line',
      xMin: DATA.deal_launch_week,
      xMax: DATA.deal_launch_week,
      borderColor: '#444',
      borderWidth: 2,
      borderDash: [6, 4],
      label: {
        content: 'Deal launch (' + DATA.deal_launch + ')',
        display: true,
        position: 'start',
        backgroundColor: 'rgba(0,0,0,0.7)',
        color: '#fff',
        font: { size: 11 }
      }
    };
  }

  new Chart(document.getElementById('weekly-chart'), {
    type: 'line',
    data: {
      labels: DATA.weekly.map(w => w.week),
      datasets: [
        { label: 'Newsletter', data: DATA.weekly.map(w => w.newsletter), borderColor: '#4a90e2', backgroundColor: '#4a90e220', tension: 0.2, fill: false },
        { label: 'Newspaper', data: DATA.weekly.map(w => w.newspaper), borderColor: '#e85a4f', backgroundColor: '#e85a4f20', tension: 0.2, fill: false },
        { label: 'Deal', data: DATA.weekly.map(w => w.deal), borderColor: '#7cb342', backgroundColor: '#7cb34220', tension: 0.2, fill: false }
      ]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: 'index', intersect: false },
      plugins: {
        legend: { position: 'top' },
        annotation: { annotations: annotations }
      },
      scales: {
        x: { ticks: { autoSkip: true, maxTicksLimit: 13 } },
        y: { beginAtZero: true }
      }
    }
  });

  const PAGE_SIZE = 50;
  let filtered = DATA.people.slice();
  let page = 0;

  function applyFilters() {
    const fromStr = document.getElementById('from-date').value;
    const toStr = document.getElementById('to-date').value;
    const dealOnly = document.getElementById('deal-only').checked;
    const fromMs = fromStr ? new Date(fromStr).getTime() : -Infinity;
    const toMs = toStr ? new Date(toStr).getTime() + 86400000 : Infinity;

    filtered = DATA.people.filter(p => {
      if (dealOnly && !p.ds) return false;
      if (!p.ft) return !fromStr && !toStr;
      const t = new Date(p.ft).getTime();
      return t >= fromMs && t < toMs;
    });
    filtered.sort((a, b) => (b.ft || '').localeCompare(a.ft || ''));
    page = 0;
    renderTable();
  }

  function renderTable() {
    const tbody = document.querySelector('#people-table tbody');
    tbody.innerHTML = '';
    const start = page * PAGE_SIZE;
    const slice = filtered.slice(start, start + PAGE_SIZE);
    for (const p of slice) {
      const tr = document.createElement('tr');
      tr.innerHTML = ''
        + '<td>' + esc(p.e) + '</td>'
        + '<td>' + dt(p.ft) + '</td>'
        + '<td>' + esc(p.fs) + '</td>'
        + '<td>' + dt(p.nl) + '</td>'
        + '<td>' + dt(p.np) + '</td>'
        + '<td>' + statusBadge(p.ns) + '</td>'
        + '<td>' + (p.dn != null ? p.dn.toFixed(1) : '') + '</td>'
        + '<td>' + outcomeBadge(p.do) + '</td>';
      tbody.appendChild(tr);
    }
    document.getElementById('filter-count').textContent =
      filtered.length.toLocaleString() + ' matching';
    document.getElementById('page-info').textContent =
      'Page ' + (page + 1) + ' of ' + Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
    document.getElementById('prev-page').disabled = page === 0;
    document.getElementById('next-page').disabled = (page + 1) * PAGE_SIZE >= filtered.length;
  }

  function dt(iso) {
    if (!iso) return '';
    const d = new Date(iso);
    if (isNaN(d.getTime())) return '';
    return d.toLocaleDateString();
  }
  function esc(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }
  function statusBadge(s) {
    if (!s) return '';
    const cls = s === 'active' ? 'badge-active' : (s === 'canceled' ? 'badge-canceled' : 'badge-other');
    return '<span class="badge ' + cls + '">' + esc(s) + '</span>';
  }
  function outcomeBadge(o) {
    if (!o) return '';
    return '<span class="badge badge-' + esc(o) + '">' + esc(o.replace('_', ' ')) + '</span>';
  }

  document.getElementById('from-date').addEventListener('change', applyFilters);
  document.getElementById('to-date').addEventListener('change', applyFilters);
  document.getElementById('deal-only').addEventListener('change', applyFilters);
  document.getElementById('reset-filter').addEventListener('click', () => {
    document.getElementById('from-date').value = '';
    document.getElementById('to-date').value = '';
    document.getElementById('deal-only').checked = false;
    applyFilters();
  });
  document.getElementById('prev-page').addEventListener('click', () => {
    if (page > 0) { page--; renderTable(); }
  });
  document.getElementById('next-page').addEventListener('click', () => {
    if ((page + 1) * PAGE_SIZE < filtered.length) { page++; renderTable(); }
  });

  // Default: last 90 days
  const today = new Date();
  const ninetyAgo = new Date(today.getTime() - 90 * 86400000);
  document.getElementById('to-date').value = today.toISOString().slice(0, 10);
  document.getElementById('from-date').value = ninetyAgo.toISOString().slice(0, 10);
  applyFilters();
</script>
</body>
</html>
"""
