import shutil
from datetime import datetime
from pathlib import Path
from unittest.mock import patch

import pytest
import yaml

from bamboost.core.collection import Collection
from bamboost.core.simulation.base import Simulation, SimulationWriter
from bamboost.exceptions import DuplicateSimulationError
from bamboost.index import SimulationUID, get_identifier_filename
from bamboost.index.scanner import IDENTIFIER_PREFIX, IDENTIFIER_SEPARATOR


def _populate_basic_collection(collection: Collection) -> None:
    collection.add(
        name="testsim1",
        parameters={"first_name": "John", "age": 20},
        override=True,
    )
    collection.add(
        name="testsim2",
        parameters={"first_name": "Jane", "age": 30},
        override=True,
    )
    collection.add(
        name="testsim3",
        parameters={"first_name": "Jack", "age": 40},
        override=True,
    )


# ------------------- Fixtures -------------------
# also used:
# fixture 'tmp_collection' from conftest.py


# ------------------- Tests -------------------


def test_if_new_collection_created(tmp_collection: Collection):
    assert tmp_collection.path.exists()


def test_length(test_collection: Collection):
    assert len(test_collection) == 3


def test_getitem(test_collection: Collection):
    from bamboost.core.simulation import Simulation

    sim1 = test_collection["testsim1"]
    assert isinstance(sim1, Simulation)
    assert sim1.name == "testsim1"


def test_df(test_collection: Collection):
    df = test_collection.df
    # size matching?
    assert df.index.size == 3
    # parameters exist in dataframe?
    test_collection._index.upsert_simulation(test_collection.uid, "testsim1")
    assert {"first_name", "age", "list", "dict.a", "dict.b"}.issubset(set(df.columns))
    # names correct?
    assert set(df["name"].tolist()) == {"testsim1", "testsim2", "testsim3"}


@pytest.mark.parametrize(
    "name,parameters",
    [("testsim1", {"param1": 1, "param2": "value"}), (None, {"param1": 1})],
)
def test_create_simulation_basic(tmp_collection_burn: Collection, name, parameters):
    sim_writer = tmp_collection_burn.add(name=name, parameters=parameters)
    assert isinstance(sim_writer, SimulationWriter)
    assert sim_writer.parameters._dict == parameters

    # assert that folder exists
    assert sim_writer.path.exists()


def test_create_simulation_with_description(tmp_collection: Collection):
    description = "This is a test simulation."
    sim_writer = tmp_collection.add(
        name="described_sim",
        parameters={"param": 1},
        description=description,
    )
    assert sim_writer.metadata["description"] == description


def test_create_simulation_with_tags(tmp_collection: Collection):
    sim_writer = tmp_collection.add(
        name="tagged_sim",
        parameters={"param": 999},
        tags=["alpha", "beta", "alpha", ""],
    )
    assert sim_writer.metadata["tags"] == {"alpha", "beta"}
    assert sim_writer.tags == {"alpha", "beta"}

    sim_record = tmp_collection._index.simulation(
        SimulationUID(tmp_collection.uid, "tagged_sim")
    )
    assert sim_record is not None
    # the record tags (from sqlite db) is a list, so convert to set for comparison
    assert set(sim_record.tags) == {"alpha", "beta"}


def test_create_simulation_with_files(tmp_collection: Collection, tmp_path: Path):
    # Create a temp file to "copy" into simulation
    temp_file = tmp_path / "testfile.txt"
    temp_file.write_text("Test content")

    sim_writer = tmp_collection.add(
        name="sim_with_file",
        parameters={"param": 1},
        files=[str(temp_file)],
        override=True,
    )

    # Check if file was copied
    sim_folder = sim_writer.path
    assert (sim_folder / "testfile.txt").exists()
    assert (sim_folder / "testfile.txt").read_text() == "Test content"


def test_create_simulation_with_links(tmp_collection: Collection):
    other = tmp_collection.add()
    sim_writer = tmp_collection.add(
        name="sim_with_links",
        parameters={"param": 1},
        links={"linked_sim": other.uid},
        duplicate_action="ignore",
    )
    assert isinstance(sim_writer.links["linked_sim"], Simulation)
    assert sim_writer.links["linked_sim"].uid == other.uid


def test_create_simulation_overrides_existing(tmp_collection_burn: Collection):
    # Create initial simulation
    sim1 = tmp_collection_burn.add(name="override_test", parameters={"param": 1})

    # Create it again with override=True
    sim2 = tmp_collection_burn.add(
        name="override_test", parameters={"param": 2}, override=True
    )

    sim_final = tmp_collection_burn["override_test"]

    assert len(tmp_collection_burn) == 1  # Only one simulation should exist
    assert sim_final.name == "override_test"  # Same name reused
    assert sim_final.parameters == {"param": 2}


@pytest.mark.mpi_skip(reason="Test patching does not work with MPI")
def test_create_simulation_error_handling(tmp_collection: Collection):
    with patch("shutil.rmtree", wraps=shutil.rmtree) as spy_rmtree:
        with patch(
            "bamboost.core.simulation.base.SimulationWriter.initialize",
            side_effect=PermissionError("Sim init failed"),
        ):
            with pytest.raises(PermissionError, match="Sim init failed"):
                tmp_collection.add(
                    name="error_sim", parameters={"param": 1}, override=True
                )

        # Ensure cleanup was attempted
        spy_rmtree.assert_called_once()
        assert not (tmp_collection.path / "error_sim").exists()


@pytest.mark.parametrize(
    "parameters",
    [
        {"int": 1, "string": "value", "float": 3.14},
        {"list": [1, 2, 3]},
        {"dict": {"a": 1, "b": 2}},
        {"nested": {"list": [1, 2], "dict": {"key": "value"}}},
    ],
)
def test_create_simulation_duplicate_raises_exception(
    tmp_collection_burn: Collection, parameters: dict
):
    _ = tmp_collection_burn.add(name="problematic_sim", parameters=parameters)
    with pytest.raises(DuplicateSimulationError):
        _ = tmp_collection_burn.add(parameters=parameters, duplicate_action="raise")

    assert len(tmp_collection_burn) == 1  # Only one simulation should exist


def test_collection_filter_combines_conditions(tmp_collection_burn: Collection):
    _populate_basic_collection(tmp_collection_burn)

    filtered = tmp_collection_burn.filter(
        (tmp_collection_burn.k["age"] == 20) | (tmp_collection_burn.k["age"] == 40)
    )
    items = list(filtered)
    assert {sim.name for sim in items} == {"testsim1", "testsim3"}
    assert all(isinstance(sim, Simulation) for sim in items)

    chained = filtered.filter(filtered.k["first_name"] == "Jack")
    chained_items = list(chained)
    assert [sim.name for sim in chained_items] == ["testsim3"]
    assert all(isinstance(sim, Simulation) for sim in chained_items)

    assert len(tmp_collection_burn) == 3


def test_collection_sort_orders_simulations(tmp_collection_burn: Collection):
    _populate_basic_collection(tmp_collection_burn)

    sorted_coll = tmp_collection_burn.sort(
        tmp_collection_burn.k["age"], ascending=False
    )
    items = list(sorted_coll)
    assert [sim.name for sim in items] == ["testsim3", "testsim2", "testsim1"]
    assert all(isinstance(sim, Simulation) for sim in items)
    assert len(tmp_collection_burn) == 3


def test_collection_filter_then_sort_iteration(tmp_collection_burn: Collection):
    _populate_basic_collection(tmp_collection_burn)

    chained = tmp_collection_burn.filter(tmp_collection_burn.k["age"] >= 30).sort(
        "first_name", ascending=False
    )
    items = list(chained)
    assert [sim.name for sim in items] == ["testsim2", "testsim3"]
    assert all(isinstance(sim, Simulation) for sim in items)
    # iteration should yield fresh Simulation objects each time
    new_items = list(chained)
    assert [sim.name for sim in new_items] == ["testsim2", "testsim3"]
    assert len(tmp_collection_burn) == 3


def test_collection_multi_sort(tmp_collection_burn: Collection):
    tmp_collection_burn.add(
        name="simA", parameters={"priority": 1, "step": 2}, override=True
    )
    tmp_collection_burn.add(
        name="simB", parameters={"priority": 1, "step": 1}, override=True
    )
    tmp_collection_burn.add(
        name="simC", parameters={"priority": 2, "step": 1}, override=True
    )

    sorted_coll = tmp_collection_burn.sort("priority").sort("step", ascending=False)
    items = list(sorted_coll)
    assert [sim.name for sim in items] == ["simA", "simB", "simC"]
    assert all(isinstance(sim, Simulation) for sim in items)


def test_collection_metadata_save_persists_yaml(tmp_collection_burn: Collection):
    meta = tmp_collection_burn.metadata
    meta.tags = ["alpha", "beta"]
    meta.aliases = ["Primary"]
    meta.description = "demo collection"
    meta.update({"custom": {"foo": "bar"}})

    meta.save()

    metadata_file = tmp_collection_burn.path / get_identifier_filename(
        tmp_collection_burn.uid
    )
    stored = yaml.safe_load(metadata_file.read_text())

    assert stored["uid"] == tmp_collection_burn.uid
    assert stored["tags"] == ["alpha", "beta"]
    assert stored["aliases"] == ["Primary"]
    assert stored["description"] == "demo collection"
    assert stored["custom"] == {"foo": "bar"}
    assert isinstance(stored["created_at"], datetime)


def test_collection_metadata_loads_existing_yaml(tmp_collection_burn: Collection):
    metadata_file = tmp_collection_burn.path / get_identifier_filename(
        tmp_collection_burn.uid
    )
    metadata_file.write_text(
        "\n".join(
            [
                f"uid: {tmp_collection_burn.uid}",
                "created_at: 2024-01-02T03:04:05",
                "tags:",
                "  - foo",
                "  - foo",
                "  - bar",
                "  - ''",
                "aliases:",
                "  - ALPHA",
                "  - alpha",
                "custom: 7",
            ]
        )
    )

    tmp_collection_burn.__dict__.pop("metadata", None)
    meta = tmp_collection_burn.metadata

    assert meta.tags == ["foo", "bar"]
    assert meta.aliases == ["ALPHA"]
    assert meta["custom"] == 7
    assert meta.created_at == datetime(2024, 1, 2, 3, 4, 5)


def test_collection_by_path_not_in_db(tmp_path: Path):
    collection_path = tmp_path.joinpath("new_collection")
    # create a new collection manually
    collection_path.mkdir(parents=True, exist_ok=True)
    testuid = "1234567890"
    collection_path.joinpath(
        f"{IDENTIFIER_PREFIX}{IDENTIFIER_SEPARATOR}{testuid}"
    ).write_text("")

    # load collection by path
    collection = Collection(path=collection_path)
    assert collection.uid == testuid
    # assert uid is now in index
    assert collection.uid in map(lambda i: i.uid, collection._index.all_collections)
