#!/usr/bin/env python3
"""GB32960 TCP ingest to history/archive smoke verifier."""

from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import pathlib
import socket
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any

try:
    from tools import tdengine_smoke
except ModuleNotFoundError:
    import tdengine_smoke


SHANGHAI = dt.timezone(dt.timedelta(hours=8))
DEFAULT_SAMPLE = (
    "modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex"
)
DEFAULT_FIELD_KEYS = "VEHICLE.speedKmh,VEHICLE.totalMileageKm,POSITION_V2016.longitude,POSITION_V2016.latitude"


@dataclass(frozen=True)
class FrameInfo:
    vin: str
    command: int
    event_time: dt.datetime | None


def read_hex_frame(path: pathlib.Path) -> bytes:
    text = path.read_text(encoding="utf-8")
    return bytes.fromhex("".join(text.split()))


def parse_frame_info(frame: bytes) -> FrameInfo:
    if len(frame) < 25:
        raise ValueError(f"gb32960 frame too short: {len(frame)}")
    if frame[:2] not in (b"##", b"$$"):
        raise ValueError("gb32960 frame must start with ## or $$")
    data_len = int.from_bytes(frame[22:24], "big")
    expected_len = 24 + data_len + 1
    if len(frame) < expected_len:
        raise ValueError(f"gb32960 frame incomplete: len={len(frame)} expected={expected_len}")
    command = frame[2]
    vin = frame[4:21].decode("ascii", errors="ignore").strip()
    event_time = None
    if command in (0x01, 0x02, 0x03, 0x04, 0x05) and data_len >= 6:
        event_time = parse_collect_time(frame[24:30])
    return FrameInfo(vin=vin, command=command, event_time=event_time)


def parse_collect_time(raw: bytes) -> dt.datetime:
    if len(raw) != 6:
        raise ValueError("gb32960 collect time must be 6 bytes")
    year, month, day, hour, minute, second = raw
    return dt.datetime(2000 + year, month, day, hour, minute, second, tzinfo=SHANGHAI)


def query_range(event_time: dt.datetime | None, minutes: int) -> tuple[str, str]:
    center = event_time or dt.datetime.now(tz=SHANGHAI)
    center = center.astimezone(SHANGHAI)
    start = center - dt.timedelta(minutes=minutes)
    end = center + dt.timedelta(minutes=minutes)
    return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds")


def send_frame(host: str, port: int, frame: bytes, timeout: float) -> bytes:
    with socket.create_connection((host, port), timeout=timeout) as sock:
        sock.settimeout(timeout)
        sock.sendall(frame)
        try:
            return sock.recv(4096)
        except socket.timeout:
            return b""


def valid_ack(ack: bytes) -> bool:
    return len(ack) >= 2 and ack[:2] in (b"##", b"$$")


def query_json(base_url: str, path: str, params: dict[str, Any], timeout: float) -> Any:
    url = base_url.rstrip("/") + path + "?" + urllib.parse.urlencode(params)
    request = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return json.loads(response.read().decode("utf-8"))


def wait_for_records(
    history_base_url: str,
    vin: str,
    date_from: str,
    date_to: str,
    minimum: int,
    timeout_seconds: float,
    request_timeout: float,
) -> list[dict[str, Any]]:
    deadline = time.monotonic() + timeout_seconds
    params = {
        "vin": vin,
        "dateFrom": date_from,
        "dateTo": date_to,
        "limit": max(minimum, 10),
        "order": "ASC",
    }
    last_items: list[dict[str, Any]] = []
    while time.monotonic() < deadline:
        response = query_json(history_base_url, "/api/event-history/gb32960/snapshots", params, request_timeout)
        last_items = response if isinstance(response, list) else []
        if len(last_items) >= minimum:
            return last_items
        time.sleep(1)
    return last_items


def wait_for_fields(
    history_base_url: str,
    vin: str,
    field_key: str,
    date_from: str,
    date_to: str,
    timeout_seconds: float,
    request_timeout: float,
) -> list[dict[str, Any]]:
    deadline = time.monotonic() + timeout_seconds
    params = {
        "vin": vin,
        "fields": field_key,
        "dateFrom": date_from,
        "dateTo": date_to,
        "limit": 10,
        "order": "ASC",
    }
    last_items: list[dict[str, Any]] = []
    while time.monotonic() < deadline:
        response = query_json(history_base_url, "/api/event-history/gb32960/snapshots/fields", params, request_timeout)
        last_items = response if isinstance(response, list) else []
        if any((item.get("fields") or {}).get(field_key) is not None for item in last_items):
            return last_items
        time.sleep(1)
    return last_items


def wait_for_tdengine_raw_frames(
    rest_url: str,
    username: str,
    password: str,
    sql: str,
    minimum: int,
    timeout_seconds: float,
    request_timeout: float,
) -> int:
    deadline = time.monotonic() + timeout_seconds
    last_count = 0
    while time.monotonic() < deadline:
        last_count = tdengine_smoke.query_count(rest_url, username, password, sql, request_timeout)
        if last_count >= minimum:
            return last_count
        time.sleep(1)
    return last_count


def parse_field_keys(value: str) -> list[str]:
    return [item.strip() for item in value.split(",") if item.strip()]


def archive_path(root: pathlib.Path, uri: str) -> pathlib.Path:
    prefix = "archive://"
    if not uri.startswith(prefix):
        raise ValueError(f"unsupported archive uri: {uri}")
    return root / uri[len(prefix):]


def check_archive(root: pathlib.Path, records: list[dict[str, Any]], limit: int) -> int:
    checked = 0
    seen: set[str] = set()
    for uri in raw_uris(records):
        if uri in seen:
            continue
        seen.add(uri)
        path = archive_path(root, uri)
        if not path.exists():
            raise RuntimeError(f"raw archive missing: {path}")
        checked += 1
        if checked >= limit:
            return checked
    return checked


def raw_frame_count_sql(info: FrameInfo, records: list[dict[str, Any]]) -> str:
    sqls = raw_frame_count_sqls(info, records)
    if not sqls:
        raise ValueError("no raw archive uri found in gb32960 records")
    return sqls[0]


def raw_frame_count_sqls(info: FrameInfo, records: list[dict[str, Any]]) -> list[str]:
    seen: set[str] = set()
    sqls: list[str] = []
    for uri in raw_uris(records):
        if uri in seen:
            continue
        seen.add(uri)
        sqls.append(tdengine_smoke.raw_frame_count_sql(
            protocol="GB32960",
            vehicle_key=info.vin,
            vin=info.vin,
            raw_uri=uri,
        ))
    return sqls


def verify_tdengine_raw_frames(
    rest_url: str,
    username: str,
    password: str,
    sqls: list[str],
    timeout_seconds: float,
    request_timeout: float,
) -> int:
    if not sqls:
        raise RuntimeError("no raw archive uri available for tdengine raw_frames verification")
    verified = 0
    for sql in sqls:
        count = wait_for_tdengine_raw_frames(
            rest_url, username, password, sql, 1, timeout_seconds, request_timeout)
        if count < 1:
            raise RuntimeError(f"tdengine raw_frames row not visible for query: {sql}")
        verified += 1
    return verified


def run(args: argparse.Namespace) -> dict[str, Any]:
    frame = read_hex_frame(pathlib.Path(args.frame_hex_file))
    info = parse_frame_info(frame)
    query_center = dt.datetime.now(tz=SHANGHAI)
    date_from, date_to = query_range(query_center, args.window_minutes)

    ack = send_frame(args.tcp_host, args.tcp_port, frame, args.tcp_timeout)
    if args.require_ack and not valid_ack(ack):
        raise RuntimeError(f"gb32960 ack missing or invalid: {ack.hex()}")

    records = wait_for_records(
        args.history_base_url,
        info.vin,
        date_from,
        date_to,
        args.expect_record_count,
        args.history_timeout,
        args.http_timeout,
    )
    if len(records) < args.expect_record_count:
        raise RuntimeError(f"gb32960 records not visible for {info.vin}: got {len(records)}")

    field_counts: dict[str, int] = {}
    for field_key in parse_field_keys(args.field_keys):
        field_items = wait_for_fields(
            args.history_base_url,
            info.vin,
            field_key,
            date_from,
            date_to,
            args.history_timeout,
            args.http_timeout,
        )
        field_counts[field_key] = len(field_items)
        if not field_items:
            raise RuntimeError(f"gb32960 telemetry field not visible for {info.vin}: {field_key}")

    archive_checked = 0
    if args.archive_root:
        archive_checked = check_archive(pathlib.Path(args.archive_root), records, args.archive_check_limit)

    tdengine_raw_frames = None
    if args.tdengine_rest_url:
        tdengine_raw_frames = verify_tdengine_raw_frames(
            args.tdengine_rest_url,
            args.tdengine_username,
            args.tdengine_password,
            raw_frame_count_sqls(info, records),
            args.history_timeout,
            args.http_timeout,
        )

    return {
        "tcpHost": args.tcp_host,
        "tcpPort": args.tcp_port,
        "vin": info.vin,
        "command": f"0x{info.command:02x}",
        "eventTime": info.event_time.isoformat() if info.event_time else None,
        "queryTimeSource": "received",
        "queryDateFrom": date_from,
        "queryDateTo": date_to,
        "ackBytesHex": ack.hex(),
        "records": len(records),
        "fieldCounts": field_counts,
        "tdengineRawFrames": tdengine_raw_frames,
        "archiveChecked": archive_checked,
        "firstRawUri": first_raw_uri(records),
    }


def first_raw_uri(records: list[dict[str, Any]]) -> str | None:
    uris = raw_uris(records)
    if uris:
        return uris[0]
    return None


def raw_uris(records: list[dict[str, Any]]) -> list[str]:
    out: list[str] = []
    for record in records:
        uri = record.get("rawArchiveUri")
        if isinstance(uri, str) and uri:
            out.append(uri)
        for frame in record.get("sourceFrames") or []:
            frame_uri = frame.get("rawArchiveUri") if isinstance(frame, dict) else ""
            if frame_uri:
                out.append(frame_uri)
    return out


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--tcp-host", default="127.0.0.1")
    p.add_argument("--tcp-port", type=int, default=32960)
    p.add_argument("--history-base-url", default="http://127.0.0.1:20200")
    p.add_argument("--frame-hex-file", default=DEFAULT_SAMPLE)
    p.add_argument("--field-keys", default=DEFAULT_FIELD_KEYS)
    p.add_argument("--window-minutes", type=int, default=10)
    p.add_argument("--expect-record-count", type=int, default=1)
    p.add_argument("--archive-root", default="")
    p.add_argument("--archive-check-limit", type=int, default=1)
    p.add_argument("--tdengine-rest-url", default=os.environ.get("TDENGINE_REST_URL", ""))
    p.add_argument("--tdengine-username", default=os.environ.get("TDENGINE_USERNAME", "root"))
    p.add_argument("--tdengine-password", default=os.environ.get("TDENGINE_PASSWORD", "taosdata"))
    p.add_argument("--require-ack", action="store_true", default=True)
    p.add_argument("--tcp-timeout", type=float, default=5)
    p.add_argument("--http-timeout", type=float, default=5)
    p.add_argument("--history-timeout", type=float, default=60)
    return p


def main() -> None:
    args = parser().parse_args()
    summary = run(args)
    print(json.dumps(summary, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
