import json

import pytest
from pydantic import ValidationError

from dataset_version import DatasetVersion, Example

EXAMPLES = (
    Example(id="q1", inputs={"question": "What is 2 + 2?"}, expected="4"),
    Example(id="q2", inputs={"question": "Capital of Peru?"}, expected="Lima"),
)
PROVENANCE = {"source": "handwritten", "author": "javier"}

# Pinned on Python 3.14.3 / Pydantic 2.13.5. If this changes, every stored
# identity recipe needs investigation before accepting the upgrade.
GOLDEN_VERSION = "sha256:aea27974cf983c5c194d30ad5d3abca618b81385375a51a0a04aea1e93b48eba"


def make() -> DatasetVersion:
    return DatasetVersion.create(
        dataset_id="qa-basic", provenance=PROVENANCE, examples=EXAMPLES
    )


def test_round_trip_is_identity():
    ds = make()
    assert DatasetVersion.model_validate_json(ds.model_dump_json()) == ds


def test_version_ignores_id_and_timestamp():
    a = make()
    b = DatasetVersion.create(
        dataset_id="renamed", provenance=PROVENANCE, examples=EXAMPLES
    )
    assert a.version == b.version


def test_list_and_tuple_inputs_are_the_same_dataset():
    from_tuple = make()
    from_list = DatasetVersion.create(
        dataset_id="qa-basic", provenance=PROVENANCE, examples=list(EXAMPLES)
    )
    assert isinstance(from_list.examples, tuple)
    assert from_list.version == from_tuple.version


def test_tampered_file_is_rejected_on_load():
    payload = json.loads(make().model_dump_json())
    payload["examples"][0]["expected"] = "5"
    with pytest.raises(ValidationError, match="version mismatch"):
        DatasetVersion.model_validate(payload)


def test_wrong_version_string_is_rejected():
    payload = json.loads(make().model_dump_json())
    payload["version"] = "sha256:" + "0" * 64
    with pytest.raises(ValidationError, match="version mismatch"):
        DatasetVersion.model_validate(payload)


def test_golden_hash_is_stable():
    assert make().version == GOLDEN_VERSION


def test_live_mutation_is_not_caught_until_reload():
    # Documents the known limitation rather than pretending it is fixed.
    ds = make()
    ds.provenance["source"] = "changed"  # frozen=True does not stop this
    with pytest.raises(ValidationError, match="version mismatch"):
        DatasetVersion.model_validate_json(ds.model_dump_json())


def test_order_and_provenance_are_part_of_identity():
    original = make()
    assert DatasetVersion.create(dataset_id="qa-basic", provenance=PROVENANCE,
                                 examples=EXAMPLES[::-1]).version != original.version
    assert DatasetVersion.create(dataset_id="qa-basic", provenance={"source": "changed"},
                                 examples=EXAMPLES).version != original.version
    assert DatasetVersion.create(dataset_id="qa-basic", provenance=dict(reversed(list(PROVENANCE.items()))),
                                 examples=EXAMPLES).version == original.version


def test_tuple_does_not_freeze_nested_inputs():
    ds = DatasetVersion.model_validate_json(make().model_dump_json())
    ds.examples[0].inputs["question"] = "changed"
    with pytest.raises(ValidationError, match="version mismatch"):
        DatasetVersion.model_validate_json(ds.model_dump_json())


def test_changing_content_and_hash_passes_internal_check():
    ds = make()
    payload = ds.model_dump(mode="json")
    payload["provenance"]["source"] = "changed"
    payload["version"] = DatasetVersion.compute_version(payload["provenance"], ds.examples)
    assert DatasetVersion.model_validate(payload).version != ds.version


def test_copy_update_bypasses_validation():
    copied = make().model_copy(update={"version": "wrong"})
    assert copied.version == "wrong"
    with pytest.raises(ValidationError, match="version mismatch"):
        DatasetVersion.model_validate_json(copied.model_dump_json())


def test_content_identity_is_not_model_equality_or_python_hash():
    ds = make()
    renamed = ds.model_copy(update={"dataset_id": "renamed"})
    assert ds.version == renamed.version
    assert ds != renamed
    with pytest.raises(TypeError, match="unhashable"):
        hash(ds)


def test_computed_field_roundtrip_and_exclusion():
    from pydantic import computed_field
    from dataset_version import FrozenModel, canonical_hash

    class Computed(FrozenModel):
        inputs: dict[str, str]

        @computed_field
        @property
        def version(self) -> str:
            return canonical_hash(self.inputs)

    ds = Computed(inputs={"question": "2+2?"})
    with pytest.raises(ValidationError, match="extra_forbidden"):
        Computed.model_validate_json(ds.model_dump_json())
    assert Computed.model_validate_json(ds.model_dump_json(exclude_computed_fields=True)) == ds
    assert Computed.model_validate_json(ds.model_dump_json(round_trip=True)) == ds
    before = ds.version
    ds.inputs["question"] = "3+3?"
    assert ds.version != before
    with pytest.raises(ValidationError, match="frozen_instance"):
        ds.inputs = {}


def test_mappingproxy_needs_serializer():
    from collections.abc import Mapping
    from types import MappingProxyType
    from pydantic import field_validator
    from pydantic_core import PydanticSerializationError
    from dataset_version import FrozenModel

    class Proxy(FrozenModel):
        provenance: Mapping[str, str]

        @field_validator("provenance")
        @classmethod
        def freeze(cls, value):
            return MappingProxyType(dict(value))

    ds = Proxy(provenance={"source": "x"})
    with pytest.raises(TypeError):
        ds.provenance["source"] = "y"
    with pytest.raises(TypeError):
        json.dumps(ds.provenance)
    with pytest.raises(PydanticSerializationError):
        ds.model_dump_json()
