#!/usr/bin/env python3

import argparse
import csv
import html
import os
import re
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from pathlib import Path

import plotly.graph_objects as go
from plotly.subplots import make_subplots

import aplog_plot_modemactivityinfo as modem_plot
import aplog_plot_uid_tput as uid_tput


TS_RE = re.compile(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3,6})?")
INVALID_VALUES = {"", "-1", "2147483647", "9223372036854775807"}


def parse_time(value, is_end=False):
    if value is None:
        return None
    value = value.strip()
    if "." in value:
        return datetime.strptime(value, "%m-%d %H:%M:%S.%f")
    parsed = datetime.strptime(value, "%m-%d %H:%M:%S")
    if is_end:
        return parsed + timedelta(seconds=1) - timedelta(microseconds=1)
    return parsed


def parse_line_datetime(line):
    match = TS_RE.search(line)
    if match is None:
        return None
    value = match.group(0)
    if "." in value:
        return datetime.strptime(value, "%m-%d %H:%M:%S.%f")
    return datetime.strptime(value, "%m-%d %H:%M:%S")


def in_window(dt, start_time, end_time):
    if dt is None:
        return False
    if start_time is not None and dt < start_time:
        return False
    if end_time is not None and dt > end_time:
        return False
    return True


def timestamp_text(dt):
    return dt.strftime("%m-%d %H:%M:%S.%f")[:-3]


def parse_field(text, patterns, default=""):
    if isinstance(patterns, str):
        patterns = [patterns]
    for pattern in patterns:
        match = re.search(pattern, text)
        if match:
            return match.group(1).strip()
    return default


def short_stream_label(key):
    uid, stat_src_type, if_name, app_package_name = key
    if app_package_name and app_package_name != "NULL":
        return f"uid{uid} {app_package_name.split('.')[-1]}"
    if if_name and if_name != "NULL":
        return f"uid{uid} {if_name}"
    return f"uid{uid} {stat_src_type}"


def read_signal_records(filter_dir, start_time, end_time):
    records = []
    files = [
        ("rf_sim1_signal_strength.txt", "SIM1", "PHONE0"),
        ("rf_sim2_signal_strength.txt", "SIM2", "PHONE1"),
    ]
    for filename, sim_name, phone_name in files:
        path = filter_dir / filename
        if not path.exists():
            continue
        with path.open("r", encoding="utf-8", errors="ignore") as handle:
            for line in handle:
                dt = parse_line_datetime(line)
                if not in_window(dt, start_time, end_time):
                    continue
                rsrp = parse_field(line, [r"\bssRsrp=([-]?\d+)", r"\brsrp[=:]\s*([-]?\d+)"])
                sinr = parse_field(line, [r"\bssSinr=([-]?\d+)", r"\brssnr[=:]\s*([-]?\d+)"])
                if not rsrp and not sinr:
                    continue
                records.append(
                    {
                        "dt": dt,
                        "timestamp": timestamp_text(dt),
                        "sim": sim_name,
                        "phone": phone_name,
                        "rsrp": int(rsrp) if rsrp else None,
                        "sinr": int(sinr) if sinr else None,
                    }
                )
    records.sort(key=lambda item: (item["sim"], item["dt"]))
    return records


def read_cell_records(filter_dir, start_time, end_time):
    records = []
    files = [
        ("reg_sim1_data_reg_rat.txt", "SIM1", "PHONE0"),
        ("reg_sim2_data_reg_rat.txt", "SIM2", "PHONE1"),
    ]
    for filename, sim_name, phone_name in files:
        path = filter_dir / filename
        if not path.exists():
            continue
        with path.open("r", encoding="utf-8", errors="ignore") as handle:
            for line in handle:
                dt = parse_line_datetime(line)
                if not in_window(dt, start_time, end_time):
                    continue
                cell_id = parse_field(line, [r"\bnci: (-?\d+)", r"\bci: (-?\d+)"])
                item = {
                    "dt": dt,
                    "timestamp": timestamp_text(dt),
                    "sim": sim_name,
                    "phone": phone_name,
                    "reg_state": parse_field(line, r"regState: ([A-Z_]+)"),
                    "rat": parse_field(line, r"rat: ([A-Z0-9_]+)"),
                    "operator": parse_field(line, r"operatorNumeric: (\d+)"),
                    "operator_name": parse_field(line, r"alphaLong: ([^,}]+)"),
                    "pci": parse_field(line, r"\bpci: (-?\d+)"),
                    "tac": parse_field(line, r"\btac: (-?\d+)"),
                    "cell_id": cell_id,
                    "arfcn": parse_field(line, [r"\bnrarfcn: (-?\d+)", r"\bearfcn: (-?\d+)"]),
                    "bands": parse_field(line, r"bands: \[([^\]]*)\]"),
                }
                records.append(item)
    records.sort(key=lambda item: (item["sim"], item["dt"]))
    return records


def read_primary_phone(filter_dir, end_time):
    path = filter_dir / "data_defaultDataSub.txt"
    if not path.exists():
        return "", "no data_defaultDataSub.txt"
    latest_set = ""
    latest_success = ""
    with path.open("r", encoding="utf-8", errors="ignore") as handle:
        for line in handle:
            dt = parse_line_datetime(line)
            if dt is not None and end_time is not None and dt > end_time:
                continue
            set_match = re.search(r"setPreferredDataModem - phoneId: (\d+)", line)
            if set_match:
                latest_set = set_match.group(1)
            success_match = re.search(r"DDS switch success on phoneId = (\d+)", line)
            if success_match:
                latest_success = success_match.group(1)
    if latest_success:
        return latest_success, f"DDS switch success on phoneId = {latest_success}"
    if latest_set:
        return latest_set, f"setPreferredDataModem - phoneId: {latest_set}"
    return "", "no DDS switch record"


def valid_cell(row):
    return row.get("cell_id") not in INVALID_VALUES and row.get("pci") not in INVALID_VALUES


def cell_desc(row):
    if not row:
        return "N/A"
    band = row.get("bands") or "UNKNOWN"
    arfcn_name = "nrarfcn" if row.get("rat") == "NR" else "arfcn"
    return (
        f"{row.get('rat') or 'UNKNOWN'}, {band}, {arfcn_name}={row.get('arfcn') or 'N/A'}, "
        f"pci={row.get('pci') or 'N/A'}, tac={row.get('tac') or 'N/A'}, cell={row.get('cell_id') or 'N/A'}"
    )


def build_sim_info(signal_records, cell_records, filter_dir, start_time, end_time):
    primary_phone, primary_source = read_primary_phone(filter_dir, end_time)
    rows = []
    for sim_name, phone_name in (("SIM1", "PHONE0"), ("SIM2", "PHONE1")):
        sig_items = [item for item in signal_records if item["sim"] == sim_name]
        cell_items = [item for item in cell_records if item["sim"] == sim_name]
        valid_cells = [item for item in cell_items if valid_cell(item)]
        last_cell = valid_cells[-1] if valid_cells else (cell_items[-1] if cell_items else None)
        counter = Counter(
            (
                item["rat"],
                item["operator"],
                item["operator_name"],
                item["pci"],
                item["tac"],
                item["cell_id"],
                item["arfcn"],
                item["bands"],
            )
            for item in valid_cells
        )
        dominant_key, dominant_count = counter.most_common(1)[0] if counter else (None, 0)
        dominant_cell = None
        if dominant_key:
            dominant_cell = {
                "rat": dominant_key[0],
                "operator": dominant_key[1],
                "operator_name": dominant_key[2],
                "pci": dominant_key[3],
                "tac": dominant_key[4],
                "cell_id": dominant_key[5],
                "arfcn": dominant_key[6],
                "bands": dominant_key[7],
            }
        rsrps = [item["rsrp"] for item in sig_items if item["rsrp"] is not None]
        sinrs = [item["sinr"] for item in sig_items if item["sinr"] is not None]
        phone_id = phone_name.replace("PHONE", "")
        primary = primary_phone == phone_id
        rows.append(
            {
                "card": f"{sim_name} / {phone_name}",
                "primary": f"YES, default data phoneId={primary_phone}" if primary else "NO",
                "primary_source": primary_source if primary else "",
                "operator": (
                    f"{last_cell.get('operator', '')} {last_cell.get('operator_name', '')}".strip()
                    if last_cell
                    else "N/A"
                ),
                "service_window": (
                    f"{cell_items[0]['timestamp']} ~ {cell_items[-1]['timestamp']}" if cell_items else "N/A"
                ),
                "last_cell": cell_desc(last_cell),
                "dominant_cell": (
                    f"{cell_desc(dominant_cell)}, count={dominant_count}/{len(valid_cells)}"
                    if dominant_cell
                    else "N/A"
                ),
                "signal": (
                    f"RSRP min/avg/max={min(rsrps)}/{sum(rsrps) / len(rsrps):.1f}/{max(rsrps)} dBm; "
                    f"SINR min/avg/max={min(sinrs)}/{sum(sinrs) / len(sinrs):.1f}/{max(sinrs)} dB"
                    if rsrps and sinrs
                    else "N/A"
                ),
            }
        )
    return rows


def load_modem_records(filter_dir, start_time, end_time):
    path = filter_dir / "modem_activity_info.csv"
    if not path.exists():
        path = filter_dir / "power_modem_activityinfo.txt"
    if not path.exists():
        return []
    records = modem_plot.load_records(str(path))
    records = modem_plot.dedupe_records(records)
    records = modem_plot.add_derived_metrics(records)
    if start_time is not None or end_time is not None:
        records = [
            item
            for item in records
            if in_window(item.get("aligned_log_datetime", item["log_datetime"]), start_time, end_time)
        ]
    return modem_plot.build_valid_intervals(records)


def load_tput_records(filter_dir, start_time, end_time):
    records = []
    for filename in ("data_net_netifstats.txt", "data_net_uidstats.txt"):
        path = filter_dir / filename
        if not path.exists():
            continue
        items = uid_tput.load_records(str(path))
        items = uid_tput.filter_records_by_time(items, start_time, end_time)
        records.extend(items)
    return records


def parse_number_field(text, key, default=0.0):
    match = re.search(rf"\b{re.escape(key)}:([0-9.]+)", text)
    if match is None:
        return default
    value = match.group(1)
    return float(value) if "." in value else int(value)


def short_package_name(package_name):
    if not package_name:
        return ""
    aliases = {
        "com.ss.android.ugc.aweme": "aweme",
        "com.tencent.mm": "wechat",
        "com.tencent.mobileqq": "qq",
        "com.tencent.qqmusic": "qqmusic",
        "com.byd.aeri.caranywhere": "caranywhere",
        "com.larus.nova": "nova",
    }
    return aliases.get(package_name, package_name.split(".")[-1])


def mobile_uid_label(uid, package_name):
    short_name = short_package_name(package_name)
    return f"uid{uid} {short_name}" if short_name else f"uid{uid}"


def load_mobile_uid_records(filter_dir, start_time, end_time):
    path = filter_dir / "data_stats_mobile_uid.txt"
    if not path.exists():
        return []
    records = []
    with path.open("r", encoding="utf-8", errors="ignore") as handle:
        for line in handle:
            dt = parse_line_datetime(line)
            if not in_window(dt, start_time, end_time):
                continue
            uid = parse_field(line, r"\buid:(\d+)")
            if not uid:
                continue
            package_match = re.search(r"\bnetworkType:\d+\s+([A-Za-z0-9_.$-]+)\s*$", line.strip())
            package_name = package_match.group(1) if package_match else ""
            rx_bytes = parse_number_field(line, "rxBytes", 0)
            tx_bytes = parse_number_field(line, "txBytes", 0)
            active_time = parse_number_field(line, "activeTime", 0)
            records.append(
                {
                    "dt": dt,
                    "timestamp": timestamp_text(dt),
                    "uid": uid,
                    "package": package_name,
                    "label": mobile_uid_label(uid, package_name),
                    "total_dun_s": parse_number_field(line, "totalDun", 0),
                    "ratio": parse_number_field(line, "ratio", 0.0),
                    "rx_packets": parse_number_field(line, "rxPackets", 0),
                    "tx_packets": parse_number_field(line, "txPackets", 0),
                    "rx_bytes": rx_bytes,
                    "tx_bytes": tx_bytes,
                    "active_time_s": active_time,
                    "active_count": parse_number_field(line, "activeCount", 0),
                    "wakeup": parse_number_field(line, "wakeup", 0),
                    "small_net_traffic_count": parse_number_field(line, "smallNetTrafficCount", 0),
                    "total_mb": (rx_bytes + tx_bytes) / 1_000_000,
                }
            )
    records.sort(key=lambda item: item["dt"])
    return records


def summarize_signal(records):
    rows = []
    for sim_name in sorted({item["sim"] for item in records}):
        items = [item for item in records if item["sim"] == sim_name]
        rsrps = [item["rsrp"] for item in items if item["rsrp"] is not None]
        sinrs = [item["sinr"] for item in items if item["sinr"] is not None]
        rows.append(
            {
                "domain": "RSRP",
                "stream": sim_name,
                "samples": len(items),
                "window": f"{items[0]['timestamp']} ~ {items[-1]['timestamp']}" if items else "",
                "metric1": (
                    f"RSRP min/avg/max={min(rsrps)}/{sum(rsrps) / len(rsrps):.1f}/{max(rsrps)} dBm"
                    if rsrps
                    else "RSRP N/A"
                ),
                "metric2": (
                    f"SINR min/avg/max={min(sinrs)}/{sum(sinrs) / len(sinrs):.1f}/{max(sinrs)} dB"
                    if sinrs
                    else "SINR N/A"
                ),
            }
        )
    return rows


def summarize_modem(records):
    rows = []
    by_phone = defaultdict(list)
    for item in records:
        by_phone[item["phone_id"]].append(item)
    for phone_id, items in sorted(by_phone.items()):
        total_duration = sum(item["duration_ms"] for item in items)
        total_sleep = sum(item["mSleepTimeMs"] for item in items)
        total_idle = sum(item["mIdleTimeMs"] for item in items)
        total_rx = sum(item["mRxTimeMs"] for item in items)
        total_tx = sum(item["total_tx_ms"] for item in items)
        avg_tx_power = (
            sum(item["avg_tx_power_dbm"] * item["total_tx_ms"] for item in items) / total_tx
            if total_tx
            else 0.0
        )
        rows.append(
            {
                "domain": "ModemActivity",
                "stream": f"PHONE{phone_id}",
                "samples": len(items),
                "window": (
                    f"{timestamp_text(min(item['interval_start'] for item in items))} ~ "
                    f"{timestamp_text(max(item['interval_end'] for item in items))}"
                ),
                "metric1": (
                    f"sleep={total_sleep / total_duration:.3f}, idle={total_idle / total_duration:.3f}, "
                    f"rx={total_rx / 1000:.1f}s, tx={total_tx / 1000:.1f}s"
                ),
                "metric2": (
                    f"duration={total_duration / 1000:.1f}s, avg_tx_power={avg_tx_power:.2f} dBm, "
                    f"rx_intervals={sum(1 for item in items if item['mRxTimeMs'] > 0)}/{len(items)}, "
                    f"tx_intervals={sum(1 for item in items if item['total_tx_ms'] > 0)}/{len(items)}"
                ),
            }
        )
    return rows


def summarize_tput(records):
    rows = []
    for item in uid_tput.compute_stream_summary(records):
        rows.append(
            {
                "domain": "Tput",
                "stream": item["stream_label"],
                "samples": item["sample_count"],
                "window": f"{item['first_timestamp']} ~ {item['last_timestamp']}",
                "metric1": (
                    f"traffic UL/DL/Total={item['send_mb']:.3f}/{item['recv_mb']:.3f}/{item['total_mb']:.3f} MB"
                ),
                "metric2": (
                    f"max UL/DL={item['max_send_mbps']:.3f}/{item['max_recv_mbps']:.3f} Mbps, "
                    f"avg UL/DL={item['avg_send_mbps']:.3f}/{item['avg_recv_mbps']:.3f} Mbps"
                ),
            }
        )
    return rows


def summarize_mobile_uid(records, max_rows=10):
    if not records:
        return []
    by_uid = defaultdict(list)
    for item in records:
        by_uid[item["uid"]].append(item)
    total_active = sum(item["active_time_s"] for item in records)
    rows = []
    summaries = []
    for uid, items in by_uid.items():
        package_name = next((item["package"] for item in reversed(items) if item["package"]), "")
        active_time = sum(item["active_time_s"] for item in items)
        rx_mb = sum(item["rx_bytes"] for item in items) / 1_000_000
        tx_mb = sum(item["tx_bytes"] for item in items) / 1_000_000
        summaries.append(
            {
                "uid": uid,
                "package": package_name,
                "label": mobile_uid_label(uid, package_name),
                "samples": len(items),
                "window": f"{items[0]['timestamp']} ~ {items[-1]['timestamp']}",
                "active_time": active_time,
                "active_share": active_time / total_active * 100 if total_active else 0.0,
                "rx_mb": rx_mb,
                "tx_mb": tx_mb,
                "total_mb": rx_mb + tx_mb,
                "wakeup": sum(item["wakeup"] for item in items),
                "active_count": sum(item["active_count"] for item in items),
                "small_net": sum(item["small_net_traffic_count"] for item in items),
            }
        )
    summaries.sort(key=lambda item: (item["active_time"], item["total_mb"]), reverse=True)
    total_mb = sum(item["rx_bytes"] + item["tx_bytes"] for item in records) / 1_000_000
    rows.append(
        {
            "domain": "MobileUID",
            "stream": "TOTAL",
            "samples": len(records),
            "window": f"{records[0]['timestamp']} ~ {records[-1]['timestamp']}",
            "metric1": f"activeTime={total_active:.0f}s ({total_active / 3600:.2f}h), traffic={total_mb:.3f} MB",
            "metric2": f"uids={len(by_uid)}, source=data_stats_mobile_uid.txt",
        }
    )
    for item in summaries[:max_rows]:
        rows.append(
            {
                "domain": "MobileUID",
                "stream": item["label"],
                "samples": item["samples"],
                "window": item["window"],
                "metric1": (
                    f"activeTime={item['active_time']:.0f}s, share={item['active_share']:.1f}%, "
                    f"wakeup={item['wakeup']}, activeCount={item['active_count']}"
                ),
                "metric2": (
                    f"traffic UL/DL/Total={item['tx_mb']:.3f}/{item['rx_mb']:.3f}/{item['total_mb']:.3f} MB, "
                    f"smallNet={item['small_net']}"
                ),
            }
        )
    return rows


def sim_info_summary_rows(sim_rows):
    rows = []
    for item in sim_rows:
        rows.append(
            {
                "domain": "SIMInfo",
                "stream": item["card"],
                "samples": "",
                "window": item["service_window"],
                "metric1": f"primary={item['primary']}; operator={item['operator']}; last={item['last_cell']}",
                "metric2": f"dominant={item['dominant_cell']}; {item['signal']}",
            }
        )
    return rows


def interval_hover(item, extra):
    return (
        f"start={timestamp_text(item['interval_start'])}<br>"
        f"end={timestamp_text(item['interval_end'])}<br>"
        f"duration={item['duration_ms'] / 1000:.3f}s<br>"
        f"{extra}"
    )


def add_interval_bar_with_zero_marker(fig, items, value_fn, label, color, row, legend_name, unit, zero_label):
    active = [item for item in items if value_fn(item) > 0]
    zero = [item for item in items if value_fn(item) <= 0]
    fig.add_trace(
        go.Bar(
            x=[item["interval_center"] for item in active],
            y=[value_fn(item) for item in active],
            width=[max(item["duration_ms"], 1) for item in active],
            name=label,
            legend=legend_name,
            marker_color=color,
            opacity=0.82,
            customdata=[interval_hover(item, f"{label}={value_fn(item):.3f} {unit}") for item in active],
            hovertemplate="%{customdata}<extra></extra>",
        ),
        row=row,
        col=1,
    )
    if zero:
        fig.add_trace(
            go.Scatter(
                x=[item["interval_center"] for item in zero],
                y=[0 for _ in zero],
                mode="markers",
                name=zero_label,
                legend=legend_name,
                marker=dict(color="rgba(120,120,120,0.35)", size=5, symbol="line-ew"),
                customdata=[
                    interval_hover(item, f"{label}=0 {unit}<br>not plotted as sustained 0") for item in zero
                ],
                hovertemplate="%{customdata}<extra></extra>",
            ),
            row=row,
            col=1,
        )


def top_mobile_uids(mobile_records, max_count=8):
    by_uid = defaultdict(list)
    for item in mobile_records:
        by_uid[item["uid"]].append(item)
    ranked = []
    for uid, items in by_uid.items():
        active_time = sum(item["active_time_s"] for item in items)
        total_bytes = sum(item["rx_bytes"] + item["tx_bytes"] for item in items)
        package_name = next((item["package"] for item in reversed(items) if item["package"]), "")
        ranked.append((uid, package_name, active_time, total_bytes))
    ranked.sort(key=lambda item: (item[2], item[3]), reverse=True)
    return ranked[:max_count]


def build_mobile_uid_timeseries(items):
    cumulative_active = 0.0
    cumulative_mb = 0.0
    rows = []
    for item in sorted(items, key=lambda record: record["dt"]):
        cumulative_active += item["active_time_s"]
        cumulative_mb += item["total_mb"]
        row = dict(item)
        row["cumulative_active_time_s"] = cumulative_active
        row["cumulative_total_mb"] = cumulative_mb
        rows.append(row)
    return rows


def plot_overview(signal_records, modem_records, tput_records, mobile_records, summary_rows, start_time, end_time, output_html):
    fig = make_subplots(
        rows=10,
        cols=1,
        shared_xaxes=False,
        vertical_spacing=0.028,
        row_heights=[0.19, 0.105, 0.105, 0.095, 0.095, 0.09, 0.10, 0.10, 0.10, 0.10],
        specs=[
            [{"type": "table"}],
            [{"secondary_y": True}],
            [{}],
            [{}],
            [{}],
            [{}],
            [{}],
            [{}],
            [{}],
            [{}],
        ],
        subplot_titles=(
            "Summary",
            "RSRP / SINR",
            "Modem Sleep / Idle Ratio",
            "Modem RX Time (interval-aware)",
            "Modem TX Time (interval-aware)",
            "Modem Average TX Power (TX intervals only)",
            "Mobile UID Active Time (from data_stats_mobile_uid)",
            "Mobile UID Traffic (from data_stats_mobile_uid)",
            "Tput Rate",
            "Cumulative Traffic",
        ),
    )
    headers = ["Domain", "Stream", "Samples", "Window", "Metric 1", "Metric 2"]
    keys = ["domain", "stream", "samples", "window", "metric1", "metric2"]
    fig.add_trace(
        go.Table(
            header=dict(values=headers, fill_color="#263238", font=dict(color="white", size=12), align="left"),
            cells=dict(
                values=[[row[key] for row in summary_rows] for key in keys],
                fill_color="#f7f9fb",
                font=dict(size=11),
                align="left",
                height=26,
            ),
        ),
        row=1,
        col=1,
    )

    signal_colors = {"SIM1": "#1f77b4", "SIM2": "#ff7f0e"}
    for sim_name in sorted({item["sim"] for item in signal_records}):
        items = [item for item in signal_records if item["sim"] == sim_name]
        x_values = [item["dt"] for item in items]
        fig.add_trace(
            go.Scatter(
                x=x_values,
                y=[item["rsrp"] for item in items],
                mode="lines+markers",
                name=f"{sim_name} RSRP",
                legend="legend",
                line=dict(color=signal_colors.get(sim_name)),
            ),
            row=2,
            col=1,
            secondary_y=False,
        )
        fig.add_trace(
            go.Scatter(
                x=x_values,
                y=[item["sinr"] for item in items],
                mode="lines+markers",
                name=f"{sim_name} SINR",
                legend="legend",
                line=dict(color=signal_colors.get(sim_name), dash="dot"),
            ),
            row=2,
            col=1,
            secondary_y=True,
        )

    for phone_id in sorted({item["phone_id"] for item in modem_records}):
        items = [item for item in modem_records if item["phone_id"] == phone_id]
        x_values = [item["interval_center"] for item in items]
        widths = [max(item["duration_ms"], 1) for item in items]
        fig.add_trace(
            go.Bar(
                x=x_values,
                y=[item["sleep_ratio"] for item in items],
                width=widths,
                name=f"PHONE{phone_id} sleep",
                legend="legend2",
                marker_color="#2ca02c",
                opacity=0.75,
            ),
            row=3,
            col=1,
        )
        fig.add_trace(
            go.Bar(
                x=x_values,
                y=[item["idle_ratio"] for item in items],
                width=widths,
                name=f"PHONE{phone_id} idle",
                legend="legend2",
                marker_color="#17becf",
                opacity=0.65,
            ),
            row=3,
            col=1,
        )
        add_interval_bar_with_zero_marker(
            fig, items, lambda item: item["mRxTimeMs"] / 1000, f"PHONE{phone_id} RX", "#9467bd", 4, "legend3", "s", f"PHONE{phone_id} no RX"
        )
        add_interval_bar_with_zero_marker(
            fig, items, lambda item: item["total_tx_ms"] / 1000, f"PHONE{phone_id} TX", "#d62728", 5, "legend4", "s", f"PHONE{phone_id} no TX"
        )
        tx_items = [item for item in items if item["total_tx_ms"] > 0 and item["avg_tx_power_dbm"] > 0]
        no_tx_items = [item for item in items if item["total_tx_ms"] <= 0]
        fig.add_trace(
            go.Bar(
                x=[item["interval_center"] for item in tx_items],
                y=[item["avg_tx_power_dbm"] for item in tx_items],
                width=[max(item["duration_ms"], 1) for item in tx_items],
                name=f"PHONE{phone_id} avg TX",
                legend="legend5",
                marker_color="#e377c2",
                opacity=0.85,
                customdata=[
                    interval_hover(item, f"tx={item['total_tx_ms'] / 1000:.3f}s<br>avg_tx_power={item['avg_tx_power_dbm']:.2f} dBm")
                    for item in tx_items
                ],
                hovertemplate="%{customdata}<extra></extra>",
            ),
            row=6,
            col=1,
        )
        if no_tx_items:
            fig.add_trace(
                go.Scatter(
                    x=[item["interval_center"] for item in no_tx_items],
                    y=[0 for _ in no_tx_items],
                    mode="markers",
                    name=f"PHONE{phone_id} no TX power",
                    legend="legend5",
                    marker=dict(color="rgba(120,120,120,0.35)", size=5, symbol="line-ew"),
                    customdata=[interval_hover(item, "tx=0s<br>not plotted as sustained 0 dBm") for item in no_tx_items],
                    hovertemplate="%{customdata}<extra></extra>",
                ),
                row=6,
                col=1,
            )

    mobile_colors = ["#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#66a61e", "#e6ab02", "#a6761d", "#666666"]
    by_mobile_uid = defaultdict(list)
    for item in mobile_records:
        by_mobile_uid[item["uid"]].append(item)
    for index, (uid, package_name, _active_time, _total_bytes) in enumerate(top_mobile_uids(mobile_records)):
        items = build_mobile_uid_timeseries(by_mobile_uid[uid])
        label = mobile_uid_label(uid, package_name)
        color = mobile_colors[index % len(mobile_colors)]
        fig.add_trace(
            go.Bar(
                x=[item["dt"] for item in items],
                y=[item["active_time_s"] for item in items],
                name=label,
                legend="legend6",
                marker_color=color,
                opacity=0.78,
                customdata=[
                    (
                        f"{item['timestamp']}<br>{label}<br>"
                        f"activeTime={item['active_time_s']:.0f}s<br>"
                        f"ratio={item['ratio']:.1f}%<br>wakeup={item['wakeup']}<br>"
                        f"activeCount={item['active_count']}<br>"
                        f"traffic={item['total_mb']:.3f} MB"
                    )
                    for item in items
                ],
                hovertemplate="%{customdata}<extra></extra>",
            ),
            row=7,
            col=1,
        )
        fig.add_trace(
            go.Scatter(
                x=[item["dt"] for item in items],
                y=[item["cumulative_total_mb"] for item in items],
                mode="lines+markers",
                name=label,
                legend="legend7",
                line=dict(color=color),
            ),
            row=8,
            col=1,
        )

    tput_colors = ["#d62728", "#1f77b4", "#2ca02c", "#ff7f0e", "#9467bd", "#8c564b"]
    by_stream = uid_tput.group_records_by_stream(tput_records)
    for index, (key, items) in enumerate(sorted(by_stream.items(), key=lambda kv: uid_tput.stream_label_from_key(kv[0]))):
        items = sorted(items, key=lambda item: item["log_datetime"])
        label = short_stream_label(key)
        color = tput_colors[index % len(tput_colors)]
        fig.add_trace(
            go.Scatter(
                x=[item["log_datetime"] for item in items],
                y=[item["send_rate_mbps"] for item in items],
                mode="lines",
                name=f"{label} UL",
                legend="legend8",
                line=dict(color=color),
            ),
            row=9,
            col=1,
        )
        fig.add_trace(
            go.Scatter(
                x=[item["log_datetime"] for item in items],
                y=[item["recv_rate_mbps"] for item in items],
                mode="lines",
                name=f"{label} DL",
                legend="legend8",
                line=dict(color=color, dash="dot"),
            ),
            row=9,
            col=1,
        )
    for index, (key, items) in enumerate(sorted(by_stream.items(), key=lambda kv: uid_tput.stream_label_from_key(kv[0]))):
        timeseries = uid_tput.build_uid_timeseries(items)
        label = short_stream_label(key)
        color = tput_colors[index % len(tput_colors)]
        fig.add_trace(
            go.Scatter(
                x=[item["log_datetime"] for item in timeseries],
                y=[item["cumulative_total_mb"] for item in timeseries],
                mode="lines",
                name=f"{label} total",
                legend="legend9",
                line=dict(color=color),
            ),
            row=10,
            col=1,
        )

    fig.update_yaxes(title_text="RSRP dBm", row=2, col=1, secondary_y=False)
    fig.update_yaxes(title_text="SINR dB", row=2, col=1, secondary_y=True)
    fig.update_yaxes(title_text="Ratio", row=3, col=1, range=[0, 1])
    fig.update_yaxes(title_text="RX seconds per interval", row=4, col=1)
    fig.update_yaxes(title_text="TX seconds per interval", row=5, col=1)
    fig.update_yaxes(title_text="dBm", row=6, col=1, range=[0, 25])
    fig.update_yaxes(title_text="Active seconds", row=7, col=1)
    fig.update_yaxes(title_text="Cumulative MB", row=8, col=1)
    fig.update_yaxes(title_text="Mbps", row=9, col=1)
    fig.update_yaxes(title_text="MB", row=10, col=1)
    if start_time is not None and end_time is not None:
        for row in range(2, 11):
            fig.update_xaxes(range=[start_time, end_time], domain=[0.0, 0.78], row=row, col=1)
    else:
        for row in range(2, 11):
            fig.update_xaxes(domain=[0.0, 0.78], row=row, col=1)

    legend_style = dict(
        x=0.805,
        xanchor="left",
        orientation="v",
        bgcolor="rgba(255,255,255,0.75)",
        bordercolor="rgba(180,180,180,0.4)",
        borderwidth=1,
        font=dict(size=10),
        itemwidth=30,
    )
    title_window = (
        f"{start_time.strftime('%m-%d %H:%M:%S')} ~ {end_time.strftime('%m-%d %H:%M:%S')}"
        if start_time is not None and end_time is not None
        else "full window"
    )
    fig.update_layout(
        title=f"{title_window} RSRP / Modem Activity / Mobile UID / Tput Analysis",
        height=max(2450, 1850 + len(summary_rows) * 28),
        hovermode="x unified",
        barmode="overlay",
        margin=dict(l=70, r=230, t=95, b=90),
        legend=dict(**legend_style, y=0.858, yanchor="top", title_text="Signal"),
        legend2=dict(**legend_style, y=0.749, yanchor="top", title_text="Sleep/Idle"),
        legend3=dict(**legend_style, y=0.640, yanchor="top", title_text="RX Time"),
        legend4=dict(**legend_style, y=0.541, yanchor="top", title_text="TX Time"),
        legend5=dict(**legend_style, y=0.442, yanchor="top", title_text="TX Power"),
        legend6=dict(**legend_style, y=0.349, yanchor="top", title_text="Mobile Active"),
        legend7=dict(**legend_style, y=0.245, yanchor="top", title_text="Mobile Traffic"),
        legend8=dict(**legend_style, y=0.142, yanchor="top", title_text="Tput"),
        legend9=dict(**legend_style, y=0.041, yanchor="top", title_text="Traffic"),
    )
    fig.write_html(str(output_html), include_plotlyjs="cdn")


def inject_sim_summary(output_html, sim_rows, start_time, end_time):
    title_window = (
        f"{start_time.strftime('%m-%d %H:%M:%S')} ~ {end_time.strftime('%m-%d %H:%M:%S')}"
        if start_time is not None and end_time is not None
        else "full window"
    )
    style = """
<style id="dual-sim-summary-style">
.dual-sim-summary { font-family: Arial, sans-serif; margin: 18px 24px 8px 24px; }
.dual-sim-summary h2 { margin: 0 0 6px 0; font-size: 20px; color: #263238; }
.dual-sim-summary .note { margin: 0 0 10px 0; color: #546e7a; font-size: 13px; }
.dual-sim-summary table { border-collapse: collapse; width: 100%; table-layout: fixed; font-size: 13px; }
.dual-sim-summary th { background: #263238; color: white; text-align: left; padding: 8px; border: 1px solid #cfd8dc; }
.dual-sim-summary td { padding: 8px; border: 1px solid #cfd8dc; vertical-align: top; word-break: break-word; }
.dual-sim-summary tr:nth-child(even) td { background: #f7f9fb; }
.dual-sim-summary .primary { color: #c62828; font-weight: bold; }
.dual-sim-summary .mono { font-family: Consolas, Menlo, monospace; }
</style>
"""
    headers = ["SIM / Phone", "Primary", "Operator", "Service Window", "Last Serving Cell", "Dominant Serving Cell", "Signal"]
    keys = ["card", "primary", "operator", "service_window", "last_cell", "dominant_cell", "signal"]
    html_rows = []
    for row in sim_rows:
        cells = []
        for key in keys:
            class_name = "primary" if key == "primary" and row[key].startswith("YES") else ""
            if key in {"last_cell", "dominant_cell", "signal"}:
                class_name = f"{class_name} mono".strip()
            cells.append(f'<td class="{class_name}">{html.escape(row[key])}</td>')
        html_rows.append("<tr>" + "".join(cells) + "</tr>")
    primary_sources = [row["primary_source"] for row in sim_rows if row["primary"].startswith("YES") and row["primary_source"]]
    source_text = primary_sources[0] if primary_sources else "unknown"
    block = f"""
<!-- DUAL_SIM_SUMMARY_START -->
{style}
<div class="dual-sim-summary">
  <h2>Dual SIM / Primary / Serving Cell Summary ({html.escape(title_window)})</h2>
  <p class="note">Primary source: <span class="mono">{html.escape(source_text)}</span>.</p>
  <table>
    <thead><tr>{''.join(f'<th>{html.escape(item)}</th>' for item in headers)}</tr></thead>
    <tbody>
      {' '.join(html_rows)}
    </tbody>
  </table>
</div>
<!-- DUAL_SIM_SUMMARY_END -->
"""
    content = output_html.read_text(encoding="utf-8")
    start = content.find("<!-- DUAL_SIM_SUMMARY_START -->")
    end = content.find("<!-- DUAL_SIM_SUMMARY_END -->")
    if start != -1 and end != -1:
        end += len("<!-- DUAL_SIM_SUMMARY_END -->")
        content = content[:start] + block + content[end:]
    else:
        content = content.replace("<body>", "<body>" + block, 1)
    output_html.write_text(content, encoding="utf-8")


def write_summary_csv(summary_rows, output_csv):
    headers = ["domain", "stream", "samples", "window", "metric1", "metric2"]
    with output_csv.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=headers)
        writer.writeheader()
        writer.writerows(summary_rows)


def build_output_paths(filter_dir, outdir, start_time, end_time):
    if outdir is None:
        outdir = filter_dir
    outdir.mkdir(parents=True, exist_ok=True)
    if start_time is not None and end_time is not None:
        suffix = f"{start_time.strftime('%m%d_%H%M%S')}_{end_time.strftime('%m%d_%H%M%S')}"
    else:
        suffix = "full"
    stem = f"{filter_dir.name}_window_overview_{suffix}"
    return outdir / f"{stem}.html", outdir / f"{stem}_summary.csv"


def main():
    parser = argparse.ArgumentParser(description="Generate the fixed AP window overview HTML template.")
    parser.add_argument("--filter-dir", required=True, help="AP filter output directory.")
    parser.add_argument("--outdir", help="Output directory.")
    parser.add_argument("--start-time", help="MM-DD HH:MM:SS[.mmm]")
    parser.add_argument("--end-time", help="MM-DD HH:MM:SS[.mmm]")
    args = parser.parse_args()

    filter_dir = Path(args.filter_dir).resolve()
    outdir = Path(args.outdir).resolve() if args.outdir else None
    start_time = parse_time(args.start_time)
    end_time = parse_time(args.end_time, is_end=True)

    signal_records = read_signal_records(filter_dir, start_time, end_time)
    cell_records = read_cell_records(filter_dir, start_time, end_time)
    sim_rows = build_sim_info(signal_records, cell_records, filter_dir, start_time, end_time)
    modem_records = load_modem_records(filter_dir, start_time, end_time)
    tput_records = load_tput_records(filter_dir, start_time, end_time)
    mobile_uid_records = load_mobile_uid_records(filter_dir, start_time, end_time)

    summary_rows = (
        summarize_signal(signal_records)
        + summarize_modem(modem_records)
        + summarize_mobile_uid(mobile_uid_records)
        + summarize_tput(tput_records)
        + sim_info_summary_rows(sim_rows)
    )
    output_html, output_csv = build_output_paths(filter_dir, outdir, start_time, end_time)
    write_summary_csv(summary_rows, output_csv)
    plot_overview(
        signal_records,
        modem_records,
        tput_records,
        mobile_uid_records,
        summary_rows,
        start_time,
        end_time,
        output_html,
    )
    inject_sim_summary(output_html, sim_rows, start_time, end_time)
    print(f"html {output_html}")
    print(f"summary {output_csv}")


if __name__ == "__main__":
    main()
