import sys
from io import StringIO
from pathlib import Path
from typing import Any, Optional

import polars as pl
import pytest
import torch
from rich.console import Console

from sglang.srt.debug_utils.comparator.display import (
    _collect_input_ids_and_positions,
    _collect_rank_info,
    _extract_parallel_info,
    _render_polars_as_rich_table,
    _render_polars_as_text,
)
from sglang.srt.debug_utils.comparator.output_types import (
    InputIdsRecord,
    RankInfoRecord,
)
from sglang.test.ci.ci_register import register_cpu_ci

register_cpu_ci(est_time=10, suite="base-a-test-cpu", nightly=True)


def _render_rich(renderable: object) -> str:
    buf: StringIO = StringIO()
    Console(file=buf, force_terminal=False, width=120).print(renderable)
    return buf.getvalue().rstrip("\n")


def _save_dump_file(
    directory: Path,
    *,
    name: str,
    step: int,
    rank: int,
    dump_index: int,
    value: torch.Tensor,
    meta: dict,
) -> str:
    filename = f"name={name}___step={step}___rank={rank}___dump_index={dump_index}.pt"
    torch.save({"value": value, "meta": meta}, directory / filename)
    return filename


def _make_df(rows: list[dict]) -> pl.DataFrame:
    df = pl.DataFrame(rows)
    df = df.with_columns(
        pl.col("step").cast(int),
        pl.col("rank").cast(int),
        pl.col("dump_index").cast(int),
    )
    return df


class TestRenderPolarsAsText:
    def test_renders_table(self) -> None:
        df = pl.DataFrame({"col_a": [1, 2], "col_b": ["x", "y"]})
        text: str = _render_polars_as_text(df, title="test table")

        assert "test table" in text
        assert "col_a" in text
        assert "col_b" in text

    def test_renders_empty_dataframe(self) -> None:
        df = pl.DataFrame({"a": [], "b": []})
        text: str = _render_polars_as_text(df, title="empty")
        assert "empty" in text


class TestCollectRankInfo:
    def test_collects_rank_info(self, tmp_path: Path) -> None:
        sglang_info = {
            "tp_rank": 0,
            "tp_size": 2,
            "pp_rank": 0,
            "pp_size": 1,
        }
        filename: str = _save_dump_file(
            tmp_path,
            name="input_ids",
            step=0,
            rank=0,
            dump_index=0,
            value=torch.tensor([1, 2, 3]),
            meta={"sglang_parallel_info": sglang_info},
        )
        df = _make_df(
            [
                {
                    "filename": filename,
                    "name": "input_ids",
                    "step": 0,
                    "rank": 0,
                    "dump_index": 0,
                }
            ]
        )

        rows: Optional[list[dict[str, Any]]] = _collect_rank_info(df, dump_dir=tmp_path)

        assert rows is not None
        assert len(rows) == 1
        assert rows[0]["rank"] == 0
        assert rows[0]["tp"] == "0/2"
        assert rows[0]["pp"] == "0/1"

    def test_returns_none_when_no_input_ids(self, tmp_path: Path) -> None:
        df = _make_df(
            [
                {
                    "filename": "f.pt",
                    "name": "some_other",
                    "step": 0,
                    "rank": 0,
                    "dump_index": 0,
                }
            ]
        )
        result = _collect_rank_info(df, dump_dir=tmp_path)
        assert result is None

    def test_deduplicates_ranks(self, tmp_path: Path) -> None:
        meta = {"sglang_parallel_info": {"tp_rank": 0, "tp_size": 1}}
        f1: str = _save_dump_file(
            tmp_path,
            name="input_ids",
            step=0,
            rank=0,
            dump_index=0,
            value=torch.tensor([1]),
            meta=meta,
        )
        f2: str = _save_dump_file(
            tmp_path,
            name="input_ids",
            step=1,
            rank=0,
            dump_index=1,
            value=torch.tensor([2]),
            meta=meta,
        )
        df = _make_df(
            [
                {
                    "filename": f1,
                    "name": "input_ids",
                    "step": 0,
                    "rank": 0,
                    "dump_index": 0,
                },
                {
                    "filename": f2,
                    "name": "input_ids",
                    "step": 1,
                    "rank": 0,
                    "dump_index": 1,
                },
            ]
        )

        rows = _collect_rank_info(df, dump_dir=tmp_path)

        assert rows is not None
        assert len(rows) == 1


class TestCollectInputIdsAndPositions:
    def test_collects_ids_and_positions(self, tmp_path: Path) -> None:
        f_ids: str = _save_dump_file(
            tmp_path,
            name="input_ids",
            step=0,
            rank=0,
            dump_index=0,
            value=torch.tensor([10, 20, 30]),
            meta={},
        )
        f_pos: str = _save_dump_file(
            tmp_path,
            name="positions",
            step=0,
            rank=0,
            dump_index=1,
            value=torch.tensor([0, 1, 2]),
            meta={},
        )
        df = _make_df(
            [
                {
                    "filename": f_ids,
                    "name": "input_ids",
                    "step": 0,
                    "rank": 0,
                    "dump_index": 0,
                },
                {
                    "filename": f_pos,
                    "name": "positions",
                    "step": 0,
                    "rank": 0,
                    "dump_index": 1,
                },
            ]
        )

        rows = _collect_input_ids_and_positions(df, dump_dir=tmp_path)

        assert rows is not None
        assert len(rows) == 1
        assert rows[0]["step"] == 0
        assert rows[0]["rank"] == 0
        assert rows[0]["num_tokens"] == 3
        assert "10" in rows[0]["input_ids"]
        assert "0" in rows[0]["positions"]

    def test_returns_none_when_empty(self, tmp_path: Path) -> None:
        df = _make_df(
            [
                {
                    "filename": "f.pt",
                    "name": "weight",
                    "step": 0,
                    "rank": 0,
                    "dump_index": 0,
                }
            ]
        )
        result = _collect_input_ids_and_positions(df, dump_dir=tmp_path)
        assert result is None

    def test_with_mock_tokenizer(self, tmp_path: Path) -> None:
        f_ids: str = _save_dump_file(
            tmp_path,
            name="input_ids",
            step=0,
            rank=0,
            dump_index=0,
            value=torch.tensor([1, 2]),
            meta={},
        )
        df = _make_df(
            [
                {
                    "filename": f_ids,
                    "name": "input_ids",
                    "step": 0,
                    "rank": 0,
                    "dump_index": 0,
                }
            ]
        )

        class _MockTokenizer:
            def decode(self, ids: list[int], skip_special_tokens: bool = False) -> str:
                return f"decoded:{ids}"

        rows = _collect_input_ids_and_positions(
            df, dump_dir=tmp_path, tokenizer=_MockTokenizer()
        )

        assert rows is not None
        assert "decoded_text" in rows[0]
        assert "decoded:" in rows[0]["decoded_text"]


class TestRankInfoRecordSnapshot:
    def test_to_text_snapshot(self) -> None:
        record = RankInfoRecord(
            label="baseline",
            rows=[
                {"rank": 0, "tp": "0/2", "pp": "0/1"},
                {"rank": 1, "tp": "1/2", "pp": "0/1"},
            ],
        )
        text: str = record.to_text()

        assert "baseline ranks" in text
        assert "rank" in text
        assert "tp" in text
        assert "pp" in text
        assert "0/2" in text
        assert "1/2" in text
        assert "0/1" in text

    def test_to_rich_snapshot(self) -> None:
        from rich.table import Table

        record = RankInfoRecord(
            label="baseline",
            rows=[
                {"rank": 0, "tp": "0/2", "pp": "0/1"},
                {"rank": 1, "tp": "1/2", "pp": "0/1"},
            ],
        )
        body = record._format_rich_body()

        assert isinstance(body, Table)
        rendered: str = _render_rich(body)
        assert "baseline ranks" in rendered
        assert "0/2" in rendered
        assert "1/2" in rendered

    def test_json_roundtrip(self) -> None:
        record = RankInfoRecord(
            label="target",
            rows=[{"rank": 0, "tp": "0/4"}],
        )
        json_str: str = record.model_dump_json()

        assert '"type":"rank_info"' in json_str
        assert '"label":"target"' in json_str
        assert '"tp":"0/4"' in json_str


class TestInputIdsRecordSnapshot:
    def test_to_text_snapshot(self) -> None:
        record = InputIdsRecord(
            label="target",
            rows=[
                {
                    "step": 0,
                    "rank": 0,
                    "num_tokens": 3,
                    "input_ids": "[10, 20, 30]",
                    "positions": "[0, 1, 2]",
                },
            ],
        )
        text: str = record.to_text()

        assert "target input_ids & positions" in text
        assert "step" in text
        assert "num_tokens" in text
        assert "10, 20, 30" in text
        assert "0, 1, 2" in text

    def test_to_rich_snapshot(self) -> None:
        from rich.table import Table

        record = InputIdsRecord(
            label="target",
            rows=[
                {
                    "step": 0,
                    "rank": 0,
                    "num_tokens": 3,
                    "input_ids": "[10, 20, 30]",
                    "positions": "[0, 1, 2]",
                },
            ],
        )
        body = record._format_rich_body()

        assert isinstance(body, Table)
        rendered: str = _render_rich(body)
        assert "target input_ids & positions" in rendered
        assert "10, 20, 30" in rendered
        assert "0, 1, 2" in rendered

    def test_json_roundtrip(self) -> None:
        record = InputIdsRecord(
            label="baseline",
            rows=[
                {
                    "step": 0,
                    "rank": 0,
                    "num_tokens": 2,
                    "input_ids": "[1, 2]",
                    "positions": "[0, 1]",
                    "decoded_text": "'hello'",
                },
            ],
        )
        json_str: str = record.model_dump_json()

        assert '"type":"input_ids"' in json_str
        assert '"label":"baseline"' in json_str
        assert '"decoded_text"' in json_str

    def test_to_text_with_decoded(self) -> None:
        record = InputIdsRecord(
            label="test",
            rows=[
                {
                    "step": 0,
                    "rank": 0,
                    "num_tokens": 2,
                    "input_ids": "[1, 2]",
                    "positions": "[0, 1]",
                    "decoded_text": "'hello world'",
                },
            ],
        )
        text: str = record.to_text()

        assert "decoded_text" in text
        assert "hello world" in text


class TestExtractParallelInfo:
    def test_extracts_rank_size_pairs(self) -> None:
        info: dict = {
            "tp_rank": 1,
            "tp_size": 4,
            "pp_rank": 0,
            "pp_size": 2,
        }
        row_data: dict = {}
        _extract_parallel_info(row_data=row_data, info=info)

        assert row_data["tp"] == "1/4"
        assert row_data["pp"] == "0/2"

    def test_skips_error_info(self) -> None:
        row_data: dict = {}
        _extract_parallel_info(
            row_data=row_data, info={"error": True, "tp_rank": 0, "tp_size": 1}
        )
        assert row_data == {}

    def test_skips_empty_info(self) -> None:
        row_data: dict = {}
        _extract_parallel_info(row_data=row_data, info={})
        assert row_data == {}

    def test_ignores_rank_without_size(self) -> None:
        row_data: dict = {}
        _extract_parallel_info(row_data=row_data, info={"tp_rank": 0})
        assert "tp" not in row_data


class TestRenderPolarsAsRichTable:
    def test_basic_dataframe_renders_table(self) -> None:
        df = pl.DataFrame({"a": [1, 2], "b": ["x", "y"]})
        table = _render_polars_as_rich_table(df)
        assert len(table.columns) == 2
        assert table.row_count == 2

    def test_empty_dataframe_returns_table_with_no_rows(self) -> None:
        df = pl.DataFrame(
            {"a": pl.Series([], dtype=pl.Int64), "b": pl.Series([], dtype=pl.Utf8)}
        )
        table = _render_polars_as_rich_table(df)
        assert len(table.columns) == 2
        assert table.row_count == 0

    def test_title_passed_to_table(self) -> None:
        df = pl.DataFrame({"a": [1]})
        table = _render_polars_as_rich_table(df, title="My Title")
        assert table.title == "My Title"

    def test_no_title_defaults_to_none(self) -> None:
        df = pl.DataFrame({"x": [1]})
        table = _render_polars_as_rich_table(df)
        assert table.title is None

    def test_column_names_match_dataframe(self) -> None:
        df = pl.DataFrame({"alpha": [1], "beta": [2], "gamma": [3]})
        table = _render_polars_as_rich_table(df)
        column_headers: list[str] = [col.header for col in table.columns]
        assert column_headers == ["alpha", "beta", "gamma"]

    def test_values_converted_to_strings(self) -> None:
        """Numeric and None values should be stringified in the rendered output."""
        df = pl.DataFrame({"num": [42], "text": ["hello"]})
        table = _render_polars_as_rich_table(df)
        rendered: str = _render_rich(table)
        assert "42" in rendered
        assert "hello" in rendered

    def test_single_column_dataframe(self) -> None:
        df = pl.DataFrame({"only_col": [10, 20, 30]})
        table = _render_polars_as_rich_table(df)
        assert len(table.columns) == 1
        assert table.row_count == 3

    def test_many_rows_all_present(self) -> None:
        """All rows from the dataframe appear in the rich table."""
        df = pl.DataFrame({"val": list(range(50))})
        table = _render_polars_as_rich_table(df)
        assert table.row_count == 50

    def test_null_values_rendered_as_string(self) -> None:
        """Null values should be converted to their string representation."""
        df = pl.DataFrame({"a": [1, None, 3]})
        table = _render_polars_as_rich_table(df)
        assert table.row_count == 3
        rendered: str = _render_rich(table)
        assert (
            "null" in rendered.lower()
            or "none" in rendered.lower()
            or "None" in rendered
        )


if __name__ == "__main__":
    sys.exit(pytest.main([__file__]))
