#!/usr/bin/env python3
"""Convert Qwen JSONL traces to Frontier trace-replay CSV fixtures."""

from __future__ import annotations

import argparse
import csv
import json
import math
import os
import sys
from pathlib import Path
from typing import Any


CSV_FIELDS = [
    "arrived_at",
    "num_prefill_tokens",
    "num_decode_tokens",
    "session_id",
    "block_hash_ids",
]

SIDECAR_FIELDS = [
    "request_id",
    "chat_id",
    "parent_chat_id",
    "turn",
    "type",
    "timestamp",
    "input_length",
    "output_length",
    "hash_ids",
    "block_token_counts",
]


def positive_int(value: str) -> int:
    parsed = int(value)
    if parsed <= 0:
        raise argparse.ArgumentTypeError("must be positive")
    return parsed


def positive_float(value: str) -> float:
    parsed = float(value)
    if parsed <= 0:
        raise argparse.ArgumentTypeError("must be positive")
    return parsed


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Convert Qwen JSONL to Frontier CSV plus ReplayServe sidecar."
    )
    parser.add_argument("--input", required=True, type=Path, help="Qwen JSONL path.")
    parser.add_argument(
        "--frontier-csv", required=True, type=Path, help="Output Frontier CSV path."
    )
    parser.add_argument(
        "--sidecar-jsonl",
        required=True,
        type=Path,
        help="Output ReplayServe sidecar JSONL path.",
    )
    parser.add_argument(
        "--source-jsonl",
        type=Path,
        help="Optional path for the original source JSONL slice.",
    )
    parser.add_argument(
        "--manifest-json", type=Path, help="Optional path for fixture manifest JSON."
    )
    parser.add_argument(
        "--fixture-name", help="Optional fixture name stored in the manifest."
    )
    parser.add_argument(
        "--limit", type=positive_int, help="Maximum number of rows to convert."
    )
    parser.add_argument("--max-tokens", type=positive_int, default=32768)
    parser.add_argument("--block-size", type=positive_int, default=16)
    parser.add_argument(
        "--timestamp-scale",
        type=positive_float,
        default=1.0,
        help="Multiply each source timestamp before writing fixture files.",
    )
    parser.add_argument(
        "--fail-on-overflow",
        action="store_true",
        help="Hard fail if input_length + output_length exceeds --max-tokens.",
    )
    return parser.parse_args()


def require_int(row: dict[str, Any], key: str, line_number: int) -> int:
    try:
        value = row[key]
    except KeyError as exc:
        raise ValueError(f"line {line_number}: missing field {key!r}") from exc
    if isinstance(value, bool) or not isinstance(value, int):
        raise ValueError(f"line {line_number}: field {key!r} must be an int")
    return value


def require_number(row: dict[str, Any], key: str, line_number: int) -> int | float:
    try:
        value = row[key]
    except KeyError as exc:
        raise ValueError(f"line {line_number}: missing field {key!r}") from exc
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"line {line_number}: field {key!r} must be numeric")
    return value


def require_hash_ids(row: dict[str, Any], line_number: int) -> list[int]:
    try:
        value = row["hash_ids"]
    except KeyError as exc:
        raise ValueError(f"line {line_number}: missing field 'hash_ids'") from exc
    if not isinstance(value, list):
        raise ValueError(f"line {line_number}: field 'hash_ids' must be a list")
    hash_ids: list[int] = []
    for index, item in enumerate(value):
        if isinstance(item, bool) or not isinstance(item, int):
            raise ValueError(
                f"line {line_number}: hash_ids[{index}] must be an int"
            )
        hash_ids.append(item)
    return hash_ids


def block_token_counts(input_length: int, hash_count: int, block_size: int) -> list[int]:
    if hash_count == 0:
        return []
    last_count = input_length % block_size
    if last_count == 0:
        last_count = block_size
    return [block_size] * (hash_count - 1) + [last_count]


def convert_row(
    row: dict[str, Any],
    request_id: int,
    line_number: int,
    block_size: int,
    max_tokens: int,
    fail_on_overflow: bool,
    timestamp_scale: float,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
    chat_id = require_int(row, "chat_id", line_number)
    parent_chat_id = require_int(row, "parent_chat_id", line_number)
    timestamp = float(require_number(row, "timestamp", line_number)) * timestamp_scale
    input_length = require_int(row, "input_length", line_number)
    output_length = require_int(row, "output_length", line_number)
    turn = require_int(row, "turn", line_number)
    request_type = row.get("type")
    hash_ids = require_hash_ids(row, line_number)

    if input_length <= 0:
        raise ValueError(f"line {line_number}: input_length must be positive")
    if output_length <= 0:
        raise ValueError(f"line {line_number}: output_length must be positive")

    expected_hash_count = math.ceil(input_length / block_size)
    if len(hash_ids) != expected_hash_count:
        raise ValueError(
            f"line {line_number}: len(hash_ids)={len(hash_ids)} does not match "
            f"ceil(input_length / block_size)={expected_hash_count}"
        )

    total_tokens = input_length + output_length
    overflow = total_tokens > max_tokens
    if overflow and fail_on_overflow:
        raise ValueError(
            f"line {line_number}: total_tokens={total_tokens} exceeds "
            f"max_tokens={max_tokens}"
        )

    counts = block_token_counts(input_length, len(hash_ids), block_size)
    frontier_row = {
        "arrived_at": timestamp,
        "num_prefill_tokens": input_length,
        "num_decode_tokens": output_length,
        "session_id": chat_id,
        "block_hash_ids": "|".join(str(item) for item in hash_ids),
    }
    sidecar_row = {
        "request_id": request_id,
        "chat_id": chat_id,
        "parent_chat_id": parent_chat_id,
        "turn": turn,
        "type": request_type,
        "timestamp": timestamp,
        "input_length": input_length,
        "output_length": output_length,
        "hash_ids": hash_ids,
        "block_token_counts": counts,
    }
    stats = {
        "total_tokens": total_tokens,
        "input_length": input_length,
        "output_length": output_length,
        "timestamp": timestamp,
        "partial_final_block": input_length % block_size != 0,
        "overflow": overflow,
    }
    return frontier_row, sidecar_row, stats


def tmp_path(path: Path) -> Path:
    return path.with_name(f".{path.name}.tmp")


def ensure_parent(path: Path | None) -> None:
    if path is not None:
        path.parent.mkdir(parents=True, exist_ok=True)


def publish_tmp_files(paths: list[tuple[Path, Path]]) -> None:
    for temporary, final in paths:
        os.replace(temporary, final)


def cleanup_tmp_files(paths: list[tuple[Path, Path]]) -> None:
    for temporary, _ in paths:
        try:
            temporary.unlink()
        except FileNotFoundError:
            pass


def main() -> int:
    args = parse_args()
    for output_path in (
        args.frontier_csv,
        args.sidecar_jsonl,
        args.source_jsonl,
        args.manifest_json,
    ):
        ensure_parent(output_path)

    temporary_paths: list[tuple[Path, Path]] = [
        (tmp_path(args.frontier_csv), args.frontier_csv),
        (tmp_path(args.sidecar_jsonl), args.sidecar_jsonl),
    ]
    if args.source_jsonl is not None:
        temporary_paths.append((tmp_path(args.source_jsonl), args.source_jsonl))
    if args.manifest_json is not None:
        temporary_paths.append((tmp_path(args.manifest_json), args.manifest_json))

    row_count = 0
    overflow_count = 0
    max_total_tokens = 0
    max_input_length = 0
    max_output_length = 0
    first_timestamp: float | None = None
    last_timestamp: float | None = None
    timestamp_monotonic = True
    partial_final_block_rows = 0

    try:
        with (
            args.input.open("r", encoding="utf-8") as input_file,
            tmp_path(args.frontier_csv).open("w", encoding="utf-8", newline="") as csv_file,
            tmp_path(args.sidecar_jsonl).open("w", encoding="utf-8") as sidecar_file,
        ):
            csv_writer = csv.DictWriter(
                csv_file, fieldnames=CSV_FIELDS, lineterminator="\n"
            )
            csv_writer.writeheader()

            source_file = None
            if args.source_jsonl is not None:
                source_file = tmp_path(args.source_jsonl).open("w", encoding="utf-8")

            try:
                for line_number, raw_line in enumerate(input_file, start=1):
                    if args.limit is not None and row_count >= args.limit:
                        break
                    stripped = raw_line.strip()
                    if not stripped:
                        continue
                    row = json.loads(stripped)
                    frontier_row, sidecar_row, stats = convert_row(
                        row=row,
                        request_id=line_number - 1,
                        line_number=line_number,
                        block_size=args.block_size,
                        max_tokens=args.max_tokens,
                        fail_on_overflow=args.fail_on_overflow,
                        timestamp_scale=args.timestamp_scale,
                    )
                    csv_writer.writerow(frontier_row)
                    sidecar_file.write(
                        json.dumps(sidecar_row, sort_keys=True, separators=(",", ":"))
                        + "\n"
                    )
                    if source_file is not None:
                        if args.timestamp_scale == 1.0:
                            source_file.write(
                                raw_line if raw_line.endswith("\n") else raw_line + "\n"
                            )
                        else:
                            source_row = dict(row)
                            source_row["timestamp"] = stats["timestamp"]
                            source_file.write(
                                json.dumps(
                                    source_row, sort_keys=True, separators=(",", ":")
                                )
                                + "\n"
                            )

                    row_count += 1
                    overflow_count += int(stats["overflow"])
                    max_total_tokens = max(max_total_tokens, int(stats["total_tokens"]))
                    max_input_length = max(max_input_length, int(stats["input_length"]))
                    max_output_length = max(max_output_length, int(stats["output_length"]))
                    partial_final_block_rows += int(stats["partial_final_block"])
                    timestamp = float(stats["timestamp"])
                    if first_timestamp is None:
                        first_timestamp = timestamp
                    if last_timestamp is not None and timestamp < last_timestamp:
                        timestamp_monotonic = False
                    last_timestamp = timestamp
            finally:
                if source_file is not None:
                    source_file.close()

        if args.manifest_json is not None:
            manifest = {
                "fixture_name": args.fixture_name,
                "generated_by": "tools/qwen_to_frontier.py",
                "input_jsonl": str(args.input),
                "source_jsonl": str(args.source_jsonl) if args.source_jsonl else None,
                "frontier_csv": str(args.frontier_csv),
                "sidecar_jsonl": str(args.sidecar_jsonl),
                "csv_fields": CSV_FIELDS,
                "sidecar_fields": SIDECAR_FIELDS,
                "limit": args.limit,
                "row_count": row_count,
                "block_size": args.block_size,
                "max_tokens": args.max_tokens,
                "fail_on_overflow": args.fail_on_overflow,
                "timestamp_scale": args.timestamp_scale,
                "overflow_count": overflow_count,
                "max_total_tokens": max_total_tokens,
                "max_input_length": max_input_length,
                "max_output_length": max_output_length,
                "first_timestamp": first_timestamp,
                "last_timestamp": last_timestamp,
                "timestamp_monotonic": timestamp_monotonic,
                "partial_final_block_rows": partial_final_block_rows,
                "adapter_semantics": {
                    "timestamp": "arrived_at",
                    "input_length": "num_prefill_tokens",
                    "output_length": "num_decode_tokens",
                    "chat_id": "session_id",
                    "hash_ids": "block_hash_ids joined by |",
                    "block_token_counts": (
                        "full blocks use block_size tokens; final partial block "
                        "uses input_length % block_size, or block_size when zero"
                    ),
                },
            }
            with tmp_path(args.manifest_json).open("w", encoding="utf-8") as manifest_file:
                json.dump(manifest, manifest_file, indent=2, sort_keys=True)
                manifest_file.write("\n")

        publish_tmp_files(temporary_paths)
    except Exception as exc:
        cleanup_tmp_files(temporary_paths)
        print(f"qwen_to_frontier.py: error: {exc}", file=sys.stderr)
        return 1

    if overflow_count and not args.fail_on_overflow:
        print(
            f"qwen_to_frontier.py: warning: {overflow_count} rows exceed "
            f"max_tokens={args.max_tokens}; no clipping was applied",
            file=sys.stderr,
        )
    print(
        f"converted rows={row_count} max_total_tokens={max_total_tokens} "
        f"overflows={overflow_count}",
        file=sys.stderr,
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
