"""
Testing utilities - FakeBackend for unit tests without network.

See SPECIFICATION.md for available methods and pytest fixtures.
"""

from dataclasses import replace
from datetime import datetime, timedelta
import queue
import threading
from typing import Any, Iterator, Callable

from pacsys.drf3.event import ClockEvent, PeriodicEvent

from pacsys.acnet.errors import ERR_NOPROP, ERR_OK, ERR_RETRY, FACILITY_ACNET, FACILITY_DBM
from pacsys.backends import Backend
from pacsys.backends._dispatch import CallbackDispatcher
from pacsys.drf3 import parse_request
from pacsys.drf3.field import DEFAULT_FIELD_FOR_PROPERTY
from pacsys.drf3.range import ARRAY_RANGE, BYTE_RANGE
from pacsys.types import (
    Value,
    Reading,
    WriteResult,
    BackendCapability,
    DispatchMode,
    ValueType,
    DeviceMeta,
    SubscriptionHandle,
    ReadingCallback,
    ErrorCallback,
)
from pacsys.aio._backends import AsyncBackend as _AsyncBackend
from pacsys.aio._subscription import AsyncSubscriptionHandle
from pacsys.errors import DeviceError
from pacsys.drf3.event import NeverEvent
from pacsys.drf_utils import get_device_name


def _base_key(drf: str) -> str:
    """Normalize DRF to device-state key WITHOUT event.

    Strips events and ranges so that different access patterns and range
    requests reference the same underlying device state.  Used as fallback
    when no event-specific entry exists.
    """
    try:
        req = parse_request(drf)
        out = req.device
        if req.property is not None:
            out += f".{req.property.name}"
        if req.field is not None:
            default = DEFAULT_FIELD_FOR_PROPERTY.get(req.property)
            if req.field != default:
                out += f".{req.field.name}"
        if req.extra is not None:
            out += f"<-{req.extra.name}"
        return out
    except ValueError:
        return drf


def _full_key(drf: str) -> str:
    """Normalize DRF to device-state key WITH event.

    Like _base_key but preserves event so that same-device/different-event
    requests get distinct storage slots.
    """
    try:
        req = parse_request(drf)
        out = req.device
        if req.property is not None:
            out += f".{req.property.name}"
        if req.field is not None:
            default = DEFAULT_FIELD_FOR_PROPERTY.get(req.property)
            if req.field != default:
                out += f".{req.field.name}"
        if req.event is not None and req.event.mode != "U":
            out += f"@{req.event.raw_string}"
        if req.extra is not None:
            out += f"<-{req.extra.name}"
        return out
    except ValueError:
        return drf


def _normalize_drf(drf: str) -> str:
    """Backward-compatible alias for _base_key."""
    return _base_key(drf)


def _get_range(drf: str) -> ARRAY_RANGE | BYTE_RANGE | None:
    """Extract range from a DRF string."""
    try:
        return parse_request(drf).range
    except ValueError:
        return None


def _apply_range(drf: str, value: Any, rng: ARRAY_RANGE | BYTE_RANGE | None) -> Any:
    """Slice value according to a DRF range. Returns value unchanged if no range."""
    if rng is None:
        return value

    try:
        if isinstance(rng, ARRAY_RANGE):
            if rng.mode == "full":
                return value[:]
            if rng.mode == "single":
                return value[rng.low]
            low = rng.low if rng.low is not None else 0
            high = rng.high
            if high is not None:
                return value[low : high + 1]  # ACNET ranges are inclusive
            return value[low:]

        if isinstance(rng, BYTE_RANGE):
            if rng.mode == "full":
                return value[:]
            if rng.mode == "single":
                assert rng.offset is not None
                return value[rng.offset : rng.offset + 1]
            offset = rng.offset if rng.offset is not None else 0
            length = rng.length
            if length is not None:
                return value[offset : offset + length]
            return value[offset:]
    except (TypeError, IndexError) as e:
        raise DeviceError(drf, FACILITY_ACNET, ERR_RETRY, f"Cannot apply range to stored value: {e}")

    return value


def _event_offset(drf: str) -> float:
    """Derive a deterministic numeric offset from the event in a DRF.

    The offset is predictable from the event type:
      @I          →  0     (immediate = exact)
      @p,x / @q,x → +x/1000  (periodic rate as fraction)
      @e,xx[,t,d] → -(evt + delay/1000) / 100  (clock event + delay)
      @s,...      → +2.0 + hash/1000  (state event, avoids periodic range)
      no event    →  0        (base key = exact match)
    """
    try:
        req = parse_request(drf)
    except ValueError:
        return 0.0
    evt = req.event
    if evt is None or evt.mode in ("U", "I", "N"):
        return 0.0
    if isinstance(evt, PeriodicEvent):
        return evt.freq / 1000.0
    if isinstance(evt, ClockEvent):
        return -(evt.evt + evt.delay / 1000.0) / 100.0
    if evt.mode == "S":
        # Use hash of raw_string to differentiate state events; +2.0 base
        # avoids collision with periodic offsets (which are freq/1000)
        h = sum(evt.raw_string.encode()) % 1000
        return 2.0 + h / 1000.0
    return 0.0


def _perturb_value(value: Any, drf: str) -> Any:
    """Add a deterministic event-derived offset to a numeric value.

    Only perturbs float scalars and float ndarrays; all other types
    (int, bytes, str, dict, list, None, integer arrays) pass through unchanged.
    """
    offset = _event_offset(drf)
    if offset == 0.0:
        return value

    if isinstance(value, float):
        return value + offset

    try:
        import numpy as np

        if isinstance(value, np.ndarray) and np.issubdtype(value.dtype, np.floating):
            return value + offset
    except ImportError:
        pass

    return value


def _perturb_timestamp(ts: datetime | None, drf: str) -> datetime | None:
    """Add a deterministic event-derived offset to a timestamp.

    Uses the same offset magnitude as value perturbation, interpreted as
    milliseconds.  Returns None unchanged.
    """
    if ts is None:
        return None
    offset = _event_offset(drf)
    if offset == 0.0:
        return ts
    return ts + timedelta(milliseconds=abs(offset))


def _write_range(existing: Any, value: Any, rng: ARRAY_RANGE | BYTE_RANGE) -> Any:
    """Slice-assign value into existing array/bytes at the given range.

    Returns a new object with the slice replaced (copies numpy arrays
    to keep stored Readings effectively immutable).
    """
    import numpy as np

    if isinstance(existing, np.ndarray):
        out = existing.copy()
    elif isinstance(existing, (list, bytearray)):
        out = existing.copy()
    elif isinstance(existing, bytes):
        out = bytearray(existing)
    else:
        raise TypeError(f"Cannot slice-assign into {type(existing).__name__}")

    if isinstance(rng, ARRAY_RANGE):
        if rng.mode == "full":
            out[:] = value
        elif rng.mode == "single":
            assert rng.low is not None
            out[rng.low] = value
        else:
            low = rng.low if rng.low is not None else 0
            high = rng.high
            if high is not None:
                out[low : high + 1] = value
            else:
                out[low:] = value
    elif isinstance(rng, BYTE_RANGE):
        if rng.mode == "full":
            out[:] = value
        elif rng.mode == "single":
            assert rng.offset is not None
            out[rng.offset : rng.offset + 1] = value
        else:
            offset = rng.offset if rng.offset is not None else 0
            length = rng.length
            if length is not None:
                out[offset : offset + length] = value
            else:
                out[offset:] = value

    if isinstance(existing, bytes):
        return bytes(out)
    return out


class FakeSubscriptionHandle(SubscriptionHandle):
    """Subscription handle for FakeBackend streaming tests.

    Supports both callback mode (readings pushed to callback) and
    iterator mode (readings yielded from readings() method).

    Example (iterator mode):
        fake = FakeBackend()
        with fake.subscribe(["M:OUTTMP@p,1000"]) as sub:
            fake.emit_reading("M:OUTTMP@p,1000", 72.5)
            for reading, handle in sub.readings(timeout=1.0):
                assert reading.value == 72.5
                break

    Example (callback mode):
        readings = []
        def on_reading(reading, handle):
            readings.append(reading)

        fake = FakeBackend()
        sub = fake.subscribe(["M:OUTTMP@p,1000"], callback=on_reading)
        fake.emit_reading("M:OUTTMP@p,1000", 72.5)
        assert len(readings) == 1
        sub.stop()
    """

    def __init__(
        self,
        drfs: list[str],
        callback: ReadingCallback | None,
        on_error: ErrorCallback | None,
        remover: Callable[["FakeSubscriptionHandle"], None],
        dispatcher: CallbackDispatcher | None = None,
    ):
        self._drfs = set(drfs)
        self._callback = callback
        self._on_error = on_error
        self._remover = remover
        self._dispatcher = dispatcher
        self._queue: queue.Queue[Reading | None] = queue.Queue()
        self._stopped = False
        self._exc: Exception | None = None
        self._ref_ids = list(range(len(drfs)))

    @property
    def ref_ids(self) -> list[int]:
        """Reference IDs for devices in this subscription."""
        return self._ref_ids

    @property
    def stopped(self) -> bool:
        """True if this subscription has been stopped."""
        return self._stopped

    @property
    def exc(self) -> Exception | None:
        """Exception if an error occurred, else None."""
        return self._exc

    def readings(
        self,
        timeout: float | None = None,
    ) -> Iterator[tuple[Reading, SubscriptionHandle]]:
        """Yield (reading, handle) pairs for this subscription.

        Args:
            timeout: Seconds to wait for next reading. None = block forever.
        """
        if self._exc is not None:
            raise self._exc

        while not self._stopped:
            try:
                reading = self._queue.get(block=True, timeout=timeout)
                if reading is None:
                    # Poison pill - stop signal
                    break
                yield (reading, self)
            except queue.Empty:
                # Timeout reached
                break

    def stop(self) -> None:
        """Stop this subscription."""
        if not self._stopped:
            self._stopped = True
            self._queue.put(None)  # Poison pill to unblock readings()
            self._remover(self)

    def _put_reading(self, reading: Reading) -> None:
        """Internal: deliver reading to callback or queue."""
        if self._stopped:
            return

        if self._callback:
            if self._dispatcher is not None:
                self._dispatcher.dispatch_reading(self._callback, reading, self)
            else:
                self._callback(reading, self)
        else:
            self._queue.put(reading)

    def _set_error(self, exc: Exception) -> None:
        """Internal: set error, mark stopped, remove from backend, and notify."""
        self._exc = exc
        self._stopped = True
        self._remover(self)
        if self._on_error:
            if self._dispatcher is not None:
                self._dispatcher.dispatch_error(self._on_error, exc, self)
            else:
                self._on_error(exc, self)
        else:
            self._queue.put(None)  # Unblock iterator


class FakeBackend(Backend):
    """Fake backend that simulates device state without network access.

    Models devices as shared state: writes update the stored value,
    subsequent reads return it.  Keys are device identity (name + property +
    field + event) -- ranges are stripped and applied at read time.

    Event handling: when values are set with an explicit event, they get
    a distinct storage slot.  An eventless set acts as a fallback for any
    event not explicitly configured.

    Example:
        fake = FakeBackend()
        fake.set_reading("M:OUTTMP", 72.5)
        fake.set_error("M:BADDEV", -42, "Device not found")

        # Use it like any other backend
        assert fake.read("M:OUTTMP") == 72.5
        reading = fake.get("M:BADDEV")
        assert reading.is_error

        # Writes update readable state
        fake.write("M:OUTTMP.SETTING@N", 80.0)
        assert fake.read("M:OUTTMP.SETTING@I") == 80.0

        # Inspect what happened
        assert fake.was_read("M:OUTTMP")
        assert fake.reads == ["M:OUTTMP", "M:BADDEV", "M:OUTTMP.SETTING@I"]
    """

    def __init__(self, dispatch_mode: DispatchMode = DispatchMode.DIRECT):
        """Create a fake backend with no configured readings."""
        self._readings: dict[str, Reading] = {}
        self._errors: dict[str, tuple[int, str]] = {}
        self._write_results: dict[str, WriteResult] = {}
        self._read_history: list[str] = []
        self._write_history: list[tuple[str, Value]] = []
        self._subscriptions: list[FakeSubscriptionHandle] = []
        self._lock = threading.Lock()
        self._closed = False
        self._dispatch_mode = dispatch_mode
        self._dispatcher = CallbackDispatcher(dispatch_mode)

    # ─────────────────────────────────────────────────────────────────────
    # Configuration Methods (call these in test setup)
    # ─────────────────────────────────────────────────────────────────────

    @staticmethod
    def _validate_value_type(value: Any, value_type: ValueType | None) -> None:
        """Validate that value matches the declared ValueType.

        Raises TypeError on mismatch so tests fail loudly at setup time
        rather than silently accepting nonsense.
        """
        if value_type is None:
            return
        import numpy as np

        checks: dict[ValueType, tuple[type | tuple[type, ...], str]] = {
            ValueType.RAW: ((bytes, bytearray), "bytes/bytearray"),
            ValueType.TEXT: ((str,), "str"),
            ValueType.TEXT_ARRAY: ((list,), "list of str"),
            ValueType.SCALAR: ((int, float, np.integer, np.floating, bool), "numeric (int/float)"),
            ValueType.SCALAR_ARRAY: ((list, tuple, np.ndarray), "array-like (list/tuple/ndarray)"),
            ValueType.TIMED_SCALAR_ARRAY: ((list, tuple, np.ndarray), "array-like (list/tuple/ndarray)"),
            ValueType.ANALOG_ALARM: ((dict,), "dict"),
            ValueType.DIGITAL_ALARM: ((dict,), "dict"),
            ValueType.BASIC_STATUS: ((dict,), "dict"),
        }
        if value_type not in checks:
            return
        allowed_types, label = checks[value_type]
        if not isinstance(value, allowed_types):
            raise TypeError(
                f"FakeBackend: value {type(value).__name__} incompatible with "
                f"ValueType.{value_type.name} (expected {label})"
            )

    def set_reading(
        self,
        drf: str,
        value: Value,
        value_type: ValueType = ValueType.SCALAR,
        units: str | None = None,
        description: str | None = None,
        timestamp: datetime | None = None,
        cycle: int | None = None,
    ) -> None:
        """Pre-configure a successful reading for a device.

        Args:
            drf: Device request string
            value: The value to return when this device is read
            value_type: Type of the value (default: SCALAR)
            units: Optional units string
            description: Optional device description
            timestamp: Optional timestamp for the reading
            cycle: Cycle number (None if not available)

        Raises:
            TypeError: If value type doesn't match value_type declaration
        """
        self._validate_value_type(value, value_type)
        device_name = get_device_name(drf)

        meta = DeviceMeta(
            device_index=0,
            name=device_name,
            description=description or f"Test device {device_name}",
            units=units,
        )

        full = _full_key(drf)
        base = _base_key(drf)
        reading = Reading(
            drf=drf,
            value_type=value_type,
            value=value,
            error_code=ERR_OK,
            timestamp=timestamp or datetime.now(),
            cycle=cycle,
            meta=meta,
        )
        self._readings[full] = reading
        self._readings[base] = reading

        # Remove any error for this DRF
        self._errors.pop(full, None)
        self._errors.pop(base, None)

    def set_error(self, drf: str, error_code: int, message: str) -> None:
        """Pre-configure an error for a device.

        Args:
            drf: Device request string
            error_code: Negative error code
            message: Error message
        """
        full = _full_key(drf)
        base = _base_key(drf)
        self._errors[full] = (error_code, message)
        self._errors[base] = (error_code, message)
        # Remove any reading for this DRF
        self._readings.pop(full, None)
        self._readings.pop(base, None)

    def set_write_result(
        self,
        drf: str,
        success: bool = True,
        error_code: int | None = None,
        message: str | None = None,
    ) -> None:
        """Pre-configure the result of a write operation.

        Args:
            drf: Device request string
            success: If True, use error_code=ERR_OK (unless overridden)
            error_code: Status code to return (default: ERR_OK if success, ERR_RETRY if not)
            message: Optional message
        """
        if error_code is None:
            error_code = ERR_OK if success else ERR_RETRY

        full = _full_key(drf)
        base = _base_key(drf)
        wr = WriteResult(
            drf=drf,
            error_code=error_code,
            message=message,
        )
        self._write_results[full] = wr
        self._write_results[base] = wr

    def set_analog_alarm(self, drf: str, alarm_dict: dict) -> None:
        """Pre-configure an analog alarm structured reading.

        Args:
            drf: Device request string (e.g., "Z:TEST.ANALOG")
            alarm_dict: Dictionary with alarm fields:
                minimum, maximum, alarm_enable, alarm_status,
                abort, abort_inhibit, tries_needed, tries_now
        """
        self.set_reading(drf, alarm_dict, value_type=ValueType.ANALOG_ALARM)

    def set_digital_alarm(self, drf: str, alarm_dict: dict) -> None:
        """Pre-configure a digital alarm structured reading.

        Args:
            drf: Device request string (e.g., "Z:TEST.DIGITAL")
            alarm_dict: Dictionary with alarm fields:
                nominal, mask, alarm_enable, alarm_status,
                abort, abort_inhibit, tries_needed, tries_now
        """
        self.set_reading(drf, alarm_dict, value_type=ValueType.DIGITAL_ALARM)

    # ─────────────────────────────────────────────────────────────────────
    # Inspection Methods (call these in test assertions)
    # ─────────────────────────────────────────────────────────────────────

    @property
    def reads(self) -> list[str]:
        """Get list of DRF strings that were read (in order)."""
        return self._read_history.copy()

    @property
    def writes(self) -> list[tuple[str, Value]]:
        """Get list of (drf, value) tuples that were written (in order)."""
        return self._write_history.copy()

    def was_read(self, drf: str) -> bool:
        """Check if a specific device was read (normalizes DRF for comparison)."""
        key = _normalize_drf(drf)
        return any(_normalize_drf(d) == key for d in self._read_history)

    def was_written(self, drf: str) -> bool:
        """Check if a specific device was written (normalizes DRF for comparison)."""
        key = _normalize_drf(drf)
        return any(_normalize_drf(d) == key for d, _ in self._write_history)

    def get_written_value(self, drf: str) -> Value | None:
        """Get the value that was written to a device (last write, normalizes DRF)."""
        key = _normalize_drf(drf)
        for d, v in reversed(self._write_history):
            if _normalize_drf(d) == key:
                return v
        return None

    def reset(self) -> None:
        """Clear all configured readings, errors, recorded operations, and subscriptions."""
        self._readings.clear()
        self._errors.clear()
        self._write_results.clear()
        self._read_history.clear()
        self._write_history.clear()
        self.stop_streaming()

    def _find_reading(self, drf: str) -> tuple[Reading | None, bool]:
        """Lookup reading: event-specific key first, then base key.

        Returns (reading, is_fallback) where is_fallback is True when the
        reading came from the base key (event-unaware), meaning perturbation
        should be applied.  False for exact match or miss (None).
        """
        full = _full_key(drf)
        if full in self._readings:
            return self._readings[full], False
        base = _base_key(drf)
        reading = self._readings.get(base)
        return reading, reading is not None

    def _find_error(self, drf: str) -> tuple[int, str] | None:
        """Lookup error: event-specific key first, then base key."""
        full = _full_key(drf)
        if full in self._errors:
            return self._errors[full]
        base = _base_key(drf)
        return self._errors.get(base)

    def _device_known(self, drf: str) -> bool:
        """Check if any reading or error is configured for this device name."""
        name = get_device_name(drf)
        return any(k.split(".")[0] == name for k in self._readings) or any(
            k.split(".")[0] == name for k in self._errors
        )

    # ─────────────────────────────────────────────────────────────────────
    # Backend Interface Implementation
    # ─────────────────────────────────────────────────────────────────────

    @property
    def capabilities(self) -> BackendCapability:
        """FakeBackend supports everything."""
        return BackendCapability.READ | BackendCapability.WRITE | BackendCapability.STREAM | BackendCapability.BATCH

    @property
    def authenticated(self) -> bool:
        """FakeBackend is always 'authenticated'."""
        return True

    @property
    def principal(self) -> str | None:
        """Return a fake principal."""
        return "test-user@FNAL.GOV"

    def read(self, drf: str, timeout: float | None = None) -> Value:
        """Read a pre-configured value or raise error.

        Args:
            drf: Device request string
            timeout: Ignored (for interface compatibility)

        Returns:
            The pre-configured value (sliced if DRF includes a range)

        Raises:
            DeviceError: If an error was configured or no reading exists
        """
        self._read_history.append(drf)

        # @N means "never send data" - reading with it is semantically invalid
        try:
            req = parse_request(drf)
            if isinstance(req.event, NeverEvent):
                raise DeviceError(drf, FACILITY_ACNET, ERR_RETRY, "Cannot read with @N (never) event")
        except ValueError:
            pass

        error = self._find_error(drf)
        reading, is_fallback = self._find_reading(drf)

        # Check for configured error
        if error is not None:
            error_code, message = error
            raise DeviceError(drf, FACILITY_ACNET, error_code, message)

        # Check for configured reading
        if reading is not None:
            if reading.is_error:
                raise DeviceError(
                    drf,
                    reading.facility_code,
                    reading.error_code,
                    reading.message,
                )
            value = reading.value
            if is_fallback:
                value = _perturb_value(value, drf)
            return _apply_range(drf, value, _get_range(drf))

        # No reading configured -- distinguish property-not-found from unknown device
        if self._device_known(drf):
            raise DeviceError(drf, FACILITY_DBM, ERR_NOPROP, f"No such property for {get_device_name(drf)}")
        raise DeviceError(drf, FACILITY_ACNET, ERR_RETRY, f"No reading configured for {drf}")

    def get(self, drf: str, timeout: float | None = None) -> Reading:
        """Get a pre-configured reading.

        Args:
            drf: Device request string
            timeout: Ignored (for interface compatibility)

        Returns:
            Reading object (may have is_error=True)
        """
        self._read_history.append(drf)

        # @N means "never send data" - reading with it is semantically invalid
        try:
            req = parse_request(drf)
            if isinstance(req.event, NeverEvent):
                return Reading(
                    drf=drf,
                    error_code=ERR_RETRY,
                    message="Cannot read with @N (never) event",
                )
        except ValueError:
            pass

        error = self._find_error(drf)
        reading, is_fallback = self._find_reading(drf)

        # Check for configured error
        if error is not None:
            error_code, message = error
            return Reading(
                drf=drf,
                error_code=error_code,
                message=message,
            )

        # Check for configured reading - return with caller's DRF
        if reading is not None:
            value = reading.value
            timestamp = reading.timestamp
            if is_fallback:
                value = _perturb_value(value, drf)
                timestamp = _perturb_timestamp(timestamp, drf)
            rng = _get_range(drf)
            if rng is not None and value is not None:
                value = _apply_range(drf, value, rng)
            return replace(reading, drf=drf, value=value, timestamp=timestamp)

        # No reading configured -- distinguish property-not-found from unknown device
        if self._device_known(drf):
            return Reading(
                drf=drf,
                facility_code=FACILITY_DBM,
                error_code=ERR_NOPROP,
                message=f"No such property for {get_device_name(drf)}",
            )
        return Reading(
            drf=drf,
            facility_code=FACILITY_ACNET,
            error_code=ERR_RETRY,
            message=f"No reading configured for {drf}",
        )

    def get_many(self, drfs: list[str], timeout: float | None = None) -> list[Reading]:
        """Get multiple pre-configured readings.

        Args:
            drfs: List of device request strings
            timeout: Ignored (for interface compatibility)

        Returns:
            List of Reading objects in same order as input
        """
        return [self.get(drf, timeout) for drf in drfs]

    def write(
        self,
        drf: str,
        value: Value,
        timeout: float | None = None,
    ) -> WriteResult:
        """Write a value and update device state.

        Successful writes update the stored reading so that subsequent
        reads of the same device+property return the written value.

        Args:
            drf: Device to write
            value: Value to write
            timeout: Ignored (for interface compatibility)

        Returns:
            Pre-configured WriteResult, or success by default
        """
        self._write_history.append((drf, value))
        full = _full_key(drf)
        base = _base_key(drf)

        # Check for configured write result (full key first, then base)
        result = self._write_results.get(full) or self._write_results.get(base)
        if result is not None:
            if result.success:
                self._update_state(base, drf, value)
            return result

        # Default: write succeeds and updates state
        self._update_state(base, drf, value)
        return WriteResult(drf=drf, error_code=ERR_OK)

    def _update_state(self, key: str, drf: str, value: Value) -> None:
        """Update device state after a successful write.

        If the write DRF includes a range and an existing array is stored,
        performs a slice assignment instead of replacing the whole value.

        Updates both the base key and the event-specific full key so that
        subsequent reads (which check the full key first) see the write.
        """
        full = _full_key(drf)

        # Clear errors for both keys -- device is now responsive
        self._errors.pop(key, None)
        if full != key:
            self._errors.pop(full, None)

        rng = _get_range(drf)
        merged = value

        # Ranged write: slice-assign into existing stored value
        if rng is not None and key in self._readings:
            existing = self._readings[key].value
            merged = _write_range(existing, value, rng)

        if key in self._readings:
            old = self._readings[key]
            updated = replace(old, value=merged, error_code=ERR_OK, timestamp=datetime.now())
        else:
            device_name = get_device_name(drf)
            updated = Reading(
                drf=drf,
                value_type=ValueType.SCALAR,
                value=value,
                error_code=ERR_OK,
                timestamp=datetime.now(),
                meta=DeviceMeta(
                    device_index=0,
                    name=device_name,
                    description=f"Test device {device_name}",
                ),
            )

        self._readings[key] = updated
        if full != key:
            self._readings[full] = updated

    def write_many(
        self,
        settings: list[tuple[str, Value]],
        timeout: float | None = None,
    ) -> list[WriteResult]:
        """Record multiple write operations.

        Args:
            settings: List of (drf, value) tuples
            timeout: Ignored (for interface compatibility)

        Returns:
            List of WriteResult objects
        """
        return [self.write(drf, value, timeout=timeout) for drf, value in settings]

    # ─────────────────────────────────────────────────────────────────────
    # Streaming Methods
    # ─────────────────────────────────────────────────────────────────────

    def subscribe(
        self,
        drfs: list[str],
        callback: ReadingCallback | None = None,
        on_error: ErrorCallback | None = None,
    ) -> FakeSubscriptionHandle:
        """Subscribe to devices for streaming.

        Args:
            drfs: List of device request strings
            callback: Optional callback for push-mode (called on each reading)
            on_error: Optional error handler (called on connection errors)

        Returns:
            FakeSubscriptionHandle for managing subscription
        """
        handle = FakeSubscriptionHandle(drfs, callback, on_error, self._remove_subscription, self._dispatcher)
        with self._lock:
            self._subscriptions.append(handle)
        return handle

    def emit_reading(
        self,
        drf: str,
        value: Value,
        value_type: ValueType = ValueType.SCALAR,
        units: str | None = None,
        description: str | None = None,
        timestamp: datetime | None = None,
        cycle: int | None = None,
    ) -> None:
        """Emit a reading to all matching subscriptions.

        Use this in tests to simulate incoming data from the server.

        Args:
            drf: Device request string (must match subscription DRF)
            value: The value to emit
            value_type: Type of the value (default: SCALAR)
            units: Optional units string
            description: Optional device description
            timestamp: Optional timestamp (default: now)
            cycle: Cycle number (None if not available)
        """
        self._validate_value_type(value, value_type)
        device_name = get_device_name(drf)
        meta = DeviceMeta(
            device_index=0,
            name=device_name,
            description=description or f"Test device {device_name}",
            units=units,
        )
        reading = Reading(
            drf=drf,
            value_type=value_type,
            value=value,
            error_code=ERR_OK,
            timestamp=timestamp or datetime.now(),
            cycle=cycle,
            meta=meta,
        )

        # Deliver to matching subscriptions (copy list to avoid holding lock during callbacks)
        with self._lock:
            matching = [sub for sub in self._subscriptions if drf in sub._drfs]
        for sub in matching:
            sub._put_reading(reading)

    def emit_error(self, exception: Exception) -> None:
        """Emit an error to all active subscriptions.

        Use this in tests to simulate connection errors.

        Args:
            exception: The exception to deliver
        """
        with self._lock:
            subs = list(self._subscriptions)
        for sub in subs:
            sub._set_error(exception)

    def remove(self, handle: SubscriptionHandle) -> None:
        """Remove a subscription.

        Args:
            handle: The subscription handle to remove
        """
        handle.stop()

    def stop_streaming(self) -> None:
        """Stop all active subscriptions."""
        with self._lock:
            subs = list(self._subscriptions)
        for sub in subs:
            sub.stop()

    def _remove_subscription(self, handle: FakeSubscriptionHandle) -> None:
        """Internal: remove subscription from tracking."""
        with self._lock:
            if handle in self._subscriptions:
                self._subscriptions.remove(handle)

    # ─────────────────────────────────────────────────────────────────────
    # Lifecycle
    # ─────────────────────────────────────────────────────────────────────

    def close(self) -> None:
        """Close backend and stop all subscriptions."""
        self.stop_streaming()
        self._dispatcher.close()
        self._closed = True


# ─────────────────────────────────────────────────────────────────────────────
# Async fake backend
# ─────────────────────────────────────────────────────────────────────────────


class AsyncFakeBackend(_AsyncBackend):
    """Async fake backend for testing. Wraps FakeBackend for state management."""

    def __init__(self):
        self._sync = FakeBackend(dispatch_mode=DispatchMode.DIRECT)
        self._closed = False
        self._handles: list[AsyncSubscriptionHandle] = []
        self._sync_handles: list[FakeSubscriptionHandle] = []

    # -- Configuration (delegated to sync backend) ----------------------------

    def set_reading(self, drf, value, **kwargs):
        self._sync.set_reading(drf, value, **kwargs)

    def set_error(self, drf, error_code, message):
        self._sync.set_error(drf, error_code, message)

    def reset(self):
        self._sync.reset()
        self._handles.clear()
        self._sync_handles.clear()
        self._closed = False

    # -- Inspection -----------------------------------------------------------

    def was_read(self, drf):
        return self._sync.was_read(drf)

    def was_written(self, drf):
        return self._sync.was_written(drf)

    @property
    def reads(self):
        return self._sync.reads

    @property
    def writes(self):
        return self._sync.writes

    # -- AsyncBackend interface -----------------------------------------------

    @property
    def capabilities(self) -> BackendCapability:
        return self._sync.capabilities

    async def read(self, drf, timeout=None):
        self._check_closed()
        return self._sync.read(drf, timeout=timeout)

    async def get(self, drf, timeout=None):
        self._check_closed()
        return self._sync.get(drf, timeout=timeout)

    async def get_many(self, drfs, timeout=None):
        self._check_closed()
        return self._sync.get_many(drfs, timeout=timeout)

    async def write(self, drf, value, timeout=None):
        self._check_closed()
        return self._sync.write(drf, value, timeout=timeout)

    async def write_many(self, settings, timeout=None):
        self._check_closed()
        return self._sync.write_many(settings, timeout=timeout)

    async def subscribe(self, drfs, callback=None, on_error=None):
        self._check_closed()
        handle = AsyncSubscriptionHandle()
        self._handles.append(handle)

        def _on_reading(reading, _sync_handle):
            handle._dispatch(reading)

        sync_handle = self._sync.subscribe(drfs, callback=_on_reading)
        self._sync_handles.append(sync_handle)
        return handle

    def emit_reading(self, drf, value, **kwargs):
        self._sync.emit_reading(drf, value, **kwargs)

    async def close(self):
        if self._closed:
            return
        self._closed = True
        for h in self._handles:
            await h.stop()
        self._handles.clear()
        for sh in self._sync_handles:
            sh.stop()
        self._sync_handles.clear()
        self._sync.close()

    def _check_closed(self):
        if self._closed:
            raise RuntimeError("Backend is closed")


# ─────────────────────────────────────────────────────────────────────────────
# Optional pytest integration
# ─────────────────────────────────────────────────────────────────────────────

try:
    import pytest

    @pytest.fixture
    def fake_backend():
        """Pytest fixture providing a FakeBackend instance.

        Usage:
            def test_something(fake_backend):
                fake_backend.set_reading("M:OUTTMP", 72.5)
                # ... test code using fake_backend ...
        """
        return FakeBackend()

    @pytest.fixture
    def mock_pacsys(fake_backend, monkeypatch):
        """Pytest fixture that patches the global pacsys backend.

        This allows testing code that uses pacsys.read() etc. without
        manually patching.

        Usage:
            def test_something(mock_pacsys):
                mock_pacsys.set_reading("M:OUTTMP", 72.5)
                import pacsys
                assert pacsys.read("M:OUTTMP") == 72.5
        """
        import pacsys

        monkeypatch.setattr(pacsys, "_global_backend", fake_backend)
        monkeypatch.setattr(pacsys, "_backend_initialized", True)
        yield fake_backend

except ImportError:
    # pytest not installed, fixtures not available
    pass


__all__ = ["FakeBackend", "FakeSubscriptionHandle", "AsyncFakeBackend"]
