"""File I/O operations for the Crous binary format.

Provides convenience functions for reading and writing ``.crous`` files
directly from/to disk, mirroring the ``json.load()`` / ``json.dump()`` API.

Both **path strings** and **file objects** are accepted::

    import crous

    # Path-based (like json.load / json.dump with filenames)
    crous.dump({"name": "Alice", "age": 30}, "data.crous")
    obj = crous.load("data.crous")

    # File-object-based (like json.load / json.dump)
    with open("data.crous", "wb") as f:
        crous.dump({"name": "Alice", "age": 30}, f)
    with open("data.crous", "rb") as f:
        obj = crous.load(f)

    # Work with Value objects for lossless round-trips
    values = crous.load_values("data.crous")
    crous.dump_values(values, "data_copy.crous")
"""

from __future__ import annotations

import io
import os
from pathlib import Path
from typing import BinaryIO, Union

from crous.decoder import Decoder
from crous.encoder import Encoder
from crous.value import Value

# Type alias for path-like arguments
PathLike = Union[str, bytes, os.PathLike]

# Type alias: anything we accept as a destination / source
Target = Union[str, bytes, os.PathLike, BinaryIO]


def _is_file_object(obj: object) -> bool:
    """Return True if *obj* is an open file-like object (has read/write)."""
    return isinstance(obj, (io.RawIOBase, io.BufferedIOBase, io.IOBase)) or (
        hasattr(obj, "read") or hasattr(obj, "write")
    )


def _read_bytes(source: Target) -> bytes:
    """Read binary data from a path *or* an open file object."""
    if _is_file_object(source):
        return source.read()  # type: ignore[union-attr]
    return Path(source).read_bytes()  # type: ignore[arg-type]


def _write_bytes(dest: Target, data: bytes) -> None:
    """Write binary data to a path *or* an open file object."""
    if _is_file_object(dest):
        dest.write(data)  # type: ignore[union-attr]
    else:
        Path(dest).write_bytes(data)  # type: ignore[arg-type]


def load(source: Target) -> object:
    """Read and decode a ``.crous`` file, returning native Python objects.

    Returns a single Python value if the file contains one top-level value,
    or a list of values otherwise. Returns ``None`` for an empty file.

    This is the Crous equivalent of ``json.load()``.

    Args:
        source: Path to the ``.crous`` file **or** an open binary file
                object (e.g. ``open('data.crous', 'rb')``).

    Returns:
        The decoded Python object(s).

    Raises:
        FileNotFoundError: If a path is given and the file does not exist.
        CrousError: If the data is corrupted or invalid.

    Example::

        >>> import crous
        >>> crous.dump({"name": "Alice"}, "test.crous")
        >>> crous.load("test.crous")
        {'name': 'Alice'}
        >>> with open("test.crous", "rb") as f:
        ...     crous.load(f)
        {'name': 'Alice'}
    """
    data = _read_bytes(source)
    dec = Decoder(data)
    values = dec.decode_all()
    if not values:
        return None
    if len(values) == 1:
        return values[0].to_python()
    return [v.to_python() for v in values]


def load_values(source: Target) -> list[Value]:
    """Read and decode a ``.crous`` file, returning ``Value`` objects.

    Unlike ``load()``, this preserves the Crous type information
    (e.g., distinguishing ``UInt`` from ``Int``), enabling lossless
    round-trips through encode/decode.

    Args:
        source: Path to the ``.crous`` file **or** an open binary file
                object.

    Returns:
        A list of ``Value`` objects.

    Raises:
        FileNotFoundError: If a path is given and the file does not exist.
        CrousError: If the data is corrupted or invalid.

    Example::

        >>> import crous
        >>> crous.dump(42, "num.crous")
        >>> values = crous.load_values("num.crous")
        >>> values[0].type
        <ValueType.UINT: ...>
    """
    data = _read_bytes(source)
    dec = Decoder(data)
    return dec.decode_all()


def dump(obj: object, dest: Target) -> int:
    """Encode a Python object and write it to a ``.crous`` file.

    Creates the file if it does not exist, or overwrites it if it does.

    This is the Crous equivalent of ``json.dump()``.

    Args:
        obj: The Python object to encode (dict, list, str, int, float,
             bool, None, bytes, or bytearray).
        dest: Path to the output ``.crous`` file **or** an open binary
              file object (e.g. ``open('data.crous', 'wb')``).

    Returns:
        The number of bytes written.

    Raises:
        CrousError: If the object cannot be encoded.

    Example::

        >>> import crous
        >>> n = crous.dump({"users": ["Alice", "Bob"]}, "users.crous")
        >>> n > 8  # At least header + block
        True
        >>> with open("users.crous", "wb") as f:
        ...     crous.dump({"users": ["Alice", "Bob"]}, f)
    """
    enc = Encoder()
    enc.encode_value(Value.from_python(obj))
    data = enc.finish()
    _write_bytes(dest, data)
    return len(data)


def dump_values(values: list[Value], dest: Target) -> int:
    """Encode ``Value`` objects and write them to a ``.crous`` file.

    This preserves exact Crous type information for lossless round-trips.

    Args:
        values: A list of ``Value`` objects to encode.
        dest: Path to the output ``.crous`` file **or** an open binary
              file object.

    Returns:
        The number of bytes written.

    Raises:
        CrousError: If a value cannot be encoded.

    Example::

        >>> import crous
        >>> from crous.value import Value
        >>> vals = [Value.str_("hello"), Value.uint(42)]
        >>> crous.dump_values(vals, "multi.crous")
        ...
    """
    enc = Encoder()
    for v in values:
        enc.encode_value(v)
    data = enc.finish()
    _write_bytes(dest, data)
    return len(data)


def append(obj: object, dest: Target) -> int:
    """Encode a Python object and append it to an existing ``.crous`` file.

    If a path is given and the file does not exist, a new file is created.
    If a file object is given, its current contents are read first, the
    new value is appended, and the combined result is written back.

    For high-throughput streaming appends, consider using the ``Encoder``
    class directly with incremental I/O.

    Args:
        obj: The Python object to append.
        dest: Path to the ``.crous`` file **or** an open binary file object
              opened in read+write mode (e.g. ``open('f.crous', 'r+b')``).

    Returns:
        The number of bytes in the resulting file.

    Raises:
        CrousError: If the object cannot be encoded or the file is corrupted.

    Example::

        >>> import crous
        >>> crous.dump({"event": "start"}, "log.crous")
        ...
        >>> crous.append({"event": "stop"}, "log.crous")
        ...
        >>> crous.load("log.crous")
        [{'event': 'start'}, {'event': 'stop'}]
    """
    if _is_file_object(dest):
        # File object: read existing contents, rewrite with new value
        raw = dest.read()  # type: ignore[union-attr]
        existing = Decoder(raw).decode_all() if raw else []
        existing.append(Value.from_python(obj))
        dest.seek(0)       # type: ignore[union-attr]
        dest.truncate()    # type: ignore[union-attr]
        return dump_values(existing, dest)
    else:
        p = Path(dest)     # type: ignore[arg-type]
        if p.exists():
            existing = load_values(p)
        else:
            existing = []
        existing.append(Value.from_python(obj))
        return dump_values(existing, p)
