"""Tests for crous file I/O operations (load / dump / append)."""

from __future__ import annotations

import os
import tempfile
from pathlib import Path

import pytest

import crous
from crous import load, dump, load_values, dump_values, append
from crous.error import CrousError
from crous.value import Value, ValueType


@pytest.fixture
def tmp(tmp_path: Path) -> Path:
    """Return a temporary directory path."""
    return tmp_path


# ---------------------------------------------------------------------------
# dump / load roundtrip
# ---------------------------------------------------------------------------

class TestDumpLoad:
    def test_dict_roundtrip(self, tmp: Path) -> None:
        path = tmp / "dict.crous"
        obj = {"name": "Alice", "age": 30, "active": True}
        n = dump(obj, path)
        assert n > 8  # at least header + block
        assert load(path) == obj

    def test_list_roundtrip(self, tmp: Path) -> None:
        path = tmp / "list.crous"
        obj = [1, 2, 3, "hello", None, False]
        dump(obj, path)
        assert load(path) == obj

    def test_string_roundtrip(self, tmp: Path) -> None:
        path = tmp / "str.crous"
        dump("hello world", path)
        assert load(path) == "hello world"

    def test_int_roundtrip(self, tmp: Path) -> None:
        path = tmp / "int.crous"
        dump(42, path)
        result = load(path)
        assert result == 42

    def test_negative_int(self, tmp: Path) -> None:
        path = tmp / "neg.crous"
        dump(-100, path)
        assert load(path) == -100

    def test_float_roundtrip(self, tmp: Path) -> None:
        path = tmp / "float.crous"
        dump(3.14159, path)
        assert abs(load(path) - 3.14159) < 1e-10

    def test_bool_roundtrip(self, tmp: Path) -> None:
        path = tmp / "bool.crous"
        dump(True, path)
        assert load(path) is True

    def test_none_roundtrip(self, tmp: Path) -> None:
        path = tmp / "none.crous"
        dump(None, path)
        assert load(path) is None

    def test_bytes_roundtrip(self, tmp: Path) -> None:
        path = tmp / "bytes.crous"
        data = b"\x00\x01\x02\xff"
        dump(data, path)
        assert load(path) == data

    def test_nested_object(self, tmp: Path) -> None:
        path = tmp / "nested.crous"
        obj = {
            "users": [
                {"name": "Alice", "roles": ["admin", "user"]},
                {"name": "Bob", "roles": ["user"]},
            ],
            "count": 2,
        }
        dump(obj, path)
        assert load(path) == obj

    def test_file_created(self, tmp: Path) -> None:
        path = tmp / "new.crous"
        assert not path.exists()
        dump("test", path)
        assert path.exists()

    def test_file_overwritten(self, tmp: Path) -> None:
        path = tmp / "over.crous"
        dump("first", path)
        size1 = path.stat().st_size
        dump("second value that is longer", path)
        size2 = path.stat().st_size
        assert load(path) == "second value that is longer"
        # size may differ
        assert size2 > 0

    def test_returns_bytes_written(self, tmp: Path) -> None:
        path = tmp / "size.crous"
        n = dump({"x": 1}, path)
        assert n == path.stat().st_size

    def test_pathlib_path(self, tmp: Path) -> None:
        """dump/load accept pathlib.Path objects."""
        path = tmp / "pathlib.crous"
        dump({"ok": True}, path)
        assert load(path) == {"ok": True}

    def test_string_path(self, tmp: Path) -> None:
        """dump/load accept string paths."""
        path = str(tmp / "strpath.crous")
        dump({"ok": True}, path)
        assert load(path) == {"ok": True}

    def test_file_magic(self, tmp: Path) -> None:
        """Written file starts with CROUSv1 magic."""
        path = tmp / "magic.crous"
        dump({"x": 1}, path)
        raw = path.read_bytes()
        assert raw[:7] == b"CROUSv1"

    def test_load_nonexistent_raises(self, tmp: Path) -> None:
        with pytest.raises(FileNotFoundError):
            load(tmp / "nonexistent.crous")

    def test_single_value_load_is_scalar(self, tmp: Path) -> None:
        """A file with exactly one value is returned as-is (not wrapped in list)."""
        path = tmp / "scalar.crous"
        dump("only one", path)
        result = load(path)
        assert result == "only one"
        assert not isinstance(result, list)


# ---------------------------------------------------------------------------
# dump_values / load_values  (lossless Value roundtrip)
# ---------------------------------------------------------------------------

class TestDumpLoadValues:
    def test_basic_roundtrip(self, tmp: Path) -> None:
        path = tmp / "vals.crous"
        vals = [Value.str_("hello"), Value.uint(42), Value.bool_(False)]
        dump_values(vals, path)
        result = load_values(path)
        assert len(result) == 3
        assert result[0] == vals[0]
        assert result[1] == vals[1]
        assert result[2] == vals[2]

    def test_preserves_uint_type(self, tmp: Path) -> None:
        path = tmp / "uint.crous"
        dump_values([Value.uint(100)], path)
        result = load_values(path)
        assert result[0].type == ValueType.UINT
        assert result[0].data == 100

    def test_preserves_int_type(self, tmp: Path) -> None:
        path = tmp / "int.crous"
        dump_values([Value.int_(-1)], path)
        result = load_values(path)
        assert result[0].type == ValueType.INT
        assert result[0].data == -1

    def test_preserves_bytes_type(self, tmp: Path) -> None:
        path = tmp / "bytes.crous"
        raw = b"\xde\xad\xbe\xef"
        dump_values([Value.bytes_(raw)], path)
        result = load_values(path)
        assert result[0].type == ValueType.BYTES
        assert result[0].data == raw

    def test_multiple_values(self, tmp: Path) -> None:
        path = tmp / "multi.crous"
        vals = [Value.uint(i) for i in range(10)]
        dump_values(vals, path)
        result = load_values(path)
        assert len(result) == 10
        for i, v in enumerate(result):
            assert v.data == i

    def test_single_value_round_trips(self, tmp: Path) -> None:
        path = tmp / "one_val.crous"
        dump_values([Value.null()], path)
        result = load_values(path)
        assert len(result) == 1
        assert result[0].type == ValueType.NULL

    def test_returns_bytes_written(self, tmp: Path) -> None:
        path = tmp / "size.crous"
        n = dump_values([Value.str_("hi")], path)
        assert n == path.stat().st_size


# ---------------------------------------------------------------------------
# append
# ---------------------------------------------------------------------------

class TestAppend:
    def test_append_creates_file(self, tmp: Path) -> None:
        path = tmp / "log.crous"
        assert not path.exists()
        append({"event": "start"}, path)
        assert path.exists()
        assert load(path) == {"event": "start"}

    def test_append_single_value(self, tmp: Path) -> None:
        path = tmp / "log.crous"
        dump({"event": "start"}, path)
        append({"event": "stop"}, path)
        result = load(path)
        assert result == [{"event": "start"}, {"event": "stop"}]

    def test_append_multiple_times(self, tmp: Path) -> None:
        path = tmp / "log.crous"
        for i in range(5):
            append(i, path)
        result = load(path)
        assert result == list(range(5))

    def test_append_returns_bytes(self, tmp: Path) -> None:
        path = tmp / "log.crous"
        n = append("x", path)
        assert isinstance(n, int)
        assert n > 8

    def test_append_preserves_existing(self, tmp: Path) -> None:
        path = tmp / "log.crous"
        original = {"key": "value", "nested": [1, 2, 3]}
        dump(original, path)
        append("extra", path)
        result = load(path)
        assert result[0] == original
        assert result[1] == "extra"

    def test_append_none_value(self, tmp: Path) -> None:
        path = tmp / "log.crous"
        dump(1, path)
        append(None, path)
        result = load(path)
        assert result == [1, None]


# ---------------------------------------------------------------------------
# Module-level convenience API (crous.load / crous.dump etc.)
# ---------------------------------------------------------------------------

class TestModuleLevelAPI:
    def test_crous_dump_load(self, tmp: Path) -> None:
        path = tmp / "api.crous"
        crous.dump({"a": 1}, path)
        assert crous.load(path) == {"a": 1}

    def test_crous_dump_values_load_values(self, tmp: Path) -> None:
        path = tmp / "api_vals.crous"
        crous.dump_values([Value.uint(7)], path)
        result = crous.load_values(path)
        assert result[0].type == ValueType.UINT

    def test_crous_append(self, tmp: Path) -> None:
        path = tmp / "api_append.crous"
        crous.dump(1, path)
        crous.append(2, path)
        assert crous.load(path) == [1, 2]

    def test_all_exported(self) -> None:
        """All file I/O names are exported from the crous namespace."""
        assert callable(crous.load)
        assert callable(crous.dump)
        assert callable(crous.load_values)
        assert callable(crous.dump_values)
        assert callable(crous.append)


# ---------------------------------------------------------------------------
# Cross-function compatibility
# ---------------------------------------------------------------------------

class TestCrossCompat:
    def test_encode_then_load(self, tmp: Path) -> None:
        """bytes from crous.encode() can be saved and loaded."""
        path = tmp / "compat.crous"
        raw = crous.encode({"x": 42})
        path.write_bytes(raw)
        assert crous.load(path) == {"x": 42}

    def test_dump_then_decode(self, tmp: Path) -> None:
        """File written by dump() can be decoded with crous.decode()."""
        path = tmp / "compat2.crous"
        crous.dump({"y": 99}, path)
        raw = path.read_bytes()
        assert crous.decode(raw) == {"y": 99}


# ---------------------------------------------------------------------------
# File-object support (matches json.dump(obj, fp) / json.load(fp) API)
# ---------------------------------------------------------------------------

class TestFileObjects:
    """dump/load/append must accept open file objects, not just paths."""

    def test_dump_to_file_object(self, tmp: Path) -> None:
        path = tmp / "fobj.crous"
        obj = {"name": "Alice", "age": 30}
        with open(path, "wb") as f:
            n = crous.dump(obj, f)
        assert n > 8
        assert crous.load(path) == obj

    def test_load_from_file_object(self, tmp: Path) -> None:
        path = tmp / "fobj2.crous"
        crous.dump(42, path)
        with open(path, "rb") as f:
            assert crous.load(f) == 42

    def test_dump_values_to_file_object(self, tmp: Path) -> None:
        path = tmp / "fobj_vals.crous"
        vals = [Value.uint(10), Value.str_("hi")]
        with open(path, "wb") as f:
            crous.dump_values(vals, f)
        result = crous.load_values(path)
        assert len(result) == 2
        assert result[0].data == 10

    def test_load_values_from_file_object(self, tmp: Path) -> None:
        path = tmp / "fobj_vals2.crous"
        crous.dump_values([Value.bool_(True)], path)
        with open(path, "rb") as f:
            result = crous.load_values(f)
        assert result[0].type == ValueType.BOOL

    def test_dump_load_dict_via_file_objects(self, tmp: Path) -> None:
        path = tmp / "fobj3.crous"
        obj = {"users": [{"name": "Bob"}, {"name": "Eve"}]}
        with open(path, "wb") as f:
            crous.dump(obj, f)
        with open(path, "rb") as f:
            assert crous.load(f) == obj

    def test_dump_to_bytesio(self) -> None:
        import io
        buf = io.BytesIO()
        crous.dump({"k": "v"}, buf)
        buf.seek(0)
        assert crous.load(buf) == {"k": "v"}

    def test_dump_values_to_bytesio(self) -> None:
        import io
        buf = io.BytesIO()
        crous.dump_values([Value.null(), Value.uint(7)], buf)
        buf.seek(0)
        result = crous.load_values(buf)
        assert len(result) == 2

    def test_append_with_path(self, tmp: Path) -> None:
        """append() still works with path strings."""
        path = tmp / "app.crous"
        crous.dump(1, path)
        crous.append(2, path)
        assert crous.load(path) == [1, 2]

    def test_append_with_file_object(self, tmp: Path) -> None:
        path = tmp / "app_fobj.crous"
        crous.dump({"a": 1}, path)
        with open(path, "r+b") as f:
            crous.append({"b": 2}, f)
        assert crous.load(path) == [{"a": 1}, {"b": 2}]
