"""Tests for the custom dataframe helper package."""

from datetime import datetime, date
import logging
import os
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch

import pandas as pd
import pytest

import soigia
import soigia.datatable as dtpkg
from soigia.datatable import DataTable


def test_df_inherits_from_pandas_dataframe():
    frame = DataTable({"a": [1, 2], "b": [3, 4]})

    assert isinstance(frame, pd.DataFrame)
    assert isinstance(frame, DataTable)


def test_df_constructor_normalizes_string_columns_to_lowercase():
    source = pd.DataFrame({"NAME": ["alice"], "Age": [19]})

    frame = DataTable(source)

    assert list(frame.columns) == ["name", "age"]
    assert frame.iloc[0].to_dict() == {"name": "alice", "age": 19}


def test_df_constructor_normalizes_dict_columns_to_lowercase():
    frame = DataTable({"NAME": ["alice"], "CITY": ["HN"]})

    assert list(frame.columns) == ["name", "city"]


def test_df_allows_attribute_access_and_assignment_for_simple_column_names():
    frame = DataTable(pd.DataFrame({"NAME": ["alice", "bob"], "AGE": [19, 24]}))

    assert frame.name.tolist() == ["alice", "bob"]

    frame.name = 123

    assert frame["name"].tolist() == [123, 123]
    assert frame.name.tolist() == [123, 123]


def test_df_operations_preserve_subclass():
    frame = DataTable({"a": [1, 2], "b": [3, 4]})
    sliced = frame[["a"]]

    assert isinstance(sliced, DataTable)


def test_df_helpers_work():
    frame = DataTable({" A ": ["1", None], "B B": ["2", "3"]})

    cleaned = frame.clean_columns().fill_missing(0).to_numeric()

    assert list(cleaned.columns) == ["a", "b_b"]
    assert cleaned["a"].iloc[1] == 0
    assert cleaned["b_b"].dtype.kind in {"i", "u", "f"}


def test_df_from_records_and_json_work():
    records = [{"name": "alice", "age": 19}, {"name": "bob", "age": 24}]
    with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as handle:
        handle.write('[{"name":"alice","age":19},{"name":"bob","age":24}]')
        json_path = handle.name

    try:
        from_records = DataTable.from_records(records)
        from_json = DataTable.from_json(json_path)

        assert from_records.equals(from_json)
        assert list(from_json["name"]) == ["alice", "bob"]
    finally:
        os.unlink(json_path)


def test_df_is_exported_from_soigia():
    assert soigia.datatable.DataTable is DataTable


def test_df_database_helpers_are_exported():
    assert callable(dtpkg.db_connector)
    assert callable(dtpkg.from_sql)
    assert callable(dtpkg.from_mysql_db)
    assert callable(dtpkg.from_maria_db)
    assert callable(dtpkg.from_oracle_db)
    assert callable(dtpkg.from_postgres_db)
    assert callable(dtpkg.from_sqlite_db)


def test_df_has_unittest_style_helpers():
    frame = DataTable()

    instance_methods = [
        "assertEqual",
        "assertNotEqual",
        "assertTrue",
        "assertFalse",
        "assertIs",
        "assertIsNot",
        "assertIsNone",
        "assertIsNotNone",
        "assertIn",
        "assertNotIn",
        "assertIsInstance",
        "assertNotIsInstance",
        "assertIsSubclass",
        "assertNotIsSubclass",
        "assertAlmostEqual",
        "assertNotAlmostEqual",
        "assertGreater",
        "assertGreaterEqual",
        "assertLess",
        "assertLessEqual",
        "assertRaises",
        "assertRaisesRegex",
        "assertWarns",
        "assertWarnsRegex",
        "assertLogs",
        "assertNoLogs",
        "assertMultiLineEqual",
        "assertSequenceEqual",
        "assertListEqual",
        "assertTupleEqual",
        "assertSetEqual",
        "assertDictEqual",
        "assertCountEqual",
        "assertRegex",
        "assertNotRegex",
        "fail",
        "addTypeEqualityFunc",
        "setUp",
        "tearDown",
        "subTest",
        "addCleanup",
        "doCleanups",
        "enterContext",
        "run",
        "debug",
        "defaultTestResult",
        "countTestCases",
        "id",
        "shortDescription",
    ]
    class_methods = ["setUpClass", "tearDownClass", "addClassCleanup", "doClassCleanups", "enterClassContext"]

    for name in instance_methods:
        assert callable(getattr(frame, name))
    for name in class_methods:
        assert callable(getattr(DataTable, name))


def test_df_exposes_chainable_query_methods():
    chain_methods = {"filter", "exclude", "order_by", "get", "count", "exists", "first", "last", "paginate"}

    for name in chain_methods:
        assert callable(getattr(DataTable, name))


def test_base_dataframe_exact_usage():
    df_instance = DataTable()

    assert isinstance(df_instance, DataTable)
    assert df_instance.empty is True
    assert df_instance.objects.count() == df_instance.orm.count() == 0
    assert df_instance.manager.count() == df_instance.objects.count()


def test_df_orm_like_queries_work():
    frame = DataTable(
        {
            "name": ["alice", "bob", "carol", "dave"],
            "age": [19, 24, 31, 24],
            "active": [True, True, False, True],
        }
    )

    qs = frame.objects
    filtered = qs.filter(age__gte=24, name__contains="a").values("name", "age")
    excluded = qs.exclude(active=False).values("name")
    ordered = qs.order_by("-age", "name")
    first = ordered.first()
    last = ordered.last()
    item = qs.get(name="bob")

    assert filtered == [{"name": "carol", "age": 31}, {"name": "dave", "age": 24}]
    assert excluded == [{"name": "alice"}, {"name": "bob"}, {"name": "dave"}]
    assert ordered.values_list("name") == [("carol",), ("bob",), ("dave",), ("alice",)]
    assert isinstance(first, DataTable)
    assert isinstance(last, DataTable)
    assert first["name"].iloc[0] == "carol"
    assert last["name"].iloc[0] == "alice"
    assert item["age"].iloc[0] == 24
    assert qs.count() == 4
    assert qs.exists() is True
    assert qs.none().count() == 0


def test_df_values_helpers_work():
    frame = DataTable({"name": ["alice", "bob", "alice"], "age": [19, 24, 19]})

    values = frame.orm.values()
    names = frame.orm.values_list("name", flat=True)
    pairs = frame.orm.values_list("name", "age")
    distinct_values = values.distinct("name", "age")
    distinct_names = names.distinct()
    distinct_flat_names = frame.objects.values_list("name", flat=True).distinct()

    assert values == [{"name": "alice", "age": 19}, {"name": "bob", "age": 24}, {"name": "alice", "age": 19}]
    assert values.first() == {"name": "alice", "age": 19}
    assert values.last() == {"name": "alice", "age": 19}
    assert values.filter(name="alice") == [{"name": "alice", "age": 19}, {"name": "alice", "age": 19}]
    assert values.exclude(name="alice") == [{"name": "bob", "age": 24}]
    assert values.order_by("name") == [
        {"name": "alice", "age": 19},
        {"name": "alice", "age": 19},
        {"name": "bob", "age": 24},
    ]
    assert distinct_values == [{"name": "alice", "age": 19}, {"name": "bob", "age": 24}]
    assert names == ["alice", "bob", "alice"]
    assert names.first() == "alice"
    assert names.last() == "alice"
    assert distinct_names == ["alice", "bob"]
    assert distinct_flat_names == ["alice", "bob"]
    assert pairs == [("alice", 19), ("bob", 24), ("alice", 19)]


def test_df_distinct_preserves_first_occurrence():
    frame = DataTable({"name": ["a", "b", "a", "c", "b"]})

    distinct_names = frame.objects.values_list("name", flat=True).distinct()

    assert distinct_names == ["a", "b", "c"]


def test_df_group_by_and_aggregates_work():
    frame = DataTable(
        {
            "city": ["hanoi", "hanoi", "saigon", "saigon"],
            "amount": [10, 20, 5, 15],
        }
    )

    grouped = frame.orm.group_by("city").annotate(total_amount=("amount", "sum"), avg_amount=("amount", "mean"))
    stats = frame.orm.sum("amount")

    assert grouped.values() == [
        {"city": "hanoi", "total_amount": 30, "avg_amount": 15.0},
        {"city": "saigon", "total_amount": 20, "avg_amount": 10.0},
    ]
    assert stats == {"amount": 50}


def test_df_paginate_work():
    frame = DataTable({"n": [1, 2, 3, 4, 5]})

    page = frame.orm.paginate(limit=2, offset=2).to_df()

    assert list(page["n"]) == [3, 4]


def test_df_between_and_datetime_lookups_work():
    frame = DataTable(
        {
            "amount": [5, 10, 15, 20],
            "created_at": [
                datetime(2026, 1, 1, 8, 0),
                datetime(2026, 1, 2, 8, 0),
                datetime(2026, 1, 3, 8, 0),
                datetime(2026, 1, 4, 8, 0),
            ],
        }
    )

    between = frame.orm.filter(amount__between=(10, 20))
    by_year = frame.orm.filter(created_at__year=2026)
    by_date = frame.orm.filter(created_at__date=date(2026, 1, 2))

    assert list(between["amount"]) == [10, 15, 20]
    assert list(by_year["amount"]) == [5, 10, 15, 20]
    assert list(by_date["amount"]) == [10]


def test_df_order_by_handles_nulls():
    frame = DataTable({"name": ["b", None, "a"], "score": [2, 1, 3], "rank": [2, 1, 1]})

    ordered = frame.orm.order_by("name").to_df()
    nulls_first = frame.orm.order_by("name", nulls_first=True).to_df()
    nulls_first_explicit = frame.orm.order_by("name", nulls_last=False).to_df()
    multi_nulls = frame.orm.order_by("rank", "name", nulls={"rank": "last", "name": "first"}).to_df()

    assert list(ordered["name"]) == ["a", "b", None]
    assert list(nulls_first["name"]) == [None, "a", "b"]
    assert list(nulls_first_explicit["name"]) == [None, "a", "b"]
    assert list(multi_nulls["name"]) == [None, "a", "b"]


def test_df_random_order_work():
    frame = DataTable({"n": [1, 2, 3, 4, 5]})

    shuffled = frame.orm.order_by("?").to_df()
    random_alias = frame.objects.random_order().to_df()

    assert sorted(shuffled["n"].tolist()) == [1, 2, 3, 4, 5]
    assert sorted(random_alias["n"].tolist()) == [1, 2, 3, 4, 5]


def test_df_aggregate_work():
    frame = DataTable({"amount": [10, 20, 30]})

    stats = frame.orm.aggregate(total=("amount", "sum"), avg=("amount", "mean"), max_value=("amount", "max"))

    assert stats == {"total": 60, "avg": 20.0, "max_value": 30}


def test_df_grouped_aggregate_work():
    frame = DataTable(
        {
            "city": ["hanoi", "hanoi", "saigon", "saigon"],
            "amount": [10, 20, 5, 15],
        }
    )

    grouped = frame.orm.group_by("city").aggregate(total=("amount", "sum"), average=("amount", "mean"))

    assert grouped == {
        "total": [
            {"city": "hanoi", "total": 30},
            {"city": "saigon", "total": 20},
        ],
        "average": [
            {"city": "hanoi", "average": 15.0},
            {"city": "saigon", "average": 10.0},
        ],
    }


def test_df_write_helpers_work():
    frame = DataTable({"name": ["alice", "bob"], "age": [19, 24]})

    created = frame.objects.create(name="carol", age=31)
    appended = frame.objects.append_row(name="dave", age=27)
    bulk_created = frame.objects.bulk_create([{"name": "erin", "age": 22}, {"name": "frank", "age": 29}])
    bulk_updated, bulk_updated_count = frame.objects.bulk_update({"age": 25}, return_count=True, age__gte=20)
    fetched, created_flag = frame.objects.get_or_create(defaults={"age": 31}, name="bob")
    updated, updated_created = frame.objects.update_or_create(defaults={"age": 31}, name="dave")
    updated_count = frame.objects.filter(age__gte=20).update(age=25)
    deleted_count = frame.objects.filter(name="alice").delete()

    assert isinstance(created, DataTable)
    assert created.values.tolist() == [["carol", 31]]
    assert list(appended["name"]) == ["alice", "bob", "dave"]
    assert list(bulk_created["name"]) == ["alice", "bob", "erin", "frank"]
    assert list(bulk_updated["age"]) == [19, 25]
    assert bulk_updated_count == 1
    assert fetched.values.tolist() == [["bob", 24]]
    assert created_flag is False
    assert updated.to_dict(orient="records") == [{"name": "dave", "age": 31}]
    assert updated_created is True
    assert updated_count == 1
    assert deleted_count == 1
    assert frame.objects.save().equals(frame)


def test_df_select_aliases_work():
    frame = DataTable({"name": ["alice", "bob"], "age": [19, 24], "city": ["hn", "sg"]})

    selected = frame.objects.select("name", "age").to_df()
    excluded = frame.objects.exclude_fields("city").to_df()

    assert list(selected.columns) == ["name", "age"]
    assert list(excluded.columns) == ["name", "age"]


def test_df_unittest_style_helpers_work():
    frame = DataTable({"n": [1, 2, 3]})

    frame.assertEqual(1, 1)
    frame.assertNotEqual(1, 2)
    frame.assertTrue(True)
    frame.assertFalse(False)
    frame.assertIs(None, None)
    frame.assertIsNot(None, object())
    frame.assertIsNone(None)
    frame.assertIsNotNone(1)
    frame.assertIn("a", "cat")
    frame.assertNotIn("z", "cat")
    frame.assertIsInstance(frame, DataTable)
    frame.assertNotIsInstance(1, DataTable)
    frame.assertIsSubclass(DataTable, pd.DataFrame)
    frame.assertNotIsSubclass(dict, pd.DataFrame)
    frame.assertAlmostEqual(1.0, 1.0001, places=3)
    frame.assertNotAlmostEqual(1.0, 1.2, places=1)
    frame.assertGreater(3, 2)
    frame.assertGreaterEqual(3, 3)
    frame.assertLess(2, 3)
    frame.assertLessEqual(3, 3)
    frame.assertMultiLineEqual("a\nb", "a\nb")
    frame.assertSequenceEqual([1, 2], [1, 2])
    frame.assertListEqual([1, 2], [1, 2])
    frame.assertTupleEqual((1, 2), (1, 2))
    frame.assertSetEqual({1, 2}, {2, 1})
    frame.assertDictEqual({"a": 1}, {"a": 1})
    frame.assertCountEqual([1, 2, 2], [2, 1, 2])
    frame.assertRegex("abc", r"b")
    frame.assertNotRegex("abc", r"z")
    frame.assertEqual(frame.countTestCases(), 1)
    assert isinstance(frame.defaultTestResult(), unittest.TestResult)
    assert isinstance(frame.run(), unittest.TestResult)
    assert frame.shortDescription() is None
    assert isinstance(frame.id(), str)

    with frame.assertRaises(ZeroDivisionError):
        1 / 0

    with frame.assertRaisesRegex(ValueError, "bad"):
        raise ValueError("bad value")

    with frame.assertNoLogs("soigia.testcase", level="INFO"):
        pass

    logger_name = "soigia.testcase"
    logger = logging.getLogger(logger_name)
    with frame.assertLogs(logger_name, level="INFO") as captured:
        logger.info("hello")
    assert "hello" in "\n".join(captured.output)

    events = []

    @contextmanager
    def marker():
        events.append("enter")
        try:
            yield "value"
        finally:
            events.append("exit")

    assert frame.enterContext(marker()) == "value"
    frame.addCleanup(events.append, "cleanup")
    frame.doCleanups()
    assert events == ["enter", "cleanup", "exit"]

    class_seen = []
    DataTable.addClassCleanup(class_seen.append, "class-cleanup")
    DataTable.doClassCleanups()
    assert class_seen == ["class-cleanup"]


def test_df_file_and_sql_helpers_work():
    frame = DataTable({"name": ["alice"], "age": [19]})

    assert frame.to_rows() == [{"name": "alice", "age": 19}]
    assert "alice" in frame.to_markdown_table()
    assert "<table" in frame.to_html_table()
    assert "name,age" in frame.to_csv_table()

    rich_table = frame.to_rich_table()
    assert len(rich_table.rows) == 1

    with patch("pandas.read_excel", return_value=pd.DataFrame({"name": ["excel"]})) as mocked_excel:
        excel_df = DataTable.from_excel("people.xlsx")
        assert isinstance(excel_df, DataTable)
        assert list(excel_df["name"]) == ["excel"]
        mocked_excel.assert_called_once_with("people.xlsx")

    with patch("pandas.read_parquet", return_value=pd.DataFrame({"name": ["parquet"]})) as mocked_parquet:
        parquet_df = DataTable.from_parquet("people.parquet")
        assert isinstance(parquet_df, DataTable)
        assert list(parquet_df["name"]) == ["parquet"]
        mocked_parquet.assert_called_once_with("people.parquet")

    with patch("pandas.read_feather", return_value=pd.DataFrame({"name": ["feather"]})) as mocked_feather:
        feather_df = DataTable.from_feather("people.feather")
        assert isinstance(feather_df, DataTable)
        assert list(feather_df["name"]) == ["feather"]
        mocked_feather.assert_called_once_with("people.feather")

    with patch("pandas.read_pickle", return_value=pd.DataFrame({"name": ["pickle"]})) as mocked_pickle:
        pickle_df = DataTable.from_pickle("people.pkl")
        assert isinstance(pickle_df, DataTable)
        assert list(pickle_df["name"]) == ["pickle"]
        mocked_pickle.assert_called_once_with("people.pkl")

    with patch("soigia.datatable.from_sql", return_value=pd.DataFrame({"name": ["sql"]})) as mocked_sql:
        sql_df = DataTable.from_sql("SELECT * FROM users", backend="sqlite")
        assert isinstance(sql_df, DataTable)
        assert list(sql_df["name"]) == ["sql"]
        mocked_sql.assert_called_once_with("SELECT * FROM users", backend="sqlite", connection_string=None)

    with patch("pandas.DataFrame.to_excel") as mocked_to_excel:
        out = frame.to_excel_table("table.xlsx", style_header=False, autofit=False, freeze_header=False)
        assert str(out).endswith("table.xlsx")
        mocked_to_excel.assert_called_once()


def test_df_csv_parquet_and_db_save_load_roundtrip(monkeypatch):
    import sqlite3

    sandbox = Path.cwd() / ".test_artifacts" / "datatable_roundtrip"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)

    frame = DataTable({"name": ["alice"], "age": [19]})

    csv_path = frame.save_to_csv("people.csv")
    assert csv_path.exists()
    csv_loaded = DataTable.load_from_csv(csv_path)
    assert csv_loaded.equals(frame)

    parquet_path = frame.save_to_parquet("people.parquet")
    assert parquet_path.exists()
    parquet_loaded = DataTable.load_from_parquet(parquet_path)
    assert parquet_loaded.equals(frame)

    db_path = sandbox / "people.sqlite3"
    frame.save_to_db("people", connection_string=f"sqlite:///{db_path.as_posix()}")

    db_loaded = DataTable.load_from_db(
        "SELECT * FROM people",
        connection_string=f"sqlite:///{db_path.as_posix()}",
    )
    assert db_loaded.equals(frame)

    with sqlite3.connect(db_path) as conn:
        rows = conn.execute("SELECT COUNT(*) FROM people").fetchone()[0]
    assert rows == 1


def test_df_db_helpers_use_default_sqlite_when_connection_is_omitted(monkeypatch):
    sandbox = Path.cwd() / ".test_artifacts" / "datatable_default_db"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)
    monkeypatch.setattr(dtpkg, "_DEFAULT_CONNECTOR", None)

    frame = DataTable({"name": ["alice"], "age": [19]})

    saved = frame.save_to_db("people")
    assert saved is frame

    default_db = sandbox / "data" / "soigia.sqlite3"
    assert default_db.exists()

    loaded = DataTable.load_from_db("select * from people")
    assert loaded.equals(frame)


def test_df_db_helpers_respect_env_default_sqlite_path(monkeypatch):
    sandbox = Path.cwd() / ".test_artifacts" / "datatable_env_default_db"
    sandbox.mkdir(parents=True, exist_ok=True)
    monkeypatch.chdir(sandbox)
    monkeypatch.setattr(dtpkg, "_DEFAULT_CONNECTOR", None)

    custom_db = sandbox / "custom" / "env.sqlite3"
    monkeypatch.setenv("SOIGIA_DB_PATH", str(custom_db))

    frame = DataTable({"name": ["alice"], "age": [19]})
    frame.save_to_db("people")

    assert custom_db.exists()
    loaded = DataTable.load_from_db("select * from people")
    assert loaded.equals(frame)


def test_df_render_table_work():
    frame = DataTable({"name": ["alice"], "age": [19]})

    assert "alice" in frame.render_table("markdown")
    assert "<table" in frame.render_table("html")
    assert "name,age" in frame.render_table("csv")
    assert frame.objects.render_table("markdown").startswith("|")


def test_df_display_and_logging_helpers_work(capsys, caplog):
    from io import StringIO

    frame = DataTable({"name": ["alice"], "age": [19]})
    buffer = StringIO()

    text = frame.to_display_text()
    printed = frame.print_table()
    with caplog.at_level(logging.INFO, logger="soigia.datatable.display"):
        logged = frame.log_table(logger="soigia.datatable.display")
        wrapped_logged = frame.objects.log_table(logger="soigia.datatable.display")

    captured = capsys.readouterr()

    assert text == printed == frame.objects.to_display_text()
    assert "alice" in text
    assert text in captured.out
    assert logged == text
    assert wrapped_logged == text
    assert text in caplog.text
    assert frame.objects.print_table(file=buffer) == text
    assert buffer.getvalue().startswith("|")


def test_df_latest_and_earliest_work():
    frame = DataTable(
        {
            "name": ["alice", "bob", "carol"],
            "age": [19, 24, 31],
            "created_at": [
                datetime(2026, 1, 1, 8, 0),
                datetime(2026, 1, 2, 8, 0),
                datetime(2026, 1, 3, 8, 0),
            ],
        }
    )

    latest_age = frame.objects.latest("age")
    earliest_age = frame.objects.earliest("age")
    latest_created = frame.objects.latest("created_at")
    earliest_created = frame.objects.earliest("created_at")

    assert latest_age["name"].iloc[0] == "carol"
    assert earliest_age["name"].iloc[0] == "alice"
    assert latest_created["name"].iloc[0] == "carol"
    assert earliest_created["name"].iloc[0] == "alice"


def test_df_versioning_work():
    frame = DataTable({"name": ["alice", "bob"], "age": [19, None], "city": ["hn", "sg"]})

    frame.init_versions("initial")
    updated = frame.fill_missing(0, columns=["age"])
    updated.mark_version("after_fill")
    trimmed = updated.exclude_fields("city")
    trimmed.mark_version("after_trim")

    history = trimmed.version_history(include_data=True)
    assert trimmed.version_count() == 3
    assert history[0]["label"] == "initial"
    assert history[1]["label"] == "after_fill"
    assert history[2]["label"] == "after_trim"
    assert trimmed.current_version()["label"] == "after_trim"
    initial_rows = trimmed.get_version(0).to_rows()
    filled_rows = trimmed.get_version("after_fill").to_rows()
    restored_rows = trimmed.restore_version("after_fill").to_rows()

    assert initial_rows[0]["age"] == 19.0
    assert pd.isna(initial_rows[1]["age"])
    assert filled_rows[1]["age"] == 0.0
    assert restored_rows[1]["age"] == 0.0

    row_diff = trimmed.diff_versions(0, 1)
    cell_diff = trimmed.diff_cells(0, 1)
    column_diff = trimmed.diff_columns(1, 2)
    summary = trimmed.diff_summary(0, 2)

    assert row_diff["row_delta"] == 0
    assert len(row_diff["removed_rows"]) == 1
    assert len(row_diff["added_rows"]) == 1
    assert len(cell_diff["changed_cells"]) == 1
    assert cell_diff["changed_cells"][0]["column"] == "age"
    assert cell_diff["changed_cells"][0]["new"] == 0
    assert column_diff["removed_columns"] == ["city"]
    assert column_diff["added_columns"] == []
    assert summary["removed_columns"] == ["city"]
    assert summary["changed_cells"] == 3
    assert summary["changed_rows"] == 2


def test_df_compare_frames_work():
    left = DataTable({"name": ["alice", "bob"], "age": [19, 24], "city": ["hn", "sg"]})
    right = DataTable({"name": ["alice", "bob", "carol"], "age": [19, 25, 31], "city": ["hn", "sg", "dn"]})

    exact_same = left.same_rows(right)
    exact_same_alias = left.same(right)
    exact_diff = left.different_rows(right)
    keyed = left.compare_frames(right, key_columns=["name"])

    assert exact_same.to_rows() == [{"name": "alice", "age": 19, "city": "hn"}]
    assert exact_same_alias.to_rows() == exact_same.to_rows()
    assert exact_diff["left_only_count"] == 1
    assert exact_diff["right_only_count"] == 2
    assert exact_diff["changed_count"] == 0
    assert keyed["same_count"] == 1
    assert keyed["left_only_count"] == 0
    assert keyed["right_only_count"] == 1
    assert keyed["changed_count"] == 1
    assert keyed["changed_rows"][0]["key"] == ("bob",)
    assert keyed["changed_rows"][0]["changed_columns"] == ["age"]
    assert left.diff_rows(right, key_columns=["name"])["changed_count"] == 1


def test_df_compare_frames_with_duplicate_keys_uses_latest_row_for_each_key():
    left = DataTable({"id": [1, 1], "value": [10, 20]})
    right = DataTable({"id": [1, 1], "value": [10, 30]})

    comparison = left.compare_frames(right, key_columns=["id"])
    same_rows = left.same(right, key_columns=["id"])
    left_only = left.left_only(right, key_columns=["id"])
    right_only = left.right_only(right, key_columns=["id"])
    differences = left.different_rows(right, key_columns=["id"])
    report = left.diff_report(right, key_columns=["id"])
    cell_diff = left.diff_changed_cells(right, key_columns=["id"])

    assert comparison["changed_count"] == 1
    assert same_rows.shape[0] == 0
    assert left_only.shape[0] == 0
    assert right_only.shape[0] == 0
    assert differences["changed_count"] == 1
    assert differences["changed_rows"][0]["key"] == (1,)
    assert report["changed_count"] == 1
    assert report["changed_cell_count"] == 1
    assert report["has_changes"] is True
    assert comparison["changed_rows"][0]["left"]["value"] == 20
    assert comparison["changed_rows"][0]["right"]["value"] == 30
    assert cell_diff.to_dict(orient="records") == [{"key": (1,), "column": "value", "left": 20, "right": 30}]


def test_df_normalize_and_reconcile_support_cross_source_alignment():
    left = DataTable(
        {
            "order_id": [1, 2],
            "customer_name": [" Alice ", "Bob"],
            "amount": ["10.00", "20.0"],
            "updated_at": ["2026-01-01 10:00:00", "2026-01-02 10:00:00"],
        }
    )
    right = DataTable(
        {
            "order_id": [1, 2],
            "cust_name": ["alice", "bob"],
            "total_amount": [10.04, 19.96],
            "updated_at": ["2026-01-01 10:00:30", "2026-01-02 09:59:45"],
        }
    )

    normalized = left.normalize(rename={"customer_name": "cust_name"}, lower_text=True, collapse_whitespace=True)
    assert normalized.columns.tolist() == ["order_id", "cust_name", "amount", "updated_at"]
    assert normalized["cust_name"].tolist() == ["alice", "bob"]

    report = left.reconcile(
        right,
        key_columns=["order_id"],
        column_map={"cust_name": "customer_name", "total_amount": "amount"},
        lower_text=True,
        numeric_tolerance=0.1,
        datetime_tolerance="45s",
    )

    assert report["same_count"] == 2
    assert report["left_only_count"] == 0
    assert report["right_only_count"] == 0
    assert report["changed_count"] == 0
    assert report["changed_cell_count"] == 0
    assert report["has_changes"] is False


def test_df_reconcile_detects_duplicate_keys():
    left = DataTable({"id": [1, 1], "value": [10, 20]})
    right = DataTable({"id": [1], "value": [10]})

    with pytest.raises(ValueError):
        left.reconcile(right, key_columns=["id"])


def test_df_reconcile_html_report_contains_summary_and_sections():
    left = DataTable({"id": [1, 2], "name": ["alice", "bob"], "amount": [10, 20]})
    right = DataTable({"id": [1, 2, 3], "name": ["alice", "bobby", "carol"], "amount": [10, 25, 30]})

    html = left.reconcile_html_report(
        right,
        key_columns=["id"],
        title="Demo Reconcile Report",
        top_n_mismatched_keys=1,
        include_raw_json=True,
    )
    alias_html = left.to_html_report(
        right,
        key_columns=["id"],
        title="Demo Reconcile Report",
        top_n_mismatched_keys=1,
        include_raw_json=True,
    )

    assert "<!doctype html>" in html.lower()
    assert alias_html == html
    assert "Demo Reconcile Report" in html
    assert "Summary" in html
    assert "Column Differences" in html
    assert "Changed Rows" in html
    assert "Top Mismatched Keys" in html
    assert "Showing top 1" in html
    assert "Only on Left" in html
    assert "Only on Right" in html
    assert "Duplicates" in html
    assert "Match status" in html
    assert 'type="application/json"' in html
    assert "Raw JSON" in html

    output = Path("tests") / "_reconcile-report.html"
    try:
        html_written = left.reconcile_html_report(
            right,
            key_columns=["id"],
            title="Demo Reconcile Report",
            top_n_mismatched_keys=1,
            include_raw_json=True,
            output_path=output,
        )
        assert isinstance(html_written, str)
        assert "Demo Reconcile Report" in html_written
        assert output.exists()
        html_text = output.read_text(encoding="utf-8")
        assert "Demo Reconcile Report" in html_text
        assert "mini-pill" in html_text
        assert "Duplicate rows" in html_text
        assert "Showing top 1" in html_text
        assert "reconcile-report-json" in html_text
    finally:
        if output.exists():
            output.unlink()


def test_df_reconcile_html_report_writes_json_sidecar_and_opens_browser(monkeypatch):
    left = DataTable({"id": [1], "name": ["alice"], "amount": [10]})
    right = DataTable({"id": [1], "name": ["alice"], "amount": [10]})

    html_path = Path("tests") / "_reconcile-report-open.html"
    json_path = Path("tests") / "_reconcile-report.json"
    opened = []

    monkeypatch.setattr(dtpkg.webbrowser, "open", lambda url: opened.append(url) or True)

    try:
        html = left.reconcile_html_report(
            right,
            key_columns=["id"],
            title="Open Demo",
            output_path=html_path,
            download_json_path=json_path,
        )
        assert "Open Demo" in html
        assert html_path.exists()
        assert json_path.exists()
        assert "Open Demo" in html_path.read_text(encoding="utf-8")
        assert '"title": "Open Demo"' in json_path.read_text(encoding="utf-8")

        opened_path = left.open_reconcile_html_report(
            html_path,
            right,
            key_columns=["id"],
            title="Open Demo",
        )
        assert opened_path == html_path
        assert opened
        assert opened[-1].startswith("file:")
    finally:
        if html_path.exists():
            html_path.unlink()
        if json_path.exists():
            json_path.unlink()


def test_df_reconcile_html_report_can_open_without_output_path(monkeypatch):
    left = DataTable({"id": [1], "value": [10]})
    right = DataTable({"id": [1], "value": [10]})
    opened = []

    monkeypatch.setattr(dtpkg.webbrowser, "open", lambda url: opened.append(url) or True)

    html = left.reconcile_html_report(
        right,
        key_columns=["id"],
        open_report_in_browser=True,
        title="Temp Open Demo",
    )

    assert "Temp Open Demo" in html
    assert opened
    assert opened[-1].startswith("file:")


def test_df_reconcile_report_path_returns_generated_file(monkeypatch):
    left = DataTable({"id": [1], "value": [10]})
    right = DataTable({"id": [1], "value": [10]})
    opened = []

    monkeypatch.setattr(dtpkg.webbrowser, "open", lambda url: opened.append(url) or True)

    output = left.reconcile_report_path(right, title="Path Demo", open_report_in_browser=True)
    try:
        assert output.exists()
        assert output.suffix == ".html"
        assert opened
        assert opened[-1].startswith("file:")
        assert "Path Demo" in output.read_text(encoding="utf-8")
    finally:
        if output.exists():
            output.unlink()


def test_df_reconcile_report_json_path_returns_generated_file():
    left = DataTable({"id": [1], "value": [10]})
    right = DataTable({"id": [1], "value": [10]})

    output = left.reconcile_report_json_path(right, title="JSON Demo")
    try:
        assert output.exists()
        assert output.suffix == ".json"
        assert '"title": "JSON Demo"' in output.read_text(encoding="utf-8")
    finally:
        if output.exists():
            output.unlink()


def test_df_open_reconcile_html_report_accepts_missing_path(monkeypatch):
    left = DataTable({"id": [1], "value": [10]})
    right = DataTable({"id": [1], "value": [10]})
    opened = []

    monkeypatch.setattr(dtpkg.webbrowser, "open", lambda url: opened.append(url) or True)

    output = left.open_reconcile_html_report(right, title="No Path Open Demo")
    try:
        assert output.exists()
        assert output.suffix == ".html"
        assert opened
        assert "No Path Open Demo" in output.read_text(encoding="utf-8")
    finally:
        if output.exists():
            output.unlink()


def test_df_reconcile_html_report_can_return_payload():
    left = DataTable({"id": [1], "value": [10]})
    right = DataTable({"id": [1], "value": [10]})

    html, payload = left.reconcile_html_report(right, key_columns=["id"], title="Payload Demo", return_payload=True)

    assert "Payload Demo" in html
    assert payload["summary"]["has_changes"] is False
    assert payload["title"] == "Payload Demo"
    assert payload["key_columns"] == ["id"]


def test_df_reconcile_bundle_returns_html_json_and_paths(monkeypatch):
    left = DataTable({"id": [1], "value": [10]})
    right = DataTable({"id": [1], "value": [10]})
    opened = []

    monkeypatch.setattr(dtpkg.webbrowser, "open", lambda url: opened.append(url) or True)

    bundle = left.reconcile_bundle(right, title="Bundle Demo", open_report_in_browser=True)
    try:
        assert bundle["html_path"].exists()
        assert bundle["json_path"].exists()
        assert "Bundle Demo" in bundle["html"]
        assert bundle["payload"]["title"] == "Bundle Demo"
        assert bundle["opened_in_browser"] is True
        assert opened
        assert opened[-1].startswith("file:")
    finally:
        if bundle["html_path"].exists():
            bundle["html_path"].unlink()
        if bundle["json_path"].exists():
            bundle["json_path"].unlink()


def test_df_reconcile_open_returns_bundle(monkeypatch):
    left = DataTable({"id": [1], "value": [10]})
    right = DataTable({"id": [1], "value": [10]})
    opened = []

    monkeypatch.setattr(dtpkg.webbrowser, "open", lambda url: opened.append(url) or True)

    bundle = left.reconcile_open(right, title="Open Bundle Demo")
    try:
        assert bundle["html_path"].exists()
        assert bundle["json_path"].exists()
        assert bundle["payload"]["title"] == "Open Bundle Demo"
        assert opened
        assert opened[-1].startswith("file:")
    finally:
        if bundle["html_path"].exists():
            bundle["html_path"].unlink()
        if bundle["json_path"].exists():
            bundle["json_path"].unlink()


def test_df_compare_aliases_work_through_queryset_wrapper():
    left = DataTable({"name": ["alice", "bob"], "score": [10, 20]})
    right = DataTable({"name": ["alice", "carol"], "score": [10, 30]})

    assert left.objects.same(right, key_columns=["name"]).to_rows() == [{"name": "alice", "score": 10}]
    assert left.objects.left_only(right, key_columns=["name"]).to_rows() == [{"name": "bob", "score": 20}]
    assert left.objects.right_only(right, key_columns=["name"]).to_rows() == [{"name": "carol", "score": 30}]


def test_df_compare_with_status_work():
    left = DataTable({"name": ["alice", "bob"], "score": [10, 20]})
    right = DataTable({"name": ["alice", "carol"], "score": [10, 30]})

    status_table = left.compare_with_status(right)
    wrapped = left.objects.compare_with_status(right)
    custom = left.compare_with_status(right, status_column="side")
    summary = left.compare_status_summary(right)
    summary_wrapped = left.objects.compare_status_summary(right)
    summary_custom = left.compare_status_summary(right, status_column="side")
    rows = left.compare_status_rows(right)
    rows_wrapped = left.objects.compare_status_rows(right)
    rows_custom = left.compare_status_rows(right, status_column="side")
    sample_rows = left.compare_sample_rows(right)
    sample_rows_wrapped = left.objects.compare_sample_rows(right)
    sample_rows_custom = left.compare_sample_rows(right, status_column="side")

    assert status_table.columns.tolist() == ["name", "score", "status"]
    assert status_table["status"].tolist() == ["same", "left_only", "right_only"]
    assert status_table.to_rows() == wrapped.to_rows()
    assert custom.columns.tolist() == ["name", "score", "side"]
    assert custom["side"].tolist() == ["same", "left_only", "right_only"]
    assert summary.columns.tolist() == ["status", "count", "rows"]
    assert summary["status"].tolist() == ["same", "left_only", "right_only"]
    assert summary["count"].tolist() == [1, 1, 1]
    assert summary.iloc[0]["rows"] == [{"name": "alice", "score": 10}]
    assert summary.iloc[1]["rows"] == [{"name": "bob", "score": 20}]
    assert summary.iloc[2]["rows"] == [{"name": "carol", "score": 30}]
    assert summary.to_rows() == summary_wrapped.to_rows()
    assert summary_custom.columns.tolist() == ["side", "count", "rows"]
    assert summary_custom["side"].tolist() == ["same", "left_only", "right_only"]
    assert rows.columns.tolist() == ["name", "score", "status"]
    assert rows["status"].tolist() == ["same", "left_only", "right_only"]
    assert rows.iloc[0].to_dict() == {"name": "alice", "score": 10, "status": "same"}
    assert rows.iloc[1].to_dict() == {"name": "bob", "score": 20, "status": "left_only"}
    assert rows.iloc[2].to_dict() == {"name": "carol", "score": 30, "status": "right_only"}
    assert rows.to_rows() == rows_wrapped.to_rows()
    assert rows_custom.columns.tolist() == ["name", "score", "side"]
    assert rows_custom["side"].tolist() == ["same", "left_only", "right_only"]
    assert sample_rows.to_rows() == rows.to_rows()
    assert sample_rows.to_rows() == sample_rows_wrapped.to_rows()
    assert sample_rows_custom.columns.tolist() == ["name", "score", "side"]
    assert sample_rows_custom["side"].tolist() == ["same", "left_only", "right_only"]


def test_df_database_classmethods_delegate():
    connector = object()

    with patch("soigia.datatable.db_connector", return_value=connector) as mocked_connector:
        returned = DataTable.db_connector("sqlite:///example.db")
        assert returned is connector
        mocked_connector.assert_called_once_with(connection_string="sqlite:///example.db")

    backend_cases = [
        ("from_mysql_db", "soigia.datatable.from_mysql_db"),
        ("from_maria_db", "soigia.datatable.from_maria_db"),
        ("from_oracle_db", "soigia.datatable.from_oracle_db"),
        ("from_postgres_db", "soigia.datatable.from_postgres_db"),
        ("from_sqlite_db", "soigia.datatable.from_sqlite_db"),
    ]
    for method_name, target in backend_cases:
        with patch(target, return_value=pd.DataFrame({"value": [1]})) as mocked_query:
            result = getattr(DataTable, method_name)("select 1")
            assert isinstance(result, DataTable)
            assert list(result["value"]) == [1]
            mocked_query.assert_called_once_with("select 1", connection_string=None)
