#
#  PyTrain: a library for controlling Lionel Legacy engines, trains, switches, and accessories
#
#  Copyright (c) 2024-2025 Dave Swindell <pytraininfo.gmail.com>
#
#  SPDX-License-Identifier: LPGL
#

import time
from dataclasses import dataclass
from typing import Any, Callable

import pytest

from src.pytrain.gui import launch_gui as mod
from src.pytrain.protocol.constants import CommandScope

# ---------------------------
# Test doubles (no tkinter)
# Tests generated by ChatGPT 5
# ---------------------------


# noinspection PyMethodMayBeStatic,PyUnusedLocal,PyBroadException
class DummyApp:
    def __init__(self, title: str, width: int, height: int):
        self.title = title
        self.width = width
        self.height = height
        self.bg = "white"
        self._destroyed = False
        self.full_screen = False
        self.when_closed = None

    def after(self, delay_ms: int, func: Callable, args: list | None = None):
        # Run immediately for tests
        if args is None:
            func()
        else:
            func(*args)

    def repeat(self, delay_ms: int, func: Callable):
        # In tests, call once to simulate scheduling
        try:
            func()
        except Exception:
            pass

    def display(self):
        # Return immediately; tests don't want an event loop
        return

    def destroy(self):
        self._destroyed = True


# noinspection PyUnusedLocal
class DummyBox:
    def __init__(self, *args, **kwargs):
        self.enabled = True
        self.visible = True

    def enable(self):
        self.enabled = True

    def disable(self):
        self.enabled = False

    def hide(self):
        self.visible = False

    def show(self):
        self.visible = True


# noinspection PyMethodMayBeStatic,PyUnusedLocal
class DummyText:
    def __init__(self, *args, **kwargs):
        self.value = kwargs.get("text", "")
        self.text_color = kwargs.get("color", "black")
        self.visible = True

    def clear(self):
        self.value = ""

    def show(self):
        self.visible = True

    def repeat(self, delay_ms: int, func: Callable):
        # Call once for tests
        func()

    def cancel(self, func: Callable):
        # No-op for tests
        pass


class _DummyTkHandle:
    def config(self, **kwargs):
        # No-op for tests
        pass


# noinspection PyMethodMayBeStatic,PyUnusedLocal
class DummyPushButton:
    def __init__(self, *args, **kwargs):
        self.image = kwargs.get("image", None)
        self.height = kwargs.get("height", 0)
        self.width = kwargs.get("width", 0)
        self.enabled = True
        self.tk = _DummyTkHandle()

    def enable(self):
        self.enabled = True

    def disable(self):
        self.enabled = False

    # noinspection PyMethodMayBeStatic
    def after(self, delay_ms: int, func: Callable, args: list | None = None):
        if args is None:
            func()
        else:
            func(*args)


# CommandReq and dispatcher/store doubles


@dataclass
class SentCall:
    kwargs: dict


# noinspection PyMethodMayBeStatic,PyUnusedLocal
class FakeReq:
    def __init__(self, *args, **kwargs):
        self._sent = []

    def send(self, **kwargs):
        self._sent.append(SentCall(kwargs=kwargs))

    def as_action(self, **_kwargs):
        def act(**_k):
            # record an invocation
            self.send(**_k)

        return act


class FakeDispatcher:
    def __init__(self):
        self.subscribed = []

    def subscribe(self, who: Any, scope, addr: int):
        self.subscribed.append((who, scope, addr))


class FakeSyncState:
    def __init__(self, synchronized: bool = True):
        self._sync = synchronized

    @property
    def is_synchronized(self) -> bool:
        return self._sync


class FakeEngineState:
    def __init__(self, started=False, aux2=False):
        self.is_started = started
        self.is_aux2 = aux2


# noinspection PyMethodMayBeStatic,PyUnusedLocal
class FakeStore:
    def __init__(self, sync=True, started=False, aux2=False):
        self._sync = FakeSyncState(sync)
        self._engine = FakeEngineState(started, aux2)

    def get_state(self, scope, addr, *args, **kwargs):
        if scope == CommandScope.SYNC:
            return self._sync
        if scope == CommandScope.ENGINE:
            return self._engine
        return None


class DummyWatcher:
    def __init__(self, *_args, **_kwargs):
        pass

    def shutdown(self):
        pass


# ---------------------------
# Fixtures
# ---------------------------


@pytest.fixture(autouse=True)
def patch_guizero_and_system(monkeypatch):
    # Replace guizero widgets with mock objects
    monkeypatch.setattr(mod, "App", DummyApp, raising=True)
    monkeypatch.setattr(mod, "Box", DummyBox, raising=True)
    monkeypatch.setattr(mod, "Text", DummyText, raising=True)
    monkeypatch.setattr(mod, "PushButton", DummyPushButton, raising=True)

    # Replace CommandReq with our recorder
    monkeypatch.setattr(mod, "CommandReq", FakeReq, raising=True)

    # Replace dispatcher and store singletons
    fake_dispatcher = FakeDispatcher()
    monkeypatch.setattr(mod.CommandDispatcher, "get", staticmethod(lambda: fake_dispatcher), raising=True)

    fake_store = FakeStore(sync=True, started=False, aux2=False)
    monkeypatch.setattr(mod.ComponentStateStore, "get", staticmethod(lambda: fake_store), raising=True)
    monkeypatch.setattr(mod.ComponentStateStore, "get_state", staticmethod(fake_store.get_state), raising=True)

    # Replace StateWatcher with no-op
    monkeypatch.setattr(mod, "StateWatcher", DummyWatcher, raising=True)

    # Replace GpioHandler cache to avoid side effects
    monkeypatch.setattr(mod.GpioHandler, "cache_handler", staticmethod(lambda *_: None), raising=True)

    # Replace find_file so we don't need assets
    monkeypatch.setattr(mod, "find_file", lambda name: name, raising=True)

    return fake_dispatcher, fake_store


@pytest.fixture
def instance(monkeypatch):
    # Prevent thread.run from building full UI for some tests by patching run to a no-op.
    # Individual tests can override if they want the UI structure.
    monkeypatch.setattr(mod.LaunchGui, "run", lambda self: None, raising=True)
    return mod.LaunchGui(tmcc_id=39, width=640, height=480)


# ---------------------------
# Tests
# ---------------------------


def test_scale_calculation(instance):
    # width set to 640; scale should proportionally increase from base 480
    # value=72: 72 * 640/480 = 96; with factor=0.7 -> max(72, int(0.7*96)) = max(72, 67) = 72 (since >480 uses factor)
    # but width>480 so factor applies on the already scaled result; 96*0.7 = 67 -> min bound is orig (72)
    assert instance.scale(72, 0.7) == 72
    # For bigger base ensure it grows
    assert instance.scale(128) == max(128, int(128 * 640 / 480))  # 170


def test_on_sync_starts_and_subscribes(monkeypatch):
    started = {"flag": False}

    # noinspection PyUnusedLocal
    def fake_run(self):
        started["flag"] = True

    monkeypatch.setattr(mod.LaunchGui, "run", fake_run, raising=True)

    inst = mod.LaunchGui(tmcc_id=41, width=320, height=240)

    # on_sync should be triggered (sync True), setting started flag
    assert started["flag"] is True

    # And subscribe should be called
    # Fetch the fake dispatcher installed in fixture
    dispatcher = mod.CommandDispatcher.get()
    assert (inst, CommandScope.ENGINE, 41) in dispatcher.subscribed


def test_run_builds_min_ui_and_sync_state(monkeypatch):
    # Let the real run execute against mock objects to create widget tree
    inst = mod.LaunchGui(tmcc_id=39, width=640, height=480)
    # Give some time for thread to run setup and immediately return
    time.sleep(0.05)

    # After run, UI is torn down in finally, so app is None
    assert inst.app is None
    # But run should have executed without exceptions using mock classes


def test_toggle_power_on_path(monkeypatch):
    # Use a trimmed-down run to install minimal widget graph and not tear it down
    def mini_run(self):
        self.app = DummyApp("Launch Pad", self.width, self.height)
        self.lower_box = DummyBox()
        self.upper_box = DummyBox()
        self.lights_box = DummyBox()
        self.siren_box = DummyBox()
        self.klaxon_box = DummyBox()
        self.gantry_box = DummyBox()
        self.comms_box = DummyBox()
        self.power_button = DummyPushButton(image=self.on_button, height=self.s_72, width=self.s_72)
        self.lights_button = DummyPushButton(image=self.on_button, height=self.s_72, width=self.s_72)
        self.label = DummyText(text="T-Minus")
        self.message = DummyText(text="")
        self.count = DummyText(text="")
        self.launch = DummyPushButton()
        # monitored state used by sync_pad_lights
        self._monitored_state = FakeEngineState(started=True, aux2=False)

    monkeypatch.setattr(mod.LaunchGui, "run", mini_run, raising=True)

    inst = mod.LaunchGui(tmcc_id=55, track_id=10, width=640, height=480)

    # Initial state: power button shows "on" image
    assert inst.power_button.image == inst.on_button

    inst.toggle_power()

    # After toggling on: image should switch to off icon
    assert inst.power_button.image == inst.off_button
    # lower_box visibility cycled back on
    assert inst.lower_box.visible is True

    # Power-on command should have been sent with delay=0.5
    assert len(inst.power_on_req._sent) == 1
    assert pytest.approx(inst.power_on_req._sent[0].kwargs.get("delay", 0.0), 0.01) == 0.5

    # Track on request also sent (because track_id provided)
    assert inst.track_on_req is not None
    assert len(inst.track_on_req._sent) == 1


def test_toggle_power_off_path(monkeypatch):
    def mini_run(self):
        self.app = DummyApp("Launch Pad", self.width, self.height)
        self.lower_box = DummyBox()
        self.upper_box = DummyBox()
        self.lights_box = DummyBox()
        self.siren_box = DummyBox()
        self.klaxon_box = DummyBox()
        self.gantry_box = DummyBox()
        self.comms_box = None
        self.power_button = DummyPushButton(image=self.off_button, height=self.s_72, width=self.s_72)
        self.lights_button = DummyPushButton(image=self.on_button, height=self.s_72, width=self.s_72)
        self.klaxon_button = DummyPushButton(image=self.siren_on, height=self.s_72, width=self.s_72)
        self.label = DummyText(text="T-Minus")
        self.message = DummyText(text="")
        self.count = DummyText(text="")
        self.launch = DummyPushButton()
        self._monitored_state = FakeEngineState(started=False, aux2=True)

    monkeypatch.setattr(mod.LaunchGui, "run", mini_run, raising=True)

    inst = mod.LaunchGui(tmcc_id=60, width=640, height=480)

    # Now toggling should power off
    inst.toggle_power()

    assert inst.power_button.image == inst.on_button
    assert len(inst.power_off_req._sent) == 1


def test_set_lights_icons_and_klaxon_off(monkeypatch):
    def mini_run(self):
        self.app = DummyApp("Launch Pad", self.width, self.height)
        self.lower_box = DummyBox()
        self.power_button = DummyPushButton(image=self.on_button, height=self.s_72, width=self.s_72)
        self.lights_button = DummyPushButton(image=self.on_button, height=self.s_72, width=self.s_72)
        self.klaxon_button = DummyPushButton(image=self.siren_on, height=self.s_72, width=self.s_72)

    monkeypatch.setattr(mod.LaunchGui, "run", mini_run, raising=True)

    inst = mod.LaunchGui(tmcc_id=61, width=640, height=480)

    # Turn lights off icon
    inst.set_lights_off_icon()
    assert inst.lights_button.image == inst.off_button
    assert inst.lights_button.height == inst.s_72
    assert inst.lights_button.width == inst.s_72

    # Turn lights on icon
    inst.set_lights_on_icon()
    assert inst.lights_button.image == inst.on_button

    # Klaxon off updates image/size
    inst.set_klaxon_off_icon()
    assert inst.klaxon_button.image == inst.siren_off
    assert inst.klaxon_button.height == inst.s_72
    assert inst.klaxon_button.width == inst.s_72
