#!/usr/bin/env python3
"""
Generate a print-friendly PDF report from the latest test-webserver log file.

Usage:
    python3 generate_log_report.py [optional_log_file_path]

If no path is given, the script finds the latest test-webserver-*.jsonl
file in ../cw-api-logs/.
"""

import json
import os
import sys
import glob
from datetime import datetime, timezone
from collections import Counter, defaultdict

from reportlab.lib import colors
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.platypus import (
    SimpleDocTemplate,
    Paragraph,
    Spacer,
    Table,
    TableStyle,
    PageBreak,
    HRFlowable,
    KeepTogether,
)
from reportlab.graphics.shapes import Drawing, String
from reportlab.graphics.charts.piecharts import Pie
from reportlab.graphics.charts.barcharts import VerticalBarChart

# ─── Print-friendly color palette ─────────────────────────────────────────────
# Minimal ink: white backgrounds, thin borders, dark text, subtle accents
HEADER_BG    = colors.HexColor("#2c3e50")   # Dark header (used sparingly)
ACCENT       = colors.HexColor("#2980b9")   # Muted blue
ACCENT_2     = colors.HexColor("#27ae60")   # Muted green
ACCENT_3     = colors.HexColor("#8e44ad")   # Muted purple
WHITE        = colors.white
GRAY_50      = colors.HexColor("#fafafa")
GRAY_100     = colors.HexColor("#f5f5f5")
GRAY_200     = colors.HexColor("#e0e0e0")
GRAY_400     = colors.HexColor("#bdbdbd")
GRAY_600     = colors.HexColor("#757575")
GRAY_800     = colors.HexColor("#424242")
GRAY_900     = colors.HexColor("#212121")

# Pie/chart fills — muted, distinguishable in B&W too
PIE_COLORS = [
    colors.HexColor("#5b9bd5"),  # steel blue
    colors.HexColor("#ed7d31"),  # soft orange
    colors.HexColor("#a5a5a5"),  # gray
    colors.HexColor("#ffc000"),  # amber
    colors.HexColor("#70ad47"),  # olive green
    colors.HexColor("#4472c4"),  # darker blue
    colors.HexColor("#c55a11"),  # brown
    colors.HexColor("#7030a0"),  # purple
]

# ─── Helpers ──────────────────────────────────────────────────────────────────

def find_latest_log(base_dir):
    pattern = os.path.join(base_dir, "test-webserver-*.jsonl")
    files = sorted(glob.glob(pattern))
    if not files:
        raise FileNotFoundError(f"No test-webserver log files found in {base_dir}")
    return files[-1]


def parse_log(path):
    entries = []
    with open(path, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entries.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return entries


def fmt_ts(iso_str):
    try:
        dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
        return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
    except Exception:
        return str(iso_str)


def duration_str(seconds):
    if seconds < 60:
        return f"{seconds:.1f}s"
    minutes = seconds / 60
    if minutes < 60:
        return f"{minutes:.1f}m"
    hours = minutes / 60
    return f"{hours:.1f}h"


def truncate(s, max_len=50):
    s = str(s)
    return s if len(s) <= max_len else s[: max_len - 3] + "..."


def resolve_actor(entry):
    """
    Derive the actor exactly like testWebserver.ts does:
      entityUpdatedBy ?? query.params.memberId ?? summary.memberId ?? "-"
    The summary is already stored in request.summary in the log.
    """
    req = entry.get("request", {})
    summary = req.get("summary") or {}
    query = req.get("query") or {}
    params = query.get("params") or {}

    return str(
        summary.get("entityUpdatedBy")
        or params.get("memberId")
        or summary.get("memberId")
        or "-"
    )


# ─── Analysis ─────────────────────────────────────────────────────────────────

def analyze(entries):
    stats = {}

    timestamps = []
    for e in entries:
        ts = e.get("timestamp")
        if ts:
            try:
                timestamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
            except Exception:
                pass

    timestamps.sort()
    stats["total_entries"] = len(entries)
    stats["first_ts"] = timestamps[0] if timestamps else None
    stats["last_ts"] = timestamps[-1] if timestamps else None
    stats["duration_seconds"] = (
        (timestamps[-1] - timestamps[0]).total_seconds() if len(timestamps) >= 2 else 0
    )

    # Global counters
    methods = Counter()
    paths = Counter()
    statuses = Counter()
    actions = Counter()
    types = Counter()
    actors = Counter()
    companies = Counter()
    entity_ids = set()
    stages = Counter()
    ratings = Counter()
    hourly_buckets = Counter()
    minute_buckets = Counter()

    for e in entries:
        req = e.get("request", {})
        resp = e.get("response", {})
        bp = req.get("bodyParsed") or {}
        entity = req.get("entityParsed") or bp.get("Entity") or {}

        methods[req.get("method", "?")] += 1
        raw_path = req.get("path", "?").split("?")[0]
        paths[raw_path] += 1
        statuses[resp.get("status", "?")] += 1
        actions[bp.get("Action", "?")] += 1
        types[bp.get("Type", "?")] += 1

        actor = resolve_actor(e)
        actors[actor] += 1

        if isinstance(entity, dict):
            cn = entity.get("CompanyName")
            if cn:
                companies[cn] += 1
            eid = entity.get("Id")
            if eid is not None:
                entity_ids.add(eid)
            stage = entity.get("StageName")
            if stage:
                stages[stage] += 1
            rating = entity.get("Rating")
            if rating:
                ratings[rating] += 1

        ts_str = e.get("timestamp")
        if ts_str:
            try:
                dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
                hourly_buckets[dt.strftime("%H:00")] += 1
                minute_buckets[dt.strftime("%H:%M")] += 1
            except Exception:
                pass

    # ── Per-actor deep stats ──
    actor_details = defaultdict(lambda: {
        "count": 0,
        "actions": Counter(),
        "types": Counter(),
        "companies": Counter(),
        "entity_ids": set(),
        "stages": Counter(),
        "ratings": Counter(),
        "timestamps": [],
        "statuses": Counter(),
        "paths": Counter(),
        "member_ids": Counter(),
        "sales_reps": Counter(),
        "total_estimated": 0.0,
    })

    for e in entries:
        req = e.get("request", {})
        resp = e.get("response", {})
        bp = req.get("bodyParsed") or {}
        entity = req.get("entityParsed") or bp.get("Entity") or {}
        summary = req.get("summary") or {}

        actor = resolve_actor(e)
        ad = actor_details[actor]
        ad["count"] += 1
        ad["actions"][bp.get("Action", "?")] += 1
        ad["types"][bp.get("Type", "?")] += 1
        ad["statuses"][resp.get("status", "?")] += 1
        raw_path = req.get("path", "?").split("?")[0]
        ad["paths"][raw_path] += 1

        # Track which MemberIds triggered callbacks for this actor
        mid = bp.get("MemberId")
        if mid:
            ad["member_ids"][mid] += 1

        ts_str = e.get("timestamp")
        if ts_str:
            try:
                ad["timestamps"].append(datetime.fromisoformat(ts_str.replace("Z", "+00:00")))
            except Exception:
                pass

        if isinstance(entity, dict):
            cn = entity.get("CompanyName")
            if cn:
                ad["companies"][cn] += 1
            eid = entity.get("Id")
            if eid is not None:
                ad["entity_ids"].add(eid)
            stage = entity.get("StageName")
            if stage:
                ad["stages"][stage] += 1
            rating = entity.get("Rating")
            if rating:
                ad["ratings"][rating] += 1
            et = entity.get("EstimatedTotal")
            if et is not None:
                ad["total_estimated"] += float(et)
            pr = entity.get("PrimarySalesRep")
            if pr:
                ad["sales_reps"][pr] += 1

    # Compute per-actor derived stats
    for aid, ad in actor_details.items():
        ad["timestamps"].sort()
        if len(ad["timestamps"]) >= 2:
            dur = (ad["timestamps"][-1] - ad["timestamps"][0]).total_seconds()
            ad["duration_seconds"] = dur
            ad["events_per_minute"] = ad["count"] / (dur / 60) if dur > 0 else ad["count"]
        else:
            ad["duration_seconds"] = 0
            ad["events_per_minute"] = ad["count"]
        ad["first_ts"] = ad["timestamps"][0] if ad["timestamps"] else None
        ad["last_ts"] = ad["timestamps"][-1] if ad["timestamps"] else None

    stats["actor_details"] = dict(actor_details)
    stats["methods"] = methods
    stats["paths"] = paths
    stats["statuses"] = statuses
    stats["actions"] = actions
    stats["types"] = types
    stats["actors"] = actors
    stats["companies"] = companies
    stats["entity_ids"] = entity_ids
    stats["stages"] = stages
    stats["ratings"] = ratings
    stats["hourly_buckets"] = hourly_buckets
    stats["minute_buckets"] = minute_buckets

    if stats["duration_seconds"] > 0:
        stats["events_per_minute"] = len(entries) / (stats["duration_seconds"] / 60)
    else:
        stats["events_per_minute"] = len(entries)

    return stats


# ─── PDF building ─────────────────────────────────────────────────────────────

def build_styles():
    ss = getSampleStyleSheet()
    ss.add(ParagraphStyle(
        "ReportTitle", parent=ss["Title"], fontSize=24, textColor=WHITE,
        spaceAfter=4, fontName="Helvetica-Bold", alignment=TA_CENTER,
    ))
    ss.add(ParagraphStyle(
        "ReportSubtitle", parent=ss["Normal"], fontSize=11, textColor=GRAY_400,
        spaceAfter=2, fontName="Helvetica", alignment=TA_CENTER,
    ))
    ss.add(ParagraphStyle(
        "SectionHeader", parent=ss["Heading1"], fontSize=16, textColor=GRAY_900,
        spaceBefore=14, spaceAfter=6, fontName="Helvetica-Bold",
    ))
    ss.add(ParagraphStyle(
        "SubHeader", parent=ss["Heading2"], fontSize=12, textColor=GRAY_800,
        spaceBefore=10, spaceAfter=4, fontName="Helvetica-Bold",
    ))
    ss.add(ParagraphStyle(
        "BodyText2", parent=ss["Normal"], fontSize=9, textColor=GRAY_800,
        spaceAfter=3, fontName="Helvetica", leading=13,
    ))
    ss.add(ParagraphStyle(
        "SmallGray", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
        spaceAfter=2, fontName="Helvetica",
    ))
    ss.add(ParagraphStyle(
        "KPIValue", parent=ss["Normal"], fontSize=20, textColor=GRAY_900,
        fontName="Helvetica-Bold", alignment=TA_CENTER, leading=24,
    ))
    ss.add(ParagraphStyle(
        "KPILabel", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
        fontName="Helvetica", alignment=TA_CENTER, spaceAfter=2,
    ))
    ss.add(ParagraphStyle(
        "BannerText", parent=ss["Normal"], fontSize=9, textColor=WHITE,
        fontName="Helvetica-Bold", spaceAfter=1,
    ))
    return ss


def make_header_banner(ss, log_path, stats):
    elements = []
    fname = os.path.basename(log_path)
    banner_data = [[
        Paragraph("Webhook Log Report", ss["ReportTitle"]),
    ], [
        Paragraph(fname, ss["ReportSubtitle"]),
    ], [
        Paragraph(
            f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
            ss["ReportSubtitle"],
        ),
    ]]
    banner = Table(banner_data, colWidths=[7.0 * inch])
    banner.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
        ("ALIGN", (0, 0), (-1, -1), "CENTER"),
        ("TOPPADDING", (0, 0), (0, 0), 20),
        ("BOTTOMPADDING", (0, -1), (-1, -1), 16),
        ("LEFTPADDING", (0, 0), (-1, -1), 20),
        ("RIGHTPADDING", (0, 0), (-1, -1), 20),
    ]))
    elements.append(banner)
    elements.append(Spacer(1, 14))
    return elements


def make_kpi_card(label, value):
    ss = build_styles()
    card_data = [[
        Paragraph(str(value), ss["KPIValue"]),
    ], [
        Paragraph(label, ss["KPILabel"]),
    ]]
    card = Table(card_data, colWidths=[1.6 * inch], rowHeights=[28, 16])
    card.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, -1), GRAY_100),
        ("ALIGN", (0, 0), (-1, -1), "CENTER"),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ("TOPPADDING", (0, 0), (-1, -1), 6),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
        ("BOX", (0, 0), (-1, -1), 0.5, GRAY_200),
    ]))
    return card


def make_kpi_row(cards_data):
    cards = [make_kpi_card(label, value) for label, value in cards_data]
    row = Table([cards], colWidths=[1.75 * inch] * len(cards))
    row.setStyle(TableStyle([
        ("ALIGN", (0, 0), (-1, -1), "CENTER"),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
    ]))
    return row


def make_table(title, counter, ss, max_rows=15):
    """Generic table from a Counter — light styling for print."""
    elements = []
    elements.append(Paragraph(title, ss["SubHeader"]))

    items = counter.most_common(max_rows)
    if not items:
        elements.append(Paragraph("<i>No data</i>", ss["BodyText2"]))
        return elements

    total = sum(counter.values())
    header = ["Item", "Count", "%"]
    rows = [header]
    for name, count in items:
        pct = (count / total * 100) if total else 0
        rows.append([truncate(str(name), 45), f"{count:,}", f"{pct:.1f}%"])

    t = Table(rows, colWidths=[3.6 * inch, 1.0 * inch, 0.8 * inch])
    t.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
        ("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
        ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
        ("FONTSIZE", (0, 0), (-1, 0), 9),
        ("FONTSIZE", (0, 1), (-1, -1), 9),
        ("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
        ("BOTTOMPADDING", (0, 0), (-1, 0), 6),
        ("TOPPADDING", (0, 0), (-1, 0), 6),
        ("GRID", (0, 0), (-1, -1), 0.4, GRAY_200),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
        ("ALIGN", (1, 0), (2, -1), "CENTER"),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 8),
        ("TOPPADDING", (0, 1), (-1, -1), 3),
        ("BOTTOMPADDING", (0, 1), (-1, -1), 3),
    ]))
    elements.append(t)
    return elements


def make_pie_chart(title, counter, width=280, height=190):
    items = counter.most_common(8)
    if not items:
        return Spacer(1, 1)

    d = Drawing(width, height)
    d.add(String(width / 2, height - 12, title,
                 fontSize=10, fontName="Helvetica-Bold",
                 fillColor=GRAY_900, textAnchor="middle"))

    pie = Pie()
    pie.x = 50
    pie.y = 10
    pie.width = 110
    pie.height = 110
    pie.data = [v for _, v in items]
    pie.labels = [truncate(str(k), 18) for k, _ in items]
    pie.sideLabels = True
    pie.slices.strokeWidth = 0.5
    pie.slices.strokeColor = WHITE

    for i in range(len(items)):
        pie.slices[i].fillColor = PIE_COLORS[i % len(PIE_COLORS)]
        pie.slices[i].fontName = "Helvetica"
        pie.slices[i].fontSize = 7
        pie.slices[i].labelRadius = 1.35

    d.add(pie)
    return d


def make_timeline_chart(minute_buckets, width=500, height=150):
    if not minute_buckets:
        return Spacer(1, 1)

    sorted_keys = sorted(minute_buckets.keys())
    if len(sorted_keys) > 40:
        step = max(1, len(sorted_keys) // 40)
        sampled_keys = sorted_keys[::step]
    else:
        sampled_keys = sorted_keys

    values = [minute_buckets[k] for k in sampled_keys]

    d = Drawing(width, height)
    d.add(String(width / 2, height - 10, "Event Timeline (by minute)",
                 fontSize=10, fontName="Helvetica-Bold",
                 fillColor=GRAY_900, textAnchor="middle"))

    chart = VerticalBarChart()
    chart.x = 50
    chart.y = 25
    chart.width = width - 80
    chart.height = height - 50
    chart.data = [values]
    chart.categoryAxis.categoryNames = sampled_keys
    chart.categoryAxis.labels.angle = 45
    chart.categoryAxis.labels.fontSize = 6
    chart.categoryAxis.labels.fontName = "Helvetica"
    chart.categoryAxis.labels.dy = -5
    chart.valueAxis.labels.fontSize = 7
    chart.valueAxis.labels.fontName = "Helvetica"
    chart.valueAxis.valueMin = 0
    chart.bars[0].fillColor = GRAY_600
    chart.bars[0].strokeColor = None
    chart.barWidth = max(2, int((width - 100) / len(values) * 0.7))

    d.add(chart)
    return d


def build_actor_activity_section(stats, ss):
    """Per-actor deep-dive. Actor = entityUpdatedBy ?? query.memberId ?? summary.memberId."""
    elements = []
    elements.append(PageBreak())
    elements.append(Paragraph("Actor Activity Deep Dive", ss["SectionHeader"]))
    elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=4))
    elements.append(Paragraph(
        'The <b>actor</b> is resolved as: <i>entityUpdatedBy → query.memberId → summary.memberId</i>. '
        'This is the person or system that caused the change in ConnectWise.',
        ss["SmallGray"],
    ))
    elements.append(Spacer(1, 10))

    actor_details = stats.get("actor_details", {})
    if not actor_details:
        elements.append(Paragraph("<i>No actor data available.</i>", ss["BodyText2"]))
        return elements

    sorted_actors = sorted(actor_details.items(), key=lambda x: -x[1]["count"])

    # Actor distribution pie chart
    actor_counter = Counter({aid: ad["count"] for aid, ad in sorted_actors})
    elements.append(make_pie_chart("Events by Actor", actor_counter, width=350, height=210))
    elements.append(Spacer(1, 10))

    for idx, (aid, ad) in enumerate(sorted_actors):
        # Actor header — slim dark bar
        banner_data = [[
            Paragraph(
                f'<font size="12"><b>{aid}</b></font>'
                f'&nbsp;&nbsp;&nbsp;'
                f'<font size="9" color="#cccccc">{ad["count"]:,} events</font>',
                ss["BannerText"],
            ),
        ]]
        banner = Table(banner_data, colWidths=[7.0 * inch])
        banner.setStyle(TableStyle([
            ("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
            ("TOPPADDING", (0, 0), (-1, -1), 7),
            ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
            ("LEFTPADDING", (0, 0), (-1, -1), 12),
        ]))
        elements.append(banner)

        # KPI row
        kpi = make_kpi_row([
            ("Events", f"{ad['count']:,}"),
            ("Entities", f"{len(ad['entity_ids']):,}"),
            ("Companies", f"{len(ad['companies']):,}"),
            ("Evts/Min", f"{ad['events_per_minute']:.1f}"),
        ])
        elements.append(kpi)
        elements.append(Spacer(1, 4))

        # Info grid
        first_str = ad["first_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["first_ts"] else "—"
        last_str = ad["last_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["last_ts"] else "—"
        dur = duration_str(ad["duration_seconds"])
        est = ad["total_estimated"]

        mid_str = ", ".join(f"{k} ({v})" for k, v in ad["member_ids"].most_common(5)) if ad["member_ids"] else "—"
        sr_str = ", ".join(f"{k} ({v})" for k, v in ad["sales_reps"].most_common(5)) if ad["sales_reps"] else "—"

        info_rows = [
            [
                Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
                Paragraph(f'<b>{first_str}</b>', ss["BodyText2"]),
                Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
                Paragraph(f'<b>{last_str}</b>', ss["BodyText2"]),
            ],
            [
                Paragraph('<font color="#757575">Active Duration</font>', ss["BodyText2"]),
                Paragraph(f'<b>{dur}</b>', ss["BodyText2"]),
                Paragraph('<font color="#757575">Total Est. Value</font>', ss["BodyText2"]),
                Paragraph(f'<b>${est:,.2f}</b>', ss["BodyText2"]),
            ],
            [
                Paragraph('<font color="#757575">Callback Members</font>', ss["BodyText2"]),
                Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]),
                Paragraph('<font color="#757575">Sales Reps</font>', ss["BodyText2"]),
                Paragraph(f'{truncate(sr_str, 40)}', ss["BodyText2"]),
            ],
        ]
        info_table = Table(info_rows, colWidths=[1.3 * inch, 2.1 * inch, 1.3 * inch, 2.1 * inch])
        info_table.setStyle(TableStyle([
            ("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
            ("TOPPADDING", (0, 0), (-1, -1), 4),
            ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
            ("LEFTPADDING", (0, 0), (-1, -1), 8),
            ("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
        ]))
        elements.append(info_table)
        elements.append(Spacer(1, 6))

        # Breakdown tables
        if ad["types"]:
            elements.extend(make_table(f"{aid} — Entity Types", ad["types"], ss, max_rows=8))
            elements.append(Spacer(1, 6))

        if ad["companies"]:
            elements.extend(make_table(f"{aid} — Companies", ad["companies"], ss, max_rows=10))
            elements.append(Spacer(1, 6))

        if ad["stages"]:
            elements.extend(make_table(f"{aid} — Stages", ad["stages"], ss, max_rows=8))
            elements.append(Spacer(1, 6))

        if ad["ratings"]:
            elements.extend(make_table(f"{aid} — Ratings", ad["ratings"], ss, max_rows=8))
            elements.append(Spacer(1, 6))

        # Entity IDs
        if ad["entity_ids"]:
            id_list = sorted(ad["entity_ids"])
            id_str = ", ".join(str(i) for i in id_list[:30])
            if len(id_list) > 30:
                id_str += f" ... (+{len(id_list) - 30} more)"
            elements.append(Paragraph(f"{aid} — Entity IDs Touched", ss["SubHeader"]))
            elements.append(Paragraph(f'<font size="8">{id_str}</font>', ss["BodyText2"]))
            elements.append(Spacer(1, 6))

        # Divider
        if idx < len(sorted_actors) - 1:
            elements.append(Spacer(1, 4))
            elements.append(HRFlowable(width="100%", thickness=0.5, color=GRAY_200, spaceAfter=4))
            elements.append(Spacer(1, 4))

    return elements


def build_summary_log_table(entries, ss, max_rows=30):
    elements = []
    elements.append(PageBreak())
    elements.append(Paragraph("Event Summary", ss["SectionHeader"]))
    elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=6))
    elements.append(Paragraph(
        f"Aggregated view of {len(entries):,} webhook events — grouped by entity.",
        ss["SmallGray"],
    ))
    elements.append(Spacer(1, 8))

    entity_groups = defaultdict(lambda: {
        "count": 0, "name": "—", "company": "—",
        "actions": Counter(), "actors": set(),
        "est_total": None,
    })

    for e in entries:
        req = e.get("request", {})
        bp = req.get("bodyParsed") or {}
        entity = req.get("entityParsed") or bp.get("Entity") or {}
        if not isinstance(entity, dict):
            continue
        eid = entity.get("Id")
        if eid is None:
            continue

        eg = entity_groups[eid]
        eg["count"] += 1
        eg["name"] = entity.get("OpportunityName") or entity.get("CompanyName") or eg["name"]
        eg["company"] = entity.get("CompanyName") or eg["company"]
        eg["actions"][bp.get("Action", "?")] += 1
        actor = resolve_actor(e)
        eg["actors"].add(actor)
        et = entity.get("EstimatedTotal")
        if et is not None:
            eg["est_total"] = et

    sorted_entities = sorted(entity_groups.items(), key=lambda x: -x[1]["count"])

    header = ["ID", "Entity Name", "Company", "Events", "Actions", "Actors", "Est. Total"]
    rows = [header]

    for eid, eg in sorted_entities[:max_rows]:
        actions_str = ", ".join(f"{a}({c})" for a, c in eg["actions"].most_common(3))
        actors_str = ", ".join(sorted(eg["actors"]))
        total_str = f"${eg['est_total']:,.2f}" if eg["est_total"] is not None else "—"
        rows.append([
            str(eid),
            truncate(eg["name"], 28),
            truncate(eg["company"], 18),
            f"{eg['count']:,}",
            truncate(actions_str, 24),
            truncate(actors_str, 18),
            total_str,
        ])

    t = Table(rows, colWidths=[0.45 * inch, 1.7 * inch, 1.1 * inch, 0.55 * inch, 1.2 * inch, 1.0 * inch, 0.8 * inch])
    t.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
        ("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
        ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
        ("FONTSIZE", (0, 0), (-1, 0), 8),
        ("FONTSIZE", (0, 1), (-1, -1), 8),
        ("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
        ("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
        ("ALIGN", (0, 0), (0, -1), "CENTER"),
        ("ALIGN", (3, 0), (3, -1), "CENTER"),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ("LEFTPADDING", (0, 0), (-1, -1), 5),
        ("RIGHTPADDING", (0, 0), (-1, -1), 5),
        ("TOPPADDING", (0, 1), (-1, -1), 2),
        ("BOTTOMPADDING", (0, 1), (-1, -1), 2),
    ]))
    elements.append(t)

    if len(sorted_entities) > max_rows:
        elements.append(Spacer(1, 4))
        elements.append(Paragraph(
            f'Showing top {max_rows} of {len(sorted_entities)} entities.',
            ss["SmallGray"],
        ))

    return elements


def add_page_number(canvas, doc):
    canvas.saveState()
    canvas.setFillColor(GRAY_800)
    canvas.rect(0, 0, LETTER[0], 22, fill=1, stroke=0)
    canvas.setFillColor(WHITE)
    canvas.setFont("Helvetica", 7)
    canvas.drawCentredString(LETTER[0] / 2, 8, f"Page {doc.page}")
    canvas.setFillColor(GRAY_400)
    canvas.setFont("Helvetica", 7)
    canvas.drawString(30, 8, "Optima API · Webhook Log Report")
    canvas.drawRightString(LETTER[0] - 30, 8, datetime.now(timezone.utc).strftime("%Y-%m-%d"))
    canvas.restoreState()


# ─── Main ─────────────────────────────────────────────────────────────────────

def main():
    script_dir = os.path.dirname(os.path.abspath(__file__))
    log_dir = os.path.join(script_dir, "..", "cw-api-logs")

    if len(sys.argv) > 1:
        log_path = sys.argv[1]
    else:
        log_path = find_latest_log(log_dir)

    print(f"📄 Reading log: {log_path}")
    entries = parse_log(log_path)
    print(f"   → {len(entries)} entries parsed")

    stats = analyze(entries)
    ss = build_styles()

    log_basename = os.path.splitext(os.path.basename(log_path))[0]
    out_path = os.path.join(script_dir, "..", "cw-api-logs", f"{log_basename}-report.pdf")

    doc = SimpleDocTemplate(
        out_path,
        pagesize=LETTER,
        leftMargin=0.6 * inch,
        rightMargin=0.6 * inch,
        topMargin=0.5 * inch,
        bottomMargin=0.5 * inch,
    )

    elements = []

    # ── Title Banner ──
    elements.extend(make_header_banner(ss, log_path, stats))

    # ── Overview ──
    elements.append(Paragraph("Overview", ss["SectionHeader"]))
    elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))

    elements.append(make_kpi_row([
        ("Total Events", f"{stats['total_entries']:,}"),
        ("Unique Entities", f"{len(stats['entity_ids']):,}"),
        ("Companies", f"{len(stats['companies']):,}"),
        ("Duration", duration_str(stats["duration_seconds"])),
    ]))
    elements.append(Spacer(1, 8))
    elements.append(make_kpi_row([
        ("Events / Min", f"{stats['events_per_minute']:.1f}"),
        ("HTTP Methods", f"{len(stats['methods']):,}"),
        ("Action Types", f"{len(stats['actions']):,}"),
        ("Actors", f"{len(stats['actors']):,}"),
    ]))
    elements.append(Spacer(1, 12))

    # Time range
    if stats["first_ts"] and stats["last_ts"]:
        info = [
            [
                Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
                Paragraph(f'<b>{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
                Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
                Paragraph(f'<b>{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
            ]
        ]
        ti = Table(info, colWidths=[1.2 * inch, 2.4 * inch, 1.2 * inch, 2.4 * inch])
        ti.setStyle(TableStyle([
            ("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
            ("TOPPADDING", (0, 0), (-1, -1), 5),
            ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
            ("LEFTPADDING", (0, 0), (-1, -1), 8),
            ("BOX", (0, 0), (-1, -1), 0.4, GRAY_200),
        ]))
        elements.append(ti)
        elements.append(Spacer(1, 14))

    # ── Charts ──
    elements.append(Paragraph("Visual Breakdown", ss["SectionHeader"]))
    elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))

    elements.append(make_timeline_chart(stats["minute_buckets"]))
    elements.append(Spacer(1, 12))

    pie_row = [[
        make_pie_chart("By Action", stats["actions"]),
        make_pie_chart("By Type", stats["types"]),
    ]]
    pt = Table(pie_row, colWidths=[3.5 * inch, 3.5 * inch])
    pt.setStyle(TableStyle([
        ("ALIGN", (0, 0), (-1, -1), "CENTER"),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
    ]))
    elements.append(pt)
    elements.append(Spacer(1, 6))

    if len(stats["stages"]) > 1 or len(stats["ratings"]) > 1:
        pie_row2 = [[
            make_pie_chart("By Stage", stats["stages"]),
            make_pie_chart("By Rating", stats["ratings"]),
        ]]
        pt2 = Table(pie_row2, colWidths=[3.5 * inch, 3.5 * inch])
        pt2.setStyle(TableStyle([
            ("ALIGN", (0, 0), (-1, -1), "CENTER"),
            ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ]))
        elements.append(pt2)

    # Actor pie chart
    elements.append(Spacer(1, 6))
    elements.append(make_pie_chart("By Actor", stats["actors"], width=350, height=210))

    # ── General Information ──
    elements.append(PageBreak())
    elements.append(Paragraph("General Information", ss["SectionHeader"]))
    elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))

    elements.extend(make_table("Response Status Codes", stats["statuses"], ss))
    elements.append(Spacer(1, 10))
    elements.extend(make_table("HTTP Methods", stats["methods"], ss))
    elements.append(Spacer(1, 10))
    elements.extend(make_table("Webhook Actions", stats["actions"], ss))
    elements.append(Spacer(1, 10))
    elements.extend(make_table("Entity Types", stats["types"], ss))
    elements.append(Spacer(1, 10))
    elements.extend(make_table("Request Paths", stats["paths"], ss))
    elements.append(Spacer(1, 10))
    elements.extend(make_table("Actors", stats["actors"], ss))
    elements.append(Spacer(1, 10))

    if stats["companies"]:
        elements.extend(make_table("Companies", stats["companies"], ss))
        elements.append(Spacer(1, 10))
    if stats["stages"]:
        elements.extend(make_table("Opportunity Stages", stats["stages"], ss))
        elements.append(Spacer(1, 10))
    if stats["ratings"]:
        elements.extend(make_table("Opportunity Ratings", stats["ratings"], ss))
        elements.append(Spacer(1, 10))

    elements.extend(make_table("Hourly Distribution", stats["hourly_buckets"], ss))

    # ── Actor Deep Dive ──
    elements.extend(build_actor_activity_section(stats, ss))

    # ── Entity Summary ──
    elements.extend(build_summary_log_table(entries, ss, max_rows=30))

    # Build
    doc.build(elements, onFirstPage=add_page_number, onLaterPages=add_page_number)
    print(f"✅ Report generated: {out_path}")
    print(f"   File size: {os.path.getsize(out_path) / 1024:.1f} KB")


if __name__ == "__main__":
    main()
