"""Integration tests for the Echo daemon.

These tests run a real IOC (iocsh) and the echo daemon and verify end-to-end
behaviour: scalars, arrays, images, heartbeat, recovery, suicide/restart.

IOC used: ``examples/echo_daemon/st.cmd``.
Requires conda env with epics-base and run-iocsh (e.g. conda activate epics-bridge).

  pytest tests/test_echo_daemon.py -v
  pytest -m integration -v
"""

import numpy as np
import pytest

from epics_bridge.daemon_status import TaskStatus
from epics_bridge.logging import LogLevel
from tests._support import TestBase, wait_until

pytestmark = pytest.mark.integration


# ---------------------------------------------------------------------------
# Log helpers
# ---------------------------------------------------------------------------


def _non_empty_log_entries(snapshot) -> list[str]:
    """Normalize waveform snapshot values by removing empty slots."""
    return [str(entry) for entry in list(snapshot) if entry]


def _count_log_entries(bridge, suffix: str, prefix: str) -> int:
    entries = _non_empty_log_entries(bridge.uth.pvget(f"{bridge.prefix}{suffix}"))
    return sum(1 for entry in entries if entry.startswith(prefix))


def _wait_until_busy_idle(bridge) -> None:
    wait_until(
        lambda: bridge.uth.pvget(f"{bridge.prefix}Busy") == 0,
        timeout=20,
        interval=0.2,
        message="Busy did not return to 0 before next log trigger",
    )


def _wait_until_emit_logs_consumed(bridge) -> None:
    wait_until(
        lambda: bridge.uth.pvget(f"{bridge.echo_prefix}EmitLogs") == 0,
        timeout=40,
        interval=0.2,
        message="EmitLogs flag was not consumed by daemon cycle",
    )


def _emit_logs_once(bridge) -> None:
    _wait_until_busy_idle(bridge)
    bridge.uth.pvput(f"{bridge.echo_prefix}EmitLogs", 1)
    bridge.uth.pvput(f"{bridge.prefix}Trigger", 1)
    _wait_until_emit_logs_consumed(bridge)


def _emit_logs_many(bridge, count: int) -> None:
    for _ in range(count):
        _emit_logs_once(bridge)


def _log_counts(bridge) -> tuple[int, int]:
    warning_count = _count_log_entries(bridge, "WarningLog", "W:")
    error_count = _count_log_entries(bridge, "ErrorLog", "E:")
    return warning_count, error_count


def _set_log_level(bridge, level: LogLevel) -> None:
    bridge.uth.pvput(f"{bridge.prefix}LogLevel", int(level))


def _run_log_phase(
    bridge, level: LogLevel, emits: int, min_warning_delta: int, min_error_delta: int
) -> tuple[tuple[int, int], tuple[int, int]]:
    before_warning, before_error = _log_counts(bridge)
    _set_log_level(bridge, level)
    _emit_logs_many(bridge, emits)

    if min_warning_delta > 0:
        wait_until(
            lambda: _count_log_entries(bridge, "WarningLog", "W:")
            >= before_warning + min_warning_delta,
            timeout=10,
            interval=0.2,
            message="WarningLog did not increase as expected",
        )

    if min_error_delta > 0:
        wait_until(
            lambda: _count_log_entries(bridge, "ErrorLog", "E:")
            >= before_error + min_error_delta,
            timeout=10,
            interval=0.2,
            message="ErrorLog did not increase as expected",
        )

    return (before_warning, before_error), _log_counts(bridge)


# ---------------------------------------------------------------------------
# Test class
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestEchoDaemon(TestBase):
    """Echo daemon integration tests."""

    # ----------------------------------------------------------------------- Logic & scalars

    def test_update_task_duration(self, bridge, daemon_process):
        """Task duration PV is updated to a positive value after a task cycle."""
        bridge.run_task_cycle()
        wait_until(
            lambda: bridge.uth.pvget(f"{bridge.prefix}TaskDuration") > 2,
            timeout=30,
            interval=0.5,
            message="TaskDuration did not update above 2 within 30 s",
        )

    @pytest.mark.parametrize(
        "input_suffix, result_suffix, input_val, expected_val",
        [
            ("InputFloat", "ResultFloat", 10.0, 25.0),
            ("InputFloat", "ResultFloat", 0.0, 0.0),
            ("InputFloat", "ResultFloat", -4.0, -10.0),
            ("InputInt", "ResultInt", 5, 105),
            ("InputInt", "ResultInt", -50, 50),
            ("InputStr", "ResultStr", "World", "Echo: World"),
            ("InputStr", "ResultStr", "", "Echo: "),
            ("InputStr", "ResultStr", "!@#$", "Echo: !@#$"),
        ],
    )
    def test_scalar_processing(
        self,
        bridge,
        daemon_process,
        input_suffix,
        result_suffix,
        input_val,
        expected_val,
    ):
        """Basic scalar processing (Float, Int, String)."""
        pv_in = f"{bridge.echo_prefix}{input_suffix}"
        pv_out = f"{bridge.echo_prefix}{result_suffix}"
        bridge.uth.pvput(pv_in, input_val)
        wait_until(
            lambda: bridge.uth.pvget(pv_in) == input_val,
            timeout=5,
            interval=0.05,
            message=f"Input PV {pv_in} did not reflect written value {input_val!r}",
        )
        bridge.run_task_cycle()
        assert bridge.uth.pvget(pv_out) == expected_val

    def test_process_enum_cycle(self, bridge, daemon_process):
        """Enum wrapping cycle (0->1, 1->2, 2->0)."""
        for inp, expected in [(0, 1), (1, 2), (2, 0)]:
            bridge.uth.pvput(f"{bridge.echo_prefix}InputEnum", inp)
            bridge.run_task_cycle()
            assert bridge.uth.pvget(f"{bridge.echo_prefix}ResultEnum") == expected

    @pytest.mark.parametrize("daemon_process", [True], indirect=True)
    def test_trigger_override_logic(self, bridge, daemon_process):
        """Custom trigger logic for daemon configuration 'Y' (override)."""
        bridge.run_task_cycle()
        assert bridge.uth.pvget(f"{bridge.echo_prefix}ResultInt") == 9999
        assert bridge.uth.pvget(f"{bridge.echo_prefix}ResultStr") == "reset_cycle"
        assert bridge.uth.pvget(f"{bridge.prefix}Trigger") == 0

    @pytest.mark.parametrize("daemon_process", [True], indirect=True)
    def test_warning_and_error_logs_are_published_to_pvs(self, bridge, daemon_process):
        """Override daemon emits many warning/error entries and publishes to log PVs."""
        entries_to_emit = 8
        _emit_logs_many(bridge, entries_to_emit)

        wait_until(
            lambda: _count_log_entries(bridge, "WarningLog", "W:") >= entries_to_emit,
            timeout=10,
            interval=0.2,
            message="WarningLog did not receive many warning entries",
        )
        wait_until(
            lambda: _count_log_entries(bridge, "ErrorLog", "E:") >= entries_to_emit,
            timeout=10,
            interval=0.2,
            message="ErrorLog did not receive many error entries",
        )

    @pytest.mark.parametrize("daemon_process", [True], indirect=True)
    def test_runtime_log_level_controls_warning_and_error_publication(
        self, bridge, daemon_process
    ):
        """Changing LogLevel at runtime updates which log severities are published."""
        before_error_phase, after_error_phase = _run_log_phase(
            bridge,
            level=LogLevel.ERROR,
            emits=3,
            min_warning_delta=0,
            min_error_delta=3,
        )
        baseline_warning, baseline_error = before_error_phase
        after_error_warning, after_error_error = after_error_phase
        assert after_error_warning == baseline_warning
        assert after_error_error >= baseline_error + 3

        before_debug_phase, after_debug_phase = _run_log_phase(
            bridge,
            level=LogLevel.DEBUG,
            emits=3,
            min_warning_delta=3,
            min_error_delta=3,
        )
        before_debug_warning, before_debug_error = before_debug_phase
        after_debug_warning, after_debug_error = after_debug_phase
        assert after_debug_warning >= before_debug_warning + 3
        assert after_debug_error >= before_debug_error + 3

    @pytest.mark.parametrize("daemon_process", [True], indirect=True)
    def test_runtime_log_level_reapplies_across_multiple_changes(
        self, bridge, daemon_process
    ):
        """Runtime LogLevel changes are re-applied on every transition."""
        before_error_one, after_error_one = _run_log_phase(
            bridge,
            level=LogLevel.ERROR,
            emits=2,
            min_warning_delta=0,
            min_error_delta=2,
        )
        baseline_warning, baseline_error = before_error_one
        after_error_one_warning, after_error_one_error = after_error_one
        assert after_error_one_warning == baseline_warning
        assert after_error_one_error >= baseline_error + 2

        before_debug, after_debug = _run_log_phase(
            bridge,
            level=LogLevel.DEBUG,
            emits=2,
            min_warning_delta=2,
            min_error_delta=2,
        )
        before_debug_warning, before_debug_error = before_debug
        after_debug_warning, after_debug_error = after_debug
        assert after_debug_warning >= before_debug_warning + 2
        assert after_debug_error >= before_debug_error + 2

        before_error_two, after_error_two = _run_log_phase(
            bridge,
            level=LogLevel.ERROR,
            emits=2,
            min_warning_delta=0,
            min_error_delta=2,
        )
        before_error_two_warning, before_error_two_error = before_error_two
        after_error_two_warning, after_error_two_error = after_error_two
        assert after_error_two_warning == before_error_two_warning
        assert after_error_two_error >= before_error_two_error + 2

    def test_cycle_skipped_when_skip_cycle_pv_set(self, bridge, daemon_process):
        """When SkipCycle=1, run_task returns SKIPPED: TaskStatus=4, TaskDuration unchanged."""
        bridge.run_task_cycle()
        assert bridge.uth.pvget(f"{bridge.prefix}TaskStatus") == TaskStatus.SUCCESS

        bridge.uth.pvput(f"{bridge.echo_prefix}SkipCycle", 1)
        wait_until(
            lambda: bridge.uth.pvget(f"{bridge.echo_prefix}SkipCycle") == 1,
            timeout=5,
            interval=0.2,
            message="SkipCycle PV did not become 1",
        )

        bridge.uth.pvput(f"{bridge.prefix}Trigger", 1)

        wait_until(
            lambda: bridge.uth.pvget(f"{bridge.prefix}TaskStatus")
            == TaskStatus.SKIPPED,
            timeout=5,
            interval=0.2,
            message="TaskStatus did not become SKIPPED",
        )

    # ----------------------------------------------------------------------- Arrays & images

    def test_process_array(self, bridge, daemon_process):
        """Array logic: output = input * 2."""
        input_arr = np.array([1.0, 2.5, -3.0])
        bridge.uth.pvput(f"{bridge.echo_prefix}InputArr", input_arr)
        bridge.run_task_cycle()

        res = bridge.uth.pvget(f"{bridge.echo_prefix}ResultArr")
        assert np.allclose(res[: len(input_arr)], input_arr * 2)

        full_arr = np.ones(100) * 5.0
        bridge.uth.pvput(f"{bridge.echo_prefix}InputArr", full_arr)
        bridge.run_task_cycle()
        assert np.allclose(
            bridge.uth.pvget(f"{bridge.echo_prefix}ResultArr"), full_arr * 2
        )

    @pytest.mark.parametrize(
        "shape_gen",
        [
            lambda: (512, 512),
            lambda: (2, 512),
            lambda: (768, 1024),
        ],
    )
    def test_image_processing_shapes(self, bridge, daemon_process, shape_gen):
        """Image processing with various shapes."""
        shape = shape_gen()
        data = np.random.randint(0, 100, size=shape, dtype=np.int64)
        expected = data.ravel() * 10
        bridge.set_pva_image(data)
        bridge.run_task_cycle()

        result = bridge.uth.pvget(f"{bridge.pva_prefix}PvaData")
        assert np.array_equal(result, expected), "Mismatch for arrays"

    def test_image_negative_values(self, bridge, daemon_process):
        """Signed integer handling in images."""
        data = np.array([[-10, 20], [-50, 0]], dtype=np.int64)
        expected = data.ravel() * 10
        bridge.set_pva_image(data)
        bridge.run_task_cycle()

        result = bridge.uth.pvget(f"{bridge.pva_prefix}PvaData")
        assert np.array_equal(result, expected)

    # ----------------------------------------------------------------------- Lifecycle & recovery

    def test_heartbeat_lifecycle(self, bridge, daemon_process):
        """Daemon is alive and updates heartbeat."""
        initial = bridge.uth.pvget(f"{bridge.prefix}Heartbeat")
        bridge.wait_for_condition(
            f"{bridge.prefix}Heartbeat",
            lambda v: v != initial,
            timeout=10.0,
            msg="Heartbeat PV did not toggle",
        )

    def test_pv_reset_at_initialization(self, bridge, daemon_process, uth):
        """_initialize_pv_state() resets Busy and Trigger at daemon startup."""
        daemon_process.stop()
        assert not daemon_process.is_running()

        bridge.uth.pvput(f"{bridge.prefix}Busy", 1)
        bridge.uth.pvput(f"{bridge.prefix}Trigger", 1)
        assert bridge.uth.pvget(f"{bridge.prefix}Busy") == 1
        assert bridge.uth.pvget(f"{bridge.prefix}Trigger") == 1

        daemon_process.start()
        wait_until(
            lambda: bridge.uth.pvget(f"{bridge.prefix}Busy") == 0,
            timeout=10,
            interval=0.2,
            message="Busy PV was not reset to 0 during daemon initialization",
        )

        assert bridge.uth.pvget(f"{bridge.prefix}Busy") == 0
        assert bridge.uth.pvget(f"{bridge.prefix}Trigger") == 0

    def test_daemon_recover_after_ioc_crash(self, bridge, daemon_process, uth):
        """Daemon survives intermittent IOC outages and recovers."""
        assert uth.ioc.is_running()
        bridge.uth.pvput(f"{bridge.prefix}Busy", 0)

        for i in range(3):
            uth.stop_ioc()
            assert not uth.ioc.is_running()

            wait_until(
                lambda: daemon_process.is_running(),
                timeout=5,
                interval=0.2,
                message="Daemon died during IOC outage",
            )

            uth.ioc = uth.start_ioc()
            wait_until(
                lambda: uth.ioc.is_running(),
                timeout=30,
                interval=0.5,
                message=f"IOC did not come back up after outage cycle {i + 1}",
            )
            uth.wait_until_ready(f"{bridge.prefix}Heartbeat", timeout=30, interval=0.5)
            # Wait for daemon to publish a new heartbeat (reconnected to IOC)
            uth.wait_for_pv_update(f"{bridge.prefix}Heartbeat", timeout=20)

            bridge.run_task_cycle()
            assert bridge.uth.pvget(f"{bridge.prefix}Trigger") == 0

    def test_daemon_suicide_and_restart(self, bridge, daemon_process, uth):
        """IOC crash -> daemon suicide -> supervisor restart -> processing works."""
        assert uth.ioc.is_running()

        uth.stop_ioc()
        bridge.wait_for_daemon_death(daemon_process)

        uth.ioc = uth.start_ioc()
        daemon_process.start()
        wait_until(
            daemon_process.is_running,
            timeout=10,
            interval=0.2,
            message="Daemon did not start after restart",
        )

        bridge.uth.pvput(f"{bridge.prefix}Busy", 0)
        bridge.run_task_cycle()
        assert bridge.uth.pvget(f"{bridge.prefix}Trigger") == 0

    def test_daemon_restart_stress(self, bridge, daemon_process, uth):
        """Repeatedly restart the daemon while IOC stays up."""
        for _ in range(5):
            daemon_process.stop()
            assert not daemon_process.is_running()

            daemon_process.start()
            wait_until(
                daemon_process.is_running,
                timeout=10,
                interval=0.2,
                message="Daemon did not start during stress restart",
            )
            bridge.wait_for_condition(
                f"{bridge.prefix}Heartbeat",
                lambda v: True,
                timeout=10.0,
                msg="Heartbeat not available after restart",
            )
            wait_until(
                lambda: bridge.uth.pvget(f"{bridge.prefix}Busy") == 0,
                timeout=10,
                interval=0.2,
                message="Busy not reset after daemon restart",
            )

            bridge.run_task_cycle()
            assert bridge.uth.pvget(f"{bridge.prefix}Trigger") == 0
