from __future__ import annotations

import importlib
import sys
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from account_registry import Account, AccountRef, AccountRegistryKind
from factory_decision_notification_store import FactoryDecisionNotificationStore
from factory_status import (
    FactoryActivity,
    FactoryAvailability,
    FactoryLiveRun,
    FactoryPendingDecision,
    FactoryStatusSnapshot,
)
from health_client import AccountSnapshot, HealthStatus
from provider_services import LifecycleEvent, ProviderServiceMap, ProviderServices
from quota_notification_store import QuotaNotificationStore


@pytest.fixture(autouse=True)
def _isolated_runtime_dir(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None:
    """Icon and health-cache writes land in runtime_dir(); without this they reach the
    operator's real account store."""
    monkeypatch.setenv("SYSTRAY_RUNTIME_DIR", str(tmp_path_factory.mktemp("runtime")))


@dataclass
class _FakeSignalHandle:
    item: object
    signal: str
    callback: object
    blocked: bool = False


class _FakeRadioMenuItem:
    def __init__(self, label: str = "") -> None:
        self.label = label
        self.set_label_calls: list[str] = []
        self.active = False
        self.sensitive = True
        self.signal_handles: list[_FakeSignalHandle] = []
        self.joined_group: "_FakeRadioMenuItem | None" = None

    def join_group(self, group_source: "_FakeRadioMenuItem | None" = None) -> None:
        self.joined_group = group_source

    def connect(self, signal: str, callback):
        handle = _FakeSignalHandle(self, signal, callback)
        self.signal_handles.append(handle)
        return handle

    def handler_block(self, handle: _FakeSignalHandle) -> None:
        handle.blocked = True

    def handler_unblock(self, handle: _FakeSignalHandle) -> None:
        handle.blocked = False

    def set_active(self, active: bool) -> None:
        self.active = active

    def get_active(self) -> bool:
        return self.active

    def set_sensitive(self, sensitive: bool) -> None:
        self.sensitive = sensitive

    def set_label(self, label: str) -> None:
        self.label = label
        self.set_label_calls.append(label)

    def emit(self, signal: str) -> None:
        for handle in self.signal_handles:
            if handle.signal == signal and not handle.blocked:
                handle.callback(self)


class _FakeMenuItem:
    def __init__(self, label: str = "") -> None:
        self.label = label
        self.set_label_calls: list[str] = []
        self.sensitive = True
        self.signal_handles: list[_FakeSignalHandle] = []
        self.submenu = None

    def connect(self, signal: str, callback):
        handle = _FakeSignalHandle(self, signal, callback)
        self.signal_handles.append(handle)
        return handle

    def set_sensitive(self, sensitive: bool) -> None:
        self.sensitive = sensitive

    def set_submenu(self, submenu) -> None:
        self.submenu = submenu

    def set_label(self, label: str) -> None:
        self.label = label
        self.set_label_calls.append(label)

    def emit(self, signal: str) -> None:
        for handle in self.signal_handles:
            if handle.signal == signal and not handle.blocked:
                handle.callback(self)


class _FakeSeparatorMenuItem:
    pass


class _FakeMenu:
    def __init__(self) -> None:
        self.items: list[object] = []
        self.shown = False
        self.signal_handles: list[_FakeSignalHandle] = []

    def append(self, item: object) -> None:
        self.items.append(item)

    def connect(self, signal: str, callback):
        handle = _FakeSignalHandle(self, signal, callback)
        self.signal_handles.append(handle)
        return handle

    def emit(self, signal: str) -> None:
        for handle in self.signal_handles:
            if handle.signal == signal and not handle.blocked:
                handle.callback(self)

    def show_all(self) -> None:
        self.shown = True


class _FakeDialogWindow:
    def __init__(self, title: str = "", transient_for=None, flags: int = 0) -> None:
        self.title = title
        self.transient_for = transient_for
        self.flags = flags
        self.content_area = _FallbackPopupBoxLike()
        self.response = (
            _FakeGtk.dialog_responses.pop(0) if _FakeGtk.dialog_responses else _FakeGtk.ResponseType.CANCEL
        )
        self.destroyed = False

    def add_button(self, _label: str, _response: int) -> None:
        return

    def get_content_area(self):
        return self.content_area

    def show_all(self) -> None:
        return

    def run(self) -> int:
        return self.response

    def destroy(self) -> None:
        self.destroyed = True


class _FakeMessageDialog(_FakeDialogWindow):
    def __init__(self, transient_for=None, flags: int = 0, message_type=None, buttons=None, text: str = "") -> None:
        super().__init__(title=text, transient_for=transient_for, flags=flags)
        self.message_type = message_type
        self.buttons = buttons
        self.text = text


class _FallbackPopupBoxLike:
    def __init__(self) -> None:
        self.children: list[object] = []

    def add(self, child: object) -> None:
        self.children.append(child)

    def pack_start(self, child: object, *_args: object) -> None:
        self.children.append(child)


class _FakeEntry:
    def __init__(self, text: str = "") -> None:
        self.text = text
        self.user_text = None
        if _FakeGtk.entry_texts:
            self.user_text = _FakeGtk.entry_texts.pop(0)

    def set_text(self, text: str) -> None:
        self.text = text

    def get_text(self) -> str:
        if self.user_text is not None:
            return self.user_text
        return self.text


class _FakeLabel:
    def __init__(self, label: str = "") -> None:
        self.label = label

    def set_text(self, text: str) -> None:
        self.label = text


class _FakeButton(_FakeMenuItem):
    pass


class _FakeAppIndicatorInstance:
    def __init__(self, app_id: str, icon_name: str, category: str) -> None:
        self.app_id = app_id
        self.icon_name = icon_name
        self.category = category
        self.status = None
        self.menu = None
        self.title = None
        self.icon_full_calls: list[tuple[str, str]] = []
        self.icon_theme_path_calls: list[str] = []
        self.signal_handles: list[_FakeSignalHandle] = []
        self.connect_calls: list[tuple[str, object]] = []

    def set_status(self, status: str) -> None:
        self.status = status

    def set_menu(self, menu: _FakeMenu) -> None:
        self.menu = menu

    def set_title(self, title: str) -> None:
        self.title = title

    def set_icon_full(self, icon_name: str, description: str) -> None:
        self.icon_full_calls.append((icon_name, description))

    def set_icon_theme_path(self, path: str) -> None:
        self.icon_theme_path_calls.append(path)

    def connect(self, signal: str, callback):
        self.connect_calls.append((signal, callback))
        handle = _FakeSignalHandle(self, signal, callback)
        self.signal_handles.append(handle)
        return handle

    def emit(self, signal: str) -> None:
        for handle in self.signal_handles:
            if handle.signal == signal and not handle.blocked:
                handle.callback(self)


class _FakeGtk:
    quit_calls = 0
    main_calls = 0
    timeout_calls: list[tuple[int, object]] = []
    dialog_responses: list[int] = []
    entry_texts: list[str] = []

    @classmethod
    def reset(cls) -> None:
        cls.quit_calls = 0
        cls.main_calls = 0
        cls.timeout_calls = []
        cls.dialog_responses = []
        cls.entry_texts = []

    class MenuItem(_FakeMenuItem):
        pass

    class RadioMenuItem(_FakeRadioMenuItem):
        pass

    class SeparatorMenuItem(_FakeSeparatorMenuItem):
        pass

    class Dialog(_FakeDialogWindow):
        pass

    class MessageDialog(_FakeMessageDialog):
        pass

    class Entry(_FakeEntry):
        pass

    class Label(_FakeLabel):
        pass

    class Button(_FakeButton):
        pass

    class ResponseType:
        OK = 1
        YES = 2
        CANCEL = 3
        NO = 4

    class ButtonsType:
        YES_NO = "yes-no"

    class MessageType:
        QUESTION = "question"

    @classmethod
    def Menu(cls):
        return _FakeMenu()

    @classmethod
    def main_quit(cls) -> None:
        cls.quit_calls += 1

    @classmethod
    def main(cls) -> None:
        cls.main_calls += 1


class _StrictRadioMenuItem(_FakeRadioMenuItem):
    """Only accepts RadioMenuItem.new_with_label_from_widget-style construction,
    like real GTK: RadioMenuItem(group, label) with group as a widget, not a string."""

    def __init__(self, group=None, label: str = "") -> None:
        if isinstance(group, str) and label == "":
            raise TypeError("Must be string, not RadioMenuItem")
        super().__init__(label if label else group)

    @classmethod
    def new_with_label_from_widget(cls, group, label: str):
        item = cls.__new__(cls)
        _FakeRadioMenuItem.__init__(item, label)
        item.join_group(group)
        return item


class _StrictGtk(_FakeGtk):
    class RadioMenuItem(_StrictRadioMenuItem):
        pass


class _FakeAppIndicator3:
    class IndicatorCategory:
        APPLICATION_STATUS = "application-status"

    class IndicatorStatus:
        ACTIVE = "active"

    class Indicator:
        created: list[_FakeAppIndicatorInstance] = []

        @classmethod
        def new(cls, app_id: str, icon_name: str, category: str) -> _FakeAppIndicatorInstance:
            instance = _FakeAppIndicatorInstance(app_id, icon_name, category)
            cls.created.append(instance)
            return instance


class _FakeGLib:
    source_remove_calls: list[int] = []

    @staticmethod
    def idle_add(callback, *args):
        return callback(*args)

    @staticmethod
    def timeout_add_seconds(seconds: int, callback):
        _FakeGtk.timeout_calls.append((seconds, callback))
        return len(_FakeGtk.timeout_calls)

    @staticmethod
    def source_remove(source_id: int) -> None:
        _FakeGLib.source_remove_calls.append(source_id)


class _FakeRegistry:
    def __init__(
        self,
        accounts: list[Account],
        default_slug: str | None,
        *,
        base_dir: Path | None = None,
        kind: AccountRegistryKind = AccountRegistryKind.CODEX,
        locked_slugs: set[str] | None = None,
    ) -> None:
        self._accounts = list(accounts)
        self._default_slug = default_slug
        self.base_dir = base_dir or Path("/tmp/fake-registry")
        self.kind = kind
        self.set_default_calls: list[str] = []
        self.new_slug_calls: list[str] = []
        self.add_dir_calls: list[tuple[str, str]] = []
        self.remove_calls: list[str] = []
        self.rename_calls: list[tuple[str, str]] = []
        self.lock_calls: list[tuple[str, bool]] = []
        self._locked_slugs = set(locked_slugs or ())

    def list(self) -> list[Account]:
        return list(self._accounts)

    def default_slug(self) -> str | None:
        return self._default_slug

    def set_default(self, account: Account) -> None:
        self.set_default_calls.append(account.slug)
        self._default_slug = account.slug

    def is_locked(self, slug: str) -> bool:
        return slug in self._locked_slugs

    def set_locked(self, slug: str, locked: bool) -> bool:
        self.lock_calls.append((slug, locked))
        changed = (slug not in self._locked_slugs) if locked else (slug in self._locked_slugs)
        if locked:
            self._locked_slugs.add(slug)
        else:
            self._locked_slugs.discard(slug)
        return changed

    def new_slug(self, alias: str) -> str:
        self.new_slug_calls.append(alias)
        return alias.lower().replace(" ", "-")

    def add_dir(self, slug: str, alias: str) -> Path:
        self.add_dir_calls.append((slug, alias))
        account = _account(slug, alias, tool=self.kind.value)
        self._accounts.append(account)
        return account.account_home

    def remove(self, slug: str) -> None:
        self.remove_calls.append(slug)
        self._accounts = [account for account in self._accounts if account.slug != slug]

    def rename(self, slug: str, new_alias: str) -> str:
        self.rename_calls.append((slug, new_alias))
        for account in self._accounts:
            if account.slug == slug:
                account.alias = new_alias
                account.ref = AccountRef(tool=account.tool, slug=new_alias.lower().replace(" ", "-"))
                if self._default_slug == slug:
                    self._default_slug = account.slug
                return account.slug
        raise KeyError(slug)


class _FakeHealthClient:
    def __init__(self, fetch_results: dict[Path, AccountSnapshot] | None = None) -> None:
        self.fetch_results = dict(fetch_results or {})
        self.fetch_calls: list[Path] = []

    def fetch(
        self,
        codex_home: Path,
        timeout_secs: float = 10.0,
        *,
        allow_warmup: bool = False,
    ) -> AccountSnapshot:
        del timeout_secs, allow_warmup
        self.fetch_calls.append(codex_home)
        return self.fetch_results.get(
            codex_home,
            AccountSnapshot(HealthStatus.UNKNOWN, None, None),
        )

    def read_cache(self, *_args, **_kwargs):
        raise AssertionError("indicator should use HealthSnapshotStore")

    def write_cache(self, *_args, **_kwargs):
        raise AssertionError("indicator should use HealthSnapshotStore")


class _FakeProviderHealth:
    def __init__(self, fetch_results: dict[str, AccountSnapshot] | None = None) -> None:
        self.fetch_results = dict(fetch_results or {})
        self.fetch_calls: list[Account] = []

    def fetch(self, account: Account, timeout_secs: float = 10.0) -> AccountSnapshot:
        del timeout_secs
        self.fetch_calls.append(account)
        return self.fetch_results.get(
            account.tray_key,
            AccountSnapshot(HealthStatus.UNKNOWN, None, None),
        )


class _FakeLifecycle:
    def __init__(
        self,
        *,
        add_events: list[object] | None = None,
        reauthenticate_events: list[object] | None = None,
    ) -> None:
        self.add_calls: list[tuple[str, str | None, str | None]] = []
        self.reauthenticate_calls: list[Account] = []
        self.submit_code_calls: list[tuple[object, str]] = []
        self._add_events = list(add_events or [])
        self._reauthenticate_events = list(reauthenticate_events or [])

    def add(self, alias: str, *, login_hint: str | None = None, slug: str | None = None):
        self.add_calls.append((alias, login_hint, slug))
        yield from self._add_events

    def reauthenticate(self, account: Account):
        self.reauthenticate_calls.append(account)
        yield from self._reauthenticate_events

    def submit_code(self, session, code: str) -> None:
        self.submit_code_calls.append((session, code))


class _FakeHealthSnapshotStore:
    def __init__(
        self,
        snapshots: dict[str, AccountSnapshot] | None = None,
        *,
        path: Path | None = None,
    ) -> None:
        self.path = path or Path("/tmp/fake-registry/health_cache.json")
        self.snapshots = dict(snapshots or {})
        self.read_calls = 0
        self.write_calls: list[dict[str, AccountSnapshot]] = []

    def read(self) -> dict[str, AccountSnapshot]:
        self.read_calls += 1
        return dict(self.snapshots)

    def write(self, snapshots: dict[str, AccountSnapshot]) -> None:
        self.write_calls.append(dict(snapshots))
        self.snapshots = dict(snapshots)


class _FakePopupWindow:
    def __init__(self, **kwargs) -> None:
        self.kwargs = kwargs
        self.title = kwargs.get("title")
        self.auto_close = kwargs.get("auto_close")
        self.transient_for = kwargs.get("transient_for")
        self.content_area = _FallbackPopupBoxLike()
        self.set_auto_close_calls: list[bool] = []
        self.resize_to_content_calls = 0
        self._closed = True
        self._visible = False

    def set_auto_close(self, enabled: bool) -> None:
        self.auto_close = enabled
        self.set_auto_close_calls.append(enabled)

    def get_content_area(self):
        return self.content_area

    def show(self) -> None:
        self._closed = False
        self._visible = True

    def show_all(self) -> None:
        self.show()

    def resize_to_content(self) -> None:
        self.resize_to_content_calls += 1

    def close(self) -> None:
        self._closed = True
        self._visible = False

    @property
    def is_closed(self) -> bool:
        return self._closed

    @property
    def is_visible(self) -> bool:
        return self._visible


@dataclass(frozen=True)
class _FakeSettings:
    auto_close: bool = False
    refresh_interval_seconds: int = 60
    warning_threshold_pct: int = 15
    last_tab: str = ""
    theme: str = "system"


class _FakeSettingsStore:
    def __init__(self, settings: _FakeSettings | None = None) -> None:
        self.settings = settings or _FakeSettings()
        self.write_calls: list[_FakeSettings] = []

    def read(self) -> _FakeSettings:
        return self.settings

    def write(self, settings: _FakeSettings) -> None:
        self.write_calls.append(settings)
        self.settings = settings


class _FakeDashboard:
    def __init__(self, callbacks) -> None:
        self.callbacks = callbacks
        self.widget = object()
        self.update_calls: list[object] = []
        self.shown_tabs: list[str] = []
        self.theme_calls: list[str] = []

    def update(self, vm) -> None:
        self.update_calls.append(vm)

    def show_tab(self, tab_id: str) -> None:
        self.shown_tabs.append(tab_id)
        self.callbacks.on_tab_changed(tab_id)

    def set_theme(self, resolved: str) -> None:
        self.theme_calls.append(resolved)


class _FakeSettingsDialog:
    instances: list["_FakeSettingsDialog"] = []

    def __init__(self, settings, on_save, transient_for=None, gtk_module=None) -> None:
        self.settings = settings
        self.on_save = on_save
        self.transient_for = transient_for
        self.opened = False
        _FakeSettingsDialog.instances.append(self)

    def open(self) -> None:
        self.opened = True

    def simulate_save(self, settings) -> None:
        self.on_save(settings)


class _FakeAuthProcess:
    def __init__(self) -> None:
        self.terminate_calls = 0
        self.kill_calls = 0
        self.wait_calls: list[float | None] = []
        self.returncode = None

    def poll(self):
        return self.returncode

    def terminate(self) -> None:
        self.terminate_calls += 1
        self.returncode = 0

    def wait(self, timeout=None):
        self.wait_calls.append(timeout)
        return self.returncode

    def kill(self) -> None:
        self.kill_calls += 1
        self.returncode = -9


class _FakeDeviceAuthSession:
    def __init__(self) -> None:
        self.process = _FakeAuthProcess()


@dataclass
class _FakeClaudeLoginPrompt:
    url: str
    raw_text: str


@dataclass
class _FakeClaudeLoginSession:
    process: _FakeAuthProcess
    account_home: Path


class _FakeDeviceAuthFlow:
    def __init__(
        self,
        *,
        prompt=None,
        completion_result: bool = True,
        prompt_error: Exception | None = None,
    ) -> None:
        self.prompt = prompt
        self.completion_result = completion_result
        self.prompt_error = prompt_error
        self.start_calls: list[tuple[Path, bool]] = []
        self.read_prompt_calls: list[object] = []
        self.await_completion_calls: list[object] = []
        self.commit_calls: list[Path] = []
        self.rollback_calls: list[Path] = []

    def start(self, codex_home: Path, backup_existing: bool):
        self.start_calls.append((codex_home, backup_existing))
        return _FakeDeviceAuthSession()

    def read_prompt(self, session):
        self.read_prompt_calls.append(session)
        if self.prompt_error is not None:
            raise self.prompt_error
        return self.prompt

    def await_completion(self, session) -> bool:
        self.await_completion_calls.append(session)
        return self.completion_result

    def commit(self, codex_home: Path) -> None:
        self.commit_calls.append(codex_home)

    def rollback(self, codex_home: Path) -> None:
        self.rollback_calls.append(codex_home)


class _FakeDialog:
    def __init__(self, prompt, on_retry=None, on_cancel=None, existing_accounts=None) -> None:
        self.prompt = prompt
        self.on_retry = on_retry
        self.on_cancel = on_cancel
        self.existing_accounts = existing_accounts
        self.failure_retry_available: bool | None = None
        self.closed_success = False

    def show_all(self) -> None:
        return

    def show_failure(self, retry_available: bool) -> None:
        self.failure_retry_available = retry_available

    def close_success(self) -> None:
        self.closed_success = True


def _account(slug: str, alias: str, plan: str | None = "plus", *, tool: str = "codex") -> Account:
    return Account(
        ref=AccountRef(tool, slug),
        alias=alias,
        account_home=Path("/tmp") / slug,
        email=f"{slug}@example.com",
        plan=plan,
        account_id=f"acct-{slug}",
    )


def _idle_factory_snapshot() -> FactoryStatusSnapshot:
    return FactoryStatusSnapshot(
        availability=FactoryAvailability.AVAILABLE,
        activity=FactoryActivity.IDLE,
        live_runs=(),
        db_path="/tmp/sssf.db",
        db_present=True,
    )


def _build_indicator(
    registry: _FakeRegistry,
    *,
    claude_registry: _FakeRegistry | None = None,
    provider_services: ProviderServiceMap | None = None,
    fetch_results: dict[Path, AccountSnapshot] | None = None,
    store_snapshots: dict[str, AccountSnapshot] | None = None,
    claude_store_snapshots: dict[str, AccountSnapshot] | None = None,
    idle_add=None,
    thread_runner=None,
    popup_window_factory=None,
    dashboard_factory=None,
    settings_store=None,
    settings_dialog_factory=None,
    device_auth_flow=None,
    dialog_factory=None,
    time_fn=None,
    time_values: list[float] | None = None,
    gtk_module=None,
    glib_module=None,
    quota_notification_store=None,
    factory_status_loader=None,
    factory_url_opener=None,
    factory_decision_notification_store=None,
):
    import indicator

    gtk = gtk_module or _FakeGtk
    gtk.reset()
    _FakeAppIndicator3.Indicator.created = []
    health_client = _FakeHealthClient(fetch_results=fetch_results)

    kwargs: dict[str, object] = dict(
        registry=registry,
        health_client=health_client,
        claude_registry=claude_registry,
        gtk_module=gtk,
        appindicator_module=_FakeAppIndicator3,
        health_snapshot_store=_FakeHealthSnapshotStore(
            store_snapshots,
            path=Path(registry.base_dir) / "health_cache.json",
        ),
        idle_add=idle_add or (lambda callback, *args: callback(*args)),
        thread_runner=thread_runner or (lambda target: target()),
        popup_window_factory=popup_window_factory,
        dashboard_factory=dashboard_factory or _FakeDashboard,
        settings_store=settings_store or _FakeSettingsStore(),
        settings_dialog_factory=settings_dialog_factory or _FakeSettingsDialog,
        quota_notification_store=quota_notification_store
        or QuotaNotificationStore(Path(tempfile.mkdtemp()) / "quota_notifications.json"),
        factory_status_loader=factory_status_loader or _idle_factory_snapshot,
        factory_url_opener=factory_url_opener,
        factory_decision_notification_store=factory_decision_notification_store
        or FactoryDecisionNotificationStore(
            Path(tempfile.mkdtemp()) / "factory_decision_notifications.json"
        ),
    )
    codex_provider_health = None
    claude_provider_health = None
    codex_lifecycle = None
    claude_lifecycle = None
    if provider_services is None and claude_registry is not None:
        codex_fetch_results = {}
        claude_fetch_results = {}
        for account in registry.list():
            snapshot = health_client.fetch_results.get(account.account_home)
            if snapshot is not None:
                codex_fetch_results[account.tray_key] = snapshot
        for account in claude_registry.list():
            snapshot = health_client.fetch_results.get(account.account_home)
            if snapshot is not None:
                claude_fetch_results[account.tray_key] = snapshot
        (
            provider_services,
            codex_provider_health,
            claude_provider_health,
            codex_lifecycle,
            claude_lifecycle,
        ) = _provider_service_map(
            registry,
            claude_registry,
            codex_fetch_results=codex_fetch_results,
            claude_fetch_results=claude_fetch_results,
        )
    if provider_services is not None:
        kwargs["provider_services"] = provider_services
    if claude_registry is not None:
        kwargs["claude_health_snapshot_store"] = _FakeHealthSnapshotStore(
            claude_store_snapshots,
            path=Path(claude_registry.base_dir) / "claude_health_cache.json",
        )
    if device_auth_flow is not None:
        kwargs["device_auth_flow"] = device_auth_flow
    if dialog_factory is not None:
        kwargs["dialog_factory"] = dialog_factory

    tray = indicator.Indicator(**kwargs)
    tray.health_client = health_client
    if codex_provider_health is not None:
        tray._codex_provider_health = codex_provider_health
    if claude_provider_health is not None:
        tray._claude_provider_health = claude_provider_health
    if codex_lifecycle is not None:
        tray._codex_lifecycle = codex_lifecycle
    if claude_lifecycle is not None:
        tray._claude_lifecycle = claude_lifecycle

    if time_values is not None:
        sequence = list(time_values)
        remaining = iter(sequence)

        def fake_time() -> float:
            try:
                return next(remaining)
            except StopIteration:
                return sequence[-1]

        tray._time = fake_time
    elif time_fn is not None:
        tray._time = time_fn

    if glib_module is not None:
        tray._glib = glib_module

    tray.build()
    return tray


def _read_warning_icon(tray) -> str:
    return tray._warning_icon_path.read_text(encoding="utf-8")


def _build_dual_indicator(codex_registry: _FakeRegistry, claude_registry: _FakeRegistry):
    return _build_indicator(codex_registry, claude_registry=claude_registry)


def _provider_service_map(
    codex_registry: _FakeRegistry,
    claude_registry: _FakeRegistry | None = None,
    *,
    codex_fetch_results: dict[str, AccountSnapshot] | None = None,
    claude_fetch_results: dict[str, AccountSnapshot] | None = None,
) -> tuple[ProviderServiceMap, _FakeProviderHealth, _FakeProviderHealth | None, _FakeLifecycle, _FakeLifecycle | None]:
    codex_health = _FakeProviderHealth(fetch_results=codex_fetch_results)
    codex_lifecycle = _FakeLifecycle()
    providers = [
        ProviderServices(
            tool=AccountRegistryKind.CODEX,
            registry=codex_registry,
            lifecycle=codex_lifecycle,
            health=codex_health,
        )
    ]
    claude_health: _FakeProviderHealth | None = None
    claude_lifecycle: _FakeLifecycle | None = None
    if claude_registry is not None:
        claude_health = _FakeProviderHealth(fetch_results=claude_fetch_results)
        claude_lifecycle = _FakeLifecycle()
        providers.append(
            ProviderServices(
                tool=AccountRegistryKind.CLAUDE,
                registry=claude_registry,
                lifecycle=claude_lifecycle,
                health=claude_health,
            )
        )
    return (
        ProviderServiceMap(providers),
        codex_health,
        claude_health,
        codex_lifecycle,
        claude_lifecycle,
    )


def _provider_service_map_with_claude_events(
    codex_registry: _FakeRegistry,
    claude_registry: _FakeRegistry,
    *,
    claude_add_events: list[object] | None = None,
    claude_reauthenticate_events: list[object] | None = None,
) -> tuple[ProviderServiceMap, _FakeLifecycle, _FakeLifecycle]:
    codex_health = _FakeProviderHealth()
    codex_lifecycle = _FakeLifecycle()
    claude_health = _FakeProviderHealth()
    claude_lifecycle = _FakeLifecycle(
        add_events=claude_add_events,
        reauthenticate_events=claude_reauthenticate_events,
    )
    providers = [
        ProviderServices(
            tool=AccountRegistryKind.CODEX,
            registry=codex_registry,
            lifecycle=codex_lifecycle,
            health=codex_health,
        ),
        ProviderServices(
            tool=AccountRegistryKind.CLAUDE,
            registry=claude_registry,
            lifecycle=claude_lifecycle,
            health=claude_health,
        ),
    ]
    return ProviderServiceMap(providers), codex_lifecycle, claude_lifecycle


def _build_indicator_with_repair(registry: _FakeRegistry, flow: _FakeDeviceAuthFlow):
    dialogs: list[_FakeDialog] = []

    def dialog_factory(prompt, on_retry=None, on_cancel=None, existing_accounts=None):
        dialog = _FakeDialog(
            prompt,
            on_retry=on_retry,
            on_cancel=on_cancel,
            existing_accounts=existing_accounts,
        )
        dialogs.append(dialog)
        return dialog

    tray = _build_indicator(
        registry,
        device_auth_flow=flow,
        dialog_factory=dialog_factory,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )
    return tray, dialogs


def _build_indicator_with_refresh(
    registry: _FakeRegistry,
    *,
    fetch_results: dict[Path, AccountSnapshot] | None = None,
    store_snapshots: dict[str, AccountSnapshot] | None = None,
    time_values: list[float] | None = None,
    popup_window_factory=None,
    settings_store=None,
    glib_module=None,
):
    tray = _build_indicator(
        registry,
        fetch_results=fetch_results,
        store_snapshots=store_snapshots,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
        popup_window_factory=popup_window_factory,
        settings_store=settings_store,
        time_values=time_values or [1000.0],
        glib_module=glib_module or _FakeGLib,
    )
    return tray, tray.health_client


def _build_indicator_with_gtk(registry: _FakeRegistry, gtk_module):
    return _build_indicator(registry, gtk_module=gtk_module)


# ---------------------------------------------------------------------------
# Build / menu wiring
# ---------------------------------------------------------------------------


def test_build_wires_indicator_from_tray_model_and_health_store() -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy", "pro")],
        default_slug="roy",
    )
    tray = _build_indicator(
        registry,
        store_snapshots={
            "rafa": AccountSnapshot(HealthStatus.OK, 12, 34),
            "roy": AccountSnapshot(HealthStatus.BROKEN, None, None),
        },
        fetch_results={Path("/tmp/roy"): AccountSnapshot(HealthStatus.BROKEN, None, None)},
    )

    assert tray.indicator is not None
    assert tray.indicator.app_id == "systray-ai"
    assert tray.indicator.icon_name == "codex-account-switcher"
    assert tray.indicator.status == _FakeAppIndicator3.IndicatorStatus.ACTIVE
    assert tray.indicator.title == "Codex: Roy (pro)"
    assert tray.menu is not None
    assert tray.menu.shown is True
    assert tray.menu.items[0].label == (
        "🔓 Rafa · plus\n5h [##################--] 88% left\n7d [#############-------] 66% left"
    )
    assert tray.menu.items[1].label == "🔓 Roy · pro · ⚠ needs re-login"
    assert tray.menu.items[1].get_active() is True


def test_build_menu_includes_open_dashboard_launcher() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray = _build_indicator(registry)

    labels = [item.label for item in tray.menu.items if hasattr(item, "label")]
    assert labels == ["🔓 Rafa · plus", "Open dashboard", "Manage accounts", "Quit"]


def test_build_ignores_appindicator_activate_signal_when_unsupported() -> None:
    class _IndicatorWithoutActivate(_FakeAppIndicatorInstance):
        def connect(self, signal: str, callback):
            if signal == "activate":
                raise TypeError("unknown signal name: activate")
            return super().connect(signal, callback)

    class _AppIndicatorWithoutActivate(_FakeAppIndicator3):
        class Indicator:
            created: list[_IndicatorWithoutActivate] = []

            @classmethod
            def new(cls, app_id: str, icon_name: str, category: str) -> _IndicatorWithoutActivate:
                instance = _IndicatorWithoutActivate(app_id, icon_name, category)
                cls.created.append(instance)
                return instance

    import indicator

    _FakeGtk.reset()
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray = indicator.Indicator(
        registry=registry,
        health_client=_FakeHealthClient(),
        gtk_module=_FakeGtk,
        appindicator_module=_AppIndicatorWithoutActivate,
        health_snapshot_store=_FakeHealthSnapshotStore(),
        settings_store=_FakeSettingsStore(),
    )

    tray.build()

    assert tray.indicator is not None
    assert tray.indicator.title == "Codex: Rafa (plus)"


def test_build_uses_radio_menu_item_api_compatible_with_real_gtk() -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy", "pro")],
        default_slug="roy",
    )

    tray = _build_indicator_with_gtk(registry, _StrictGtk)

    assert tray.menu is not None
    assert [item.label for item in tray.menu.items[:2]] == ["🔓 Rafa · plus", "🔓 Roy · pro"]


def test_appindicator_id_and_icon_are_configurable_via_env(monkeypatch) -> None:
    monkeypatch.setenv("CODEX_TRAY_APPINDICATOR_ID", "custom-tray-id")
    monkeypatch.setenv("CODEX_TRAY_ICON_NAME", "custom-tray-icon")
    import indicator

    importlib.reload(indicator)
    try:
        registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
        _FakeGtk.reset()
        _FakeAppIndicator3.Indicator.created = []
        tray = indicator.Indicator(
            registry=registry,
            health_client=_FakeHealthClient(),
            gtk_module=_FakeGtk,
            appindicator_module=_FakeAppIndicator3,
            health_snapshot_store=_FakeHealthSnapshotStore(),
            settings_store=_FakeSettingsStore(),
            idle_add=lambda callback, *args: callback(*args),
            thread_runner=lambda target: target(),
        )
        tray.build()

        assert tray.indicator.app_id == "custom-tray-id"
        assert tray.indicator.icon_name == "custom-tray-icon"
    finally:
        monkeypatch.delenv("CODEX_TRAY_APPINDICATOR_ID", raising=False)
        monkeypatch.delenv("CODEX_TRAY_ICON_NAME", raising=False)
        importlib.reload(indicator)


def test_build_uses_unknown_title_for_missing_default() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa", None)], default_slug=None)

    tray = _build_indicator(registry)

    assert tray.indicator.title == "Codex: unknown account"


def test_build_eagerly_refreshes_all_accounts() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray = _build_indicator(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 10, 20)},
    )

    assert tray.health_client.fetch_calls == [Path("/tmp/rafa")]
    assert tray.menu.items[0].label == (
        "🔓 Rafa · plus\n5h [##################--] 90% left\n7d [################----] 80% left"
    )


def test_locked_account_is_offline_in_title_menu_and_health_refresh() -> None:
    registry = _FakeRegistry(
        accounts=[_account("owner", "Owner"), _account("agent", "Agent")],
        default_slug="owner",
        locked_slugs={"owner"},
    )

    tray = _build_indicator(
        registry,
        fetch_results={
            Path("/tmp/owner"): AccountSnapshot(HealthStatus.OK, 1, 2),
            Path("/tmp/agent"): AccountSnapshot(HealthStatus.OK, 3, 4),
        },
    )

    assert tray.indicator.title == "🔒 Codex: Owner (plus)"
    assert tray.menu.items[0].label == "🔒 Owner · plus"
    assert tray.menu.items[0].sensitive is False
    assert tray.health_client.fetch_calls == [Path("/tmp/agent")]

    manage = next(
        item
        for item in tray.menu.items
        if getattr(item, "label", None) == "Manage accounts"
    )
    owner_actions = {
        item.label: item.sensitive
        for item in manage.submenu.items
        if "Owner" in getattr(item, "label", "")
    }
    assert owner_actions == {
        "Unlock Owner": True,
        "Set default Owner": False,
        "Reload Owner": False,
        "Re-authenticate Owner…": False,
        "Rename Owner…": True,
        "Remove Owner…": True,
        "Move Owner to Gateway…": False,
    }


def test_tray_lock_toggle_locks_immediately_and_confirms_unlock() -> None:
    registry = _FakeRegistry(
        accounts=[_account("owner", "Owner")],
        default_slug="owner",
    )
    tray = _build_indicator(registry, thread_runner=lambda _target: None)
    notifications: list[str] = []
    tray._notify = notifications.append
    account = registry.list()[0]

    tray._toggle_account_lock(account)

    assert registry.lock_calls == [("owner", True)]
    assert registry.is_locked("owner") is True
    assert tray.indicator.title == "🔒 Codex: Owner (plus)"

    _FakeGtk.dialog_responses = [_FakeGtk.ResponseType.NO]
    tray._toggle_account_lock(account)
    assert registry.lock_calls == [("owner", True)]
    assert registry.is_locked("owner") is True

    _FakeGtk.dialog_responses = [_FakeGtk.ResponseType.YES]
    tray._toggle_account_lock(account)
    assert registry.lock_calls == [("owner", True), ("owner", False)]
    assert registry.is_locked("owner") is False
    assert tray.indicator.title == "Codex: Owner (plus)"
    assert notifications[-1] == "Account Owner unlocked. New sessions may use it again."


def test_locked_account_rejects_manual_reload_and_reauthentication() -> None:
    registry = _FakeRegistry(
        accounts=[_account("owner", "Owner")],
        default_slug="owner",
        locked_slugs={"owner"},
    )
    tray = _build_indicator(
        registry,
        fetch_results={Path("/tmp/owner"): AccountSnapshot(HealthStatus.OK, 1, 2)},
        thread_runner=lambda _target: None,
    )
    notifications: list[str] = []
    background_calls: list[object] = []
    tray._notify = notifications.append
    tray._thread_runner = background_calls.append
    account = registry.list()[0]

    tray._refresh_account(account)
    tray._repair_account(account)

    assert tray.health_client.fetch_calls == []
    assert background_calls == []
    assert notifications == ["Account Owner is locked", "Account Owner is locked"]


def test_build_without_accounts_writes_empty_health_cache() -> None:
    registry = _FakeRegistry(accounts=[], default_slug=None)

    tray, health_client = _build_indicator_with_refresh(registry, fetch_results={})

    assert tray.indicator.title == "Codex: unknown account"
    assert health_client.fetch_calls == []
    assert tray._health_store.write_calls[-1] == {}
    assert tray.menu is not None
    assert [item.label for item in tray.menu.items if hasattr(item, "label")] == [
        "Open dashboard",
        "Manage accounts",
        "Quit",
    ]


def test_health_cache_write_excludes_accounts_missing_from_registry() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    active = AccountSnapshot(HealthStatus.OK, 10, 20)
    removed = AccountSnapshot(HealthStatus.OK, 30, 40)
    tray = _build_indicator(
        registry,
        store_snapshots={"rafa": active, "removed": removed},
        thread_runner=lambda _target: None,
    )

    tray._write_health_cache()

    assert tray._health_store.write_calls[-1] == {"rafa": active}


# ---------------------------------------------------------------------------
# Dual codex/claude registry menu
# ---------------------------------------------------------------------------


def test_build_renders_codex_and_claude_account_columns(monkeypatch) -> None:
    codex_registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy", "pro")],
        default_slug="rafa",
    )
    claude_registry = _FakeRegistry(
        accounts=[
            _account("work", "Work", tool="claude"),
            _account("personal", "Personal", "max", tool="claude"),
        ],
        default_slug="personal",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)

    tray = _build_dual_indicator(codex_registry, claude_registry)

    assert tray.indicator.title == "Codex: Rafa (plus) · Claude: Personal (max)"
    assert tray.menu.items[0].label == "Codex"
    assert tray.menu.items[1].label == "Claude Code"
    codex_menu = tray.menu.items[0].submenu
    claude_menu = tray.menu.items[1].submenu
    assert [item.label for item in codex_menu.items if hasattr(item, "label")] == [
        "🔓 Rafa · plus",
        "🔓 Roy · pro",
        "Manage accounts",
    ]
    assert [item.label for item in claude_menu.items if hasattr(item, "label")] == [
        "🔓 Work · plus · work@example.com",
        "🔓 Personal · max · personal@example.com",
        "Manage accounts",
    ]
    assert codex_menu.items[0].get_active() is True
    assert claude_menu.items[1].get_active() is True

    claude_menu.items[0].set_active(True)
    claude_menu.items[0].emit("activate")

    assert codex_registry.set_default_calls == []
    assert claude_registry.set_default_calls == ["work"]


def test_dual_menu_keeps_codex_refresh_login_actions(monkeypatch) -> None:
    codex_registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy", "pro")],
        default_slug="rafa",
    )
    claude_registry = _FakeRegistry(
        accounts=[_account("work", "Work", tool="claude")],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    tray = _build_dual_indicator(codex_registry, claude_registry)
    repaired: list[str] = []
    tray._repair_account = lambda account: repaired.append(account.slug)

    codex_menu = tray.menu.items[0].submenu
    manage_menu = codex_menu.items[3].submenu
    refresh_roy_item = manage_menu.items[12]
    refresh_roy_item.emit("activate")

    assert [item.label for item in manage_menu.items if hasattr(item, "label")] == [
        "Add account…",
        "Lock Rafa",
        "Set default Rafa",
        "Reload Rafa",
        "Re-authenticate Rafa…",
        "Rename Rafa…",
        "Remove Rafa…",
        "Move Rafa to Gateway…",
        "Lock Roy",
        "Set default Roy",
        "Reload Roy",
        "Re-authenticate Roy…",
        "Rename Roy…",
        "Remove Roy…",
        "Move Roy to Gateway…",
    ]
    assert repaired == ["roy"]
    assert codex_registry.set_default_calls == []
    assert codex_registry.default_slug() == "rafa"


def test_dual_codex_menu_shows_cached_session_limit_snapshots() -> None:
    codex_registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy", "pro")],
        default_slug="rafa",
    )
    claude_registry = _FakeRegistry(
        accounts=[_account("work", "Work", tool="claude")],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    store_snapshots = {
        "rafa": AccountSnapshot(HealthStatus.OK, 12, 34),
        "roy": AccountSnapshot(HealthStatus.BROKEN, None, None),
    }
    provider_services, *_rest = _provider_service_map(
        codex_registry,
        claude_registry,
        codex_fetch_results={"codex:roy": AccountSnapshot(HealthStatus.BROKEN, None, None)},
    )
    tray = _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        provider_services=provider_services,
        store_snapshots=store_snapshots,
    )
    tray.health_client.fetch_calls.clear()

    codex_menu = tray.menu.items[0].submenu
    assert tray.health_client.fetch_calls == []
    assert codex_menu.items[0].label == (
        "🔓 Rafa · plus\n5h [##################--] 88% left\n7d [#############-------] 66% left"
    )
    assert codex_menu.items[1].label == "🔓 Roy · pro · ⚠ needs re-login"


def test_dual_codex_menu_updates_session_limits_after_refresh() -> None:
    codex_registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    claude_registry = _FakeRegistry(
        accounts=[_account("work", "Work", tool="claude")],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )

    tray = _build_dual_indicator(codex_registry, claude_registry)
    tray._apply_snapshot(
        codex_registry.list()[0],
        AccountSnapshot(HealthStatus.OK, 55, 66),
        generation=None,
    )

    codex_menu = tray.menu.items[0].submenu
    assert codex_menu.items[0].label == (
        "🔓 Rafa · plus\n5h [#########-----------] 45% left\n7d [#######-------------] 34% left"
    )


def test_dual_claude_menu_shows_email_and_limit_snapshots() -> None:
    codex_registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    claude_registry = _FakeRegistry(
        accounts=[_account("work", "Work", "max", tool="claude")],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )

    tray = _build_dual_indicator(codex_registry, claude_registry)
    tray._apply_snapshot(
        claude_registry.list()[0],
        AccountSnapshot(HealthStatus.OK, 55, 66),
        generation=None,
    )

    claude_menu = tray.menu.items[1].submenu
    assert claude_menu.items[0].label == (
        "🔓 Work · max · work@example.com\n"
        "5h [#########-----------] 45% left\n"
        "7d [#######-------------] 34% left"
    )


def test_dual_menu_show_refreshes_codex_and_claude_and_writes_separate_caches() -> None:
    codex_registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    claude_registry = _FakeRegistry(
        accounts=[_account("work", "Work", "max", tool="claude")],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 12, 34),
        Path("/tmp/work"): AccountSnapshot(HealthStatus.BROKEN, None, None),
    }

    tray = _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        fetch_results=fetch_results,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )
    health_client = tray.health_client

    assert health_client.fetch_calls == []
    assert [account.tray_key for account in tray._codex_provider_health.fetch_calls] == ["codex:rafa"]
    assert [account.tray_key for account in tray._claude_provider_health.fetch_calls] == ["claude:work"]
    codex_persisted = tray._health_store.write_calls[-1]["rafa"]
    assert codex_persisted.status == HealthStatus.OK
    assert codex_persisted.primary_used_pct == 12
    assert codex_persisted.secondary_used_pct == 34
    claude_persisted = tray._claude_health_store.write_calls[-1]["work"]
    assert claude_persisted.status == HealthStatus.BROKEN


def test_dual_menu_dispatches_same_slug_actions_by_provider() -> None:
    codex_registry = _FakeRegistry(
        accounts=[_account("shared", "Codex Shared")],
        default_slug="shared",
    )
    claude_registry = _FakeRegistry(
        accounts=[_account("shared", "Claude Shared", tool="claude")],
        default_slug="shared",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    provider_services, _codex_health, _claude_health, codex_lifecycle, claude_lifecycle = (
        _provider_service_map(codex_registry, claude_registry)
    )

    tray = _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        provider_services=provider_services,
    )

    codex_manage = tray.menu.items[0].submenu.items[2].submenu
    claude_manage = tray.menu.items[1].submenu.items[2].submenu

    codex_manage.items[5].emit("activate")
    claude_manage.items[5].emit("activate")

    assert [account.tray_key for account in codex_lifecycle.reauthenticate_calls] == ["codex:shared"]
    assert [account.tray_key for account in claude_lifecycle.reauthenticate_calls] == ["claude:shared"]


def test_provider_service_health_fetch_uses_account_tool_for_same_slug_accounts() -> None:
    codex_registry = _FakeRegistry(
        accounts=[_account("shared", "Codex Shared")],
        default_slug="shared",
    )
    claude_registry = _FakeRegistry(
        accounts=[_account("shared", "Claude Shared", tool="claude")],
        default_slug="shared",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    provider_services, codex_health, claude_health, _codex_lifecycle, _claude_lifecycle = (
        _provider_service_map(
            codex_registry,
            claude_registry,
            codex_fetch_results={"codex:shared": AccountSnapshot(HealthStatus.OK, 10, 20)},
            claude_fetch_results={"claude:shared": AccountSnapshot(HealthStatus.BROKEN, None, None)},
        )
    )

    _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        provider_services=provider_services,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )

    assert [account.tray_key for account in codex_health.fetch_calls] == ["codex:shared"]
    assert [account.tray_key for account in claude_health.fetch_calls] == ["claude:shared"]


def test_merge_snapshot_keeps_previous_usage_timestamp_but_current_auth_state() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray, health_client = _build_indicator_with_refresh(
        registry,
        store_snapshots={
            "rafa": AccountSnapshot(
                HealthStatus.OK,
                11,
                22,
                primary_reset_at=9_500.0,
                secondary_reset_at=9_600.0,
                checked_at=1_000.0,
            )
        },
        time_values=[1000.0, 4601.0, 4602.0],
    )
    health_client.fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.UNKNOWN, None, None, checked_at=4_602.0)
    }
    health_client.fetch_calls.clear()
    tray.last_fetched["codex:rafa"] = 1000.0

    tray._refresh_all(force=False)

    merged = tray.snapshots["codex:rafa"]
    assert merged.status == HealthStatus.UNKNOWN
    assert merged.primary_used_pct == 11
    assert merged.secondary_used_pct == 22
    assert merged.primary_reset_at == 9_500.0
    assert merged.secondary_reset_at == 9_600.0
    assert merged.checked_at == 1_000.0


def test_merge_snapshot_drops_previous_usage_from_an_elapsed_window() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray, health_client = _build_indicator_with_refresh(
        registry,
        store_snapshots={
            "rafa": AccountSnapshot(
                HealthStatus.OK,
                11,
                97,
                primary_reset_at=9_500.0,
                secondary_reset_at=2_500.0,
                checked_at=1_000.0,
            )
        },
        time_values=[1000.0, 4601.0, 4602.0],
    )
    health_client.fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.UNKNOWN, None, None, checked_at=4_602.0)
    }
    health_client.fetch_calls.clear()
    tray.last_fetched["codex:rafa"] = 1000.0

    tray._refresh_all(force=False)

    merged = tray.snapshots["codex:rafa"]
    assert merged.primary_used_pct == 11
    assert merged.secondary_used_pct is None
    assert merged.secondary_reset_at is None


def test_merge_snapshot_does_not_keep_previous_broken_status_on_unknown_probe() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray, _health_client = _build_indicator_with_refresh(registry)
    tray.snapshots["codex:rafa"] = AccountSnapshot(
        HealthStatus.BROKEN,
        None,
        None,
        checked_at=1_000.0,
    )

    merged = tray._merge_snapshot(
        "codex:rafa",
        AccountSnapshot(HealthStatus.UNKNOWN, 11, 22, checked_at=2_000.0),
    )

    assert merged is not None
    assert merged.status == HealthStatus.UNKNOWN


def test_merge_snapshot_accepts_older_cache_provenance_timestamp() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray, _health_client = _build_indicator_with_refresh(registry)
    tray.snapshots["codex:rafa"] = AccountSnapshot(
        HealthStatus.OK,
        11,
        22,
        checked_at=2_000.0,
    )

    merged = tray._merge_snapshot(
        "codex:rafa",
        AccountSnapshot(HealthStatus.UNKNOWN, None, None, checked_at=1_000.0),
    )

    assert merged is not None
    assert merged.checked_at == 1_000.0


# ---------------------------------------------------------------------------
# Session limit label formatting
# ---------------------------------------------------------------------------


def test_codex_account_label_includes_limit_reset_countdowns() -> None:
    import indicator

    assert indicator.Indicator._account_label(
        _account("rafa", "Rafa"),
        AccountSnapshot(
            HealthStatus.OK,
            12,
            34,
            primary_reset_at=1180.0,
            secondary_reset_at=1000.0 + (2 * 86400) + (3 * 3600),
        ),
        show_health=True,
        now=1000.0,
    ) == (
        "Rafa · plus\n"
        "5h [##################--] 88% left (resets in 3m)\n"
        "7d [#############-------] 66% left (resets in 2d3h)"
    )


def test_codex_account_label_is_plain_without_cached_or_live_limits() -> None:
    import indicator

    assert indicator.Indicator._account_label(
        _account("rafa", "Rafa"),
        AccountSnapshot(HealthStatus.OK, None, None),
        show_health=True,
    ) == "Rafa · plus"
    assert indicator.Indicator._account_label(
        _account("rafa", "Rafa"),
        AccountSnapshot(HealthStatus.UNKNOWN, None, None),
        show_health=True,
    ) == "Rafa · plus"
    assert (
        indicator.Indicator._account_label(
            _account("rafa", "Rafa"),
            None,
            show_health=True,
        )
        == "Rafa · plus"
    )


def test_account_label_renders_session_limit_progress_rows() -> None:
    import indicator

    label = indicator.Indicator._account_label(
        _account("rafa", "Rafa"),
        AccountSnapshot(HealthStatus.OK, 55, 100, 11800.0, 561600.0),
        now=10000.0,
    )

    assert label == (
        "Rafa · plus\n"
        "5h [#########-----------] 45% left (resets in 30m)\n"
        "7d [--------------------] 0% left (resets in 6d9h)"
    )


def test_account_label_renders_only_available_windows_for_partial_snapshot() -> None:
    import indicator

    label = indicator.Indicator._account_label(
        _account("rafa", "Rafa"),
        AccountSnapshot(HealthStatus.OK, 55, None, None, None),
    )

    assert label == "Rafa · plus\n5h [#########-----------] 45% left"


def test_update_account_menu_item_uses_widget_set_label() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray = _build_indicator(registry)
    tray.snapshots["codex:rafa"] = AccountSnapshot(HealthStatus.OK, 90, 48)

    tray._update_account_menu_item(registry.list()[0])

    assert tray.menu.items[0].set_label_calls[-1] == (
        "🔓 Rafa · plus\n5h [##------------------] 10% left\n7d [##########----------] 52% left"
    )


# ---------------------------------------------------------------------------
# Default-account activation / notifications
# ---------------------------------------------------------------------------


def test_notify_uses_systray_ai_display_name(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy", "pro")],
        default_slug="rafa",
    )
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    roy_item = tray.menu.items[1]
    roy_item.set_active(True)
    roy_item.emit("activate")

    assert registry.set_default_calls == ["roy"]
    assert tray.indicator.title == "Codex: Roy (pro)"
    assert notifications == [["notify-send", "Systray AI", "Default account set to Roy"]]


def test_activate_inactive_item_is_ignored(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator

    def _fail(*_args, **_kwargs):
        raise AssertionError("should not notify")

    monkeypatch.setattr(indicator.subprocess, "run", _fail)

    tray = _build_indicator(registry)
    roy_item = tray.menu.items[1]
    roy_item.set_active(False)
    roy_item.emit("activate")

    assert registry.set_default_calls == []
    assert tray.indicator.title == "Codex: Rafa (plus)"


def test_activate_broken_account_routes_to_repair_hook(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    tray = _build_indicator(registry)
    tray.snapshots["codex:roy"] = AccountSnapshot(HealthStatus.BROKEN, None, None)
    repaired: list[str] = []
    tray._repair_account = lambda account: repaired.append(account.slug)

    roy_item = tray.menu.items[1]
    roy_item.set_active(True)
    roy_item.emit("activate")

    assert repaired == ["roy"]
    assert registry.set_default_calls == []


def test_refresh_login_menu_item_repairs_account_without_switching_default(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    tray = _build_indicator(registry)
    repaired: list[str] = []
    tray._repair_account = lambda account: repaired.append(account.slug)

    manage_item = next(
        item for item in tray.menu.items if getattr(item, "label", None) == "Manage accounts"
    )
    manage_menu = manage_item.submenu
    refresh_roy_item = manage_menu.items[12]
    refresh_roy_item.emit("activate")

    assert repaired == ["roy"]
    assert registry.set_default_calls == []
    assert registry.default_slug() == "rafa"
    assert tray.menu.items[0].get_active() is True
    assert tray.menu.items[1].get_active() is False


def test_notify_missing_binary_is_swallowed(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator

    def missing_notify(*args, **kwargs):
        raise FileNotFoundError("notify-send")

    monkeypatch.setattr(indicator.subprocess, "run", missing_notify)

    tray = _build_indicator(registry)
    roy_item = tray.menu.items[1]
    roy_item.set_active(True)
    roy_item.emit("activate")

    assert registry.set_default_calls == ["roy"]


def test_apply_snapshot_notifies_once_and_sets_warning_icon_for_default_account(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)

    tray._apply_snapshot(
        registry.list()[0],
        AccountSnapshot(HealthStatus.OK, 86, 10),
        generation=None,
    )

    assert len(notifications) == 1
    assert "5h" in notifications[0][2]
    assert tray.indicator.icon_full_calls[-1][1] == "Usage limit warning (!) 14% left"
    assert tray.indicator.icon_full_calls[-1][0] == tray._warning_icon_name
    assert tray.indicator.icon_theme_path_calls[-1] == str(tray._warning_icon_theme_path)
    warning_icon = _read_warning_icon(tray)
    assert 'viewBox="0 0 24 24"' in warning_icon
    assert ">14%<" in warning_icon
    assert "(!)" not in warning_icon


def test_apply_snapshot_ignores_usage_from_an_elapsed_window(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    snapshot = AccountSnapshot(HealthStatus.OK, None, 97, secondary_reset_at=5_000.0)

    tray._time = lambda: 2_000.0
    tray._apply_snapshot(registry.list()[0], snapshot, generation=None)
    tray._time = lambda: 6_000.0
    tray._apply_snapshot(registry.list()[0], snapshot, generation=None)

    assert [notification[2] for notification in notifications] == [
        "Codex account 'Rafa' is under 5% remaining on its 7d limit (3% left)",
        "Codex account 'Rafa' 7d limit reset — 100% available",
    ]
    assert tray.indicator.icon_full_calls[-1] == (
        indicator.APPINDICATOR_ICON,
        indicator.APP_DISPLAY_NAME,
    )


def test_apply_snapshot_suppresses_exhaustion_notification_after_restart(monkeypatch, tmp_path: Path) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    store_path = tmp_path / "quota_notifications.json"
    snapshot = AccountSnapshot(HealthStatus.OK, 100, None, primary_reset_at=4_100_000_000.0)

    first = _build_indicator(registry)
    first._quota_notification_store = QuotaNotificationStore(store_path)
    first._apply_snapshot(registry.list()[0], snapshot, generation=None)

    second = _build_indicator(registry)
    second._quota_notification_store = QuotaNotificationStore(store_path)
    second._apply_snapshot(registry.list()[0], snapshot, generation=None)

    assert [
        notification[2]
        for notification in notifications
        if notification[2] == "Codex account 'Rafa' has 0% left on its 5h limit"
    ] == ["Codex account 'Rafa' has 0% left on its 5h limit"]


def test_apply_snapshot_deduplicates_limit_warning_notifications(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    account = registry.list()[0]

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 86, 10), generation=None)

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 90, 20), generation=None)

    assert len(notifications) == 1


def test_apply_snapshot_health_flap_does_not_rewarn(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    account = registry.list()[0]

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 86, 10), generation=None)
    assert len(notifications) == 1

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.BROKEN, None, None), generation=None)

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 86, 10), generation=None)
    assert len(notifications) == 1


def test_apply_snapshot_warns_once_until_the_window_resets(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    account = registry.list()[0]

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 96), generation=None)
    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 97), generation=None)
    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, None), generation=None)
    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 98), generation=None)

    assert [notification[2] for notification in notifications] == [
        "Codex account 'Rafa' is under 5% remaining on its 7d limit (4% left)"
    ]

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 0), generation=None)
    assert notifications[-1][2] == "Codex account 'Rafa' 7d limit reset — 100% available"

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 96), generation=None)
    assert notifications[-1][2] == "Codex account 'Rafa' is under 5% remaining on its 7d limit (4% left)"
    assert len(notifications) == 3


def test_apply_snapshot_announces_a_reset_that_happened_while_the_tray_was_down(
    monkeypatch,
) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    store = QuotaNotificationStore(Path(tempfile.mkdtemp()) / "quota_notifications.json")
    tray = _build_indicator(registry, quota_notification_store=store)
    account = registry.list()[0]
    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 96), generation=None)
    assert len(notifications) == 1

    restarted = _build_indicator(registry, quota_notification_store=store)
    restarted._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 0), generation=None)
    assert notifications[-1][2] == "Codex account 'Rafa' 7d limit reset — 100% available"

    restarted._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 1), generation=None)
    restarted._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 96), generation=None)
    assert notifications[-1][2] == "Codex account 'Rafa' is under 5% remaining on its 7d limit (4% left)"
    assert len(notifications) == 3


def test_apply_snapshot_missing_window_reading_does_not_rewarn(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    account = registry.list()[0]

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 96), generation=None)
    assert len(notifications) == 1

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, None), generation=None)
    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 50, 96), generation=None)

    assert len(notifications) == 1


def test_apply_snapshot_recovery_announces_the_reset_and_clears_warning_icon(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    account = registry.list()[0]
    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 86, 10), generation=None)
    notifications.clear()

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 40, 20), generation=None)

    assert [notification[2] for notification in notifications] == [
        "Codex account 'Rafa' 5h limit reset — 60% available"
    ]
    assert tray.indicator.icon_full_calls[-1][0] == indicator.APPINDICATOR_ICON


def test_apply_snapshot_ignores_non_default_account_limit_breach(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(registry)
    icon_call_count = len(tray.indicator.icon_full_calls)

    tray._apply_snapshot(
        registry.list()[1],
        AccountSnapshot(HealthStatus.OK, 86, 10),
        generation=None,
    )

    assert notifications == []
    assert len(tray.indicator.icon_full_calls) == icon_call_count


def test_update_warning_icon_without_claude_registry_does_not_raise() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray = _build_indicator(registry)

    tray._update_warning_icon()

    assert tray.indicator is not None


def test_activate_updates_warning_icon_synchronously_when_default_switches(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    tray = _build_indicator(
        registry,
        store_snapshots={
            "rafa": AccountSnapshot(HealthStatus.OK, 10, 10),
            "roy": AccountSnapshot(HealthStatus.OK, 86, 10),
        },
    )
    icon_call_count = len(tray.indicator.icon_full_calls)

    roy_item = tray.menu.items[1]
    roy_item.set_active(True)
    roy_item.emit("activate")

    assert len(tray.indicator.icon_full_calls) == icon_call_count + 1
    assert tray.indicator.icon_full_calls[-1][1] == "Usage limit warning (!) 14% left"
    warning_icon = _read_warning_icon(tray)
    assert 'viewBox="0 0 24 24"' in warning_icon
    assert ">14%<" in warning_icon
    assert "(!)" not in warning_icon
    assert registry.set_default_calls == ["roy"]


def test_apply_snapshot_updates_warning_icon_when_remaining_percent_changes(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    tray = _build_indicator(registry)
    account = registry.list()[0]

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 86, 10), generation=None)
    first_icon_call_count = len(tray.indicator.icon_full_calls)
    first_warning_icon = _read_warning_icon(tray)
    assert 'viewBox="0 0 24 24"' in first_warning_icon
    assert ">14%<" in first_warning_icon
    assert "(!)" not in first_warning_icon

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 97, 10), generation=None)

    assert len(tray.indicator.icon_full_calls) == first_icon_call_count + 1
    assert tray.indicator.icon_full_calls[-1][1] == "Usage limit warning (!) 3% left"
    second_warning_icon = _read_warning_icon(tray)
    assert 'viewBox="0 0 24 24"' in second_warning_icon
    assert ">3%<" in second_warning_icon
    assert "(!)" not in second_warning_icon


def test_apply_snapshot_uses_larger_text_for_single_digit_warning_percent(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    tray = _build_indicator(registry)
    account = registry.list()[0]

    tray._apply_snapshot(account, AccountSnapshot(HealthStatus.OK, 99, 10), generation=None)

    warning_icon = _read_warning_icon(tray)
    assert ">1%<" in warning_icon
    assert 'font-size="6.2"' in warning_icon


# ---------------------------------------------------------------------------
# Health refresh scheduling / staleness
# ---------------------------------------------------------------------------


def test_menu_show_forces_refetch_when_within_health_stale_seconds() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    clock = [1_000.0]
    tray = _build_indicator(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 10, 20)},
        time_fn=lambda: clock[0],
    )
    tray.health_client.fetch_calls.clear()

    clock[0] += 179
    tray.menu.emit("show")

    assert tray.health_client.fetch_calls == [Path("/tmp/rafa")]


def test_menu_show_refetches_once_past_health_stale_seconds() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    clock = [1_000.0]
    tray = _build_indicator(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 10, 20)},
        time_fn=lambda: clock[0],
    )
    tray.health_client.fetch_calls.clear()

    clock[0] += 181
    tray.menu.emit("show")

    assert tray.health_client.fetch_calls == [Path("/tmp/rafa")]


def test_menu_show_refreshes_and_persists_via_health_store() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray = _build_indicator(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 55, 66)},
    )
    tray.health_client.fetch_calls.clear()
    tray._health_store.write_calls.clear()

    tray.menu.emit("show")

    assert tray.health_client.fetch_calls == [Path("/tmp/rafa")]
    assert tray.menu.items[0].label == (
        "🔓 Rafa · plus\n5h [#########-----------] 45% left\n7d [#######-------------] 34% left"
    )
    persisted = tray._health_store.write_calls[-1]["rafa"]
    assert persisted.status == HealthStatus.OK
    assert persisted.primary_used_pct == 55
    assert persisted.secondary_used_pct == 66
    assert persisted.checked_at is not None


def test_menu_show_lazy_loads_missing_health_writes_cache_and_registers_background_refresh() -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy", "pro")],
        default_slug="rafa",
    )
    fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 12, 34),
        Path("/tmp/roy"): AccountSnapshot(HealthStatus.BROKEN, None, None),
    }

    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results=fetch_results,
        time_values=[1000.0, 1001.0, 1002.0, 1003.0],
    )

    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]
    health_client.fetch_calls.clear()
    tray._health_store.write_calls.clear()
    assert tray.menu.items[0].label == (
        "🔓 Rafa · plus\n5h [##################--] 88% left\n7d [#############-------] 66% left"
    )
    last_fetched_after_initial_refresh = dict(tray.last_fetched)
    tray.menu.emit("show")

    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]
    assert tray.snapshots["codex:rafa"].primary_used_pct == 12
    assert tray.snapshots["codex:rafa"].secondary_used_pct == 34
    assert tray.snapshots["codex:rafa"].checked_at == 1003.0
    assert tray.snapshots["codex:roy"].status == HealthStatus.BROKEN
    assert tray.menu.items[0].label == (
        "🔓 Rafa · plus\n5h [##################--] 88% left\n7d [#############-------] 66% left"
    )
    assert (
        tray.menu.items[0].set_label_calls[-1]
        == "🔓 Rafa · plus\n5h [##################--] 88% left\n7d [#############-------] 66% left"
    )
    assert tray.menu.items[1].label == "🔓 Roy · pro · ⚠ needs re-login"
    persisted = tray._health_store.write_calls[0]
    assert persisted["rafa"].primary_used_pct == 12
    assert persisted["rafa"].secondary_used_pct == 34
    assert persisted["rafa"].checked_at == 1003.0
    assert persisted["roy"].status == HealthStatus.BROKEN
    assert persisted["roy"].checked_at == 1003.0
    assert tray.last_fetched == last_fetched_after_initial_refresh
    assert len(_FakeGtk.timeout_calls) == 1
    assert _FakeGtk.timeout_calls[0][0] == 60


def test_menu_show_forces_refresh_for_all_accounts() -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 10, 20),
        Path("/tmp/roy"): AccountSnapshot(HealthStatus.OK, 30, 40),
    }
    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results=fetch_results,
        store_snapshots={
            "rafa": AccountSnapshot(HealthStatus.OK, 10, 20),
            "roy": AccountSnapshot(HealthStatus.OK, 30, 40),
        },
        time_values=[1000.0, 4601.0, 4602.0],
    )
    tray.last_fetched["codex:rafa"] = 1000.0
    tray.last_fetched["codex:roy"] = 4600.0

    health_client.fetch_calls.clear()
    tray._health_store.write_calls.clear()
    tray.menu.emit("show")

    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]
    assert tray.snapshots["codex:rafa"].primary_used_pct == 10
    assert tray.snapshots["codex:rafa"].secondary_used_pct == 20
    assert tray.snapshots["codex:rafa"].checked_at == 4602.0
    assert tray.snapshots["codex:roy"].primary_used_pct == 30
    assert tray.snapshots["codex:roy"].secondary_used_pct == 40
    assert len(tray._health_store.write_calls) == 1


def test_unknown_refresh_keeps_prior_ok_snapshot() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray, health_client = _build_indicator_with_refresh(
        registry,
        store_snapshots={"rafa": AccountSnapshot(HealthStatus.OK, 11, 22)},
        time_values=[1000.0, 4601.0, 4602.0],
    )
    health_client.fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.UNKNOWN, None, None)
    }
    health_client.fetch_calls.clear()
    tray.last_fetched["codex:rafa"] = 1000.0

    tray._refresh_all(force=False)

    assert health_client.fetch_calls == [Path("/tmp/rafa")]
    assert tray.snapshots["codex:rafa"].status == HealthStatus.UNKNOWN
    assert tray.snapshots["codex:rafa"].primary_used_pct == 11
    assert tray.snapshots["codex:rafa"].secondary_used_pct == 22
    assert tray.menu.items[0].label == (
        "🔓 Rafa · plus\n5h [##################--] 89% left\n7d [################----] 78% left"
    )
    assert tray.last_fetched["codex:rafa"] == 4602.0


def test_broken_refresh_clears_cached_limits_and_flags_relogin() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray, health_client = _build_indicator_with_refresh(
        registry,
        store_snapshots={"rafa": AccountSnapshot(HealthStatus.OK, 11, 22)},
        time_values=[1000.0, 4601.0, 4602.0],
    )
    health_client.fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.BROKEN, None, None)
    }
    health_client.fetch_calls.clear()
    tray.last_fetched["codex:rafa"] = 1000.0

    tray._refresh_all(force=False)

    snapshot = tray.snapshots["codex:rafa"]
    assert snapshot.status == HealthStatus.BROKEN
    assert snapshot.primary_used_pct is None
    assert snapshot.secondary_used_pct is None
    assert tray.menu.items[0].label == "🔓 Rafa · plus · ⚠ needs re-login"


def test_unknown_refresh_keeps_cached_weekly_only_limit_in_menu() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray, health_client = _build_indicator_with_refresh(
        registry,
        store_snapshots={"rafa": AccountSnapshot(HealthStatus.OK, None, 22)},
        time_values=[1000.0, 4601.0, 4602.0],
    )
    health_client.fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.UNKNOWN, None, None)
    }
    health_client.fetch_calls.clear()
    tray.last_fetched["codex:rafa"] = 1000.0

    tray._refresh_all(force=False)

    assert tray.snapshots["codex:rafa"].status == HealthStatus.UNKNOWN
    assert tray.snapshots["codex:rafa"].secondary_used_pct == 22
    assert tray.menu.items[0].label == (
        "🔓 Rafa · plus\n7d [################----] 78% left"
    )


def test_successful_partial_limit_refresh_clears_absent_cached_window() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray, health_client = _build_indicator_with_refresh(
        registry,
        store_snapshots={
            "rafa": AccountSnapshot(
                HealthStatus.OK,
                11,
                22,
                primary_reset_at=1_500.0,
                secondary_reset_at=2_500.0,
            )
        },
        time_values=[1000.0, 4601.0, 4602.0],
    )
    health_client.fetch_results = {
        Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 55, None)
    }
    health_client.fetch_calls.clear()
    tray.last_fetched["codex:rafa"] = 1000.0

    tray._refresh_all(force=False)

    assert health_client.fetch_calls == [Path("/tmp/rafa")]
    assert tray.snapshots["codex:rafa"].status == HealthStatus.OK
    assert tray.snapshots["codex:rafa"].primary_used_pct == 55
    assert tray.snapshots["codex:rafa"].secondary_used_pct is None
    assert tray.snapshots["codex:rafa"].secondary_reset_at is None
    assert tray.menu.items[0].label == "🔓 Rafa · plus\n5h [#########-----------] 45% left"


def test_force_refresh_fetches_even_recent_accounts() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    fetch_results = {Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)}

    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results=fetch_results,
        time_values=[1000.0, 1001.0, 1005.0, 1006.0],
    )
    health_client.fetch_calls.clear()

    tray._refresh_all(force=True)

    assert health_client.fetch_calls == [Path("/tmp/rafa")]


def test_timer_tick_does_not_refetch_recently_fetched_account() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    fetch_results = {Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)}

    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results=fetch_results,
        time_values=[1000.0, 1001.0, 1005.0, 1006.0],
    )
    health_client.fetch_calls.clear()

    timer_callback = _FakeGtk.timeout_calls[0][1]
    keep_running = timer_callback()

    assert keep_running is True
    assert health_client.fetch_calls == []


def test_menu_show_still_forces_refresh_of_recently_fetched_account() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    fetch_results = {Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)}

    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results=fetch_results,
        time_values=[1000.0, 1001.0, 1005.0, 1006.0],
    )
    health_client.fetch_calls.clear()

    tray.menu.emit("show")

    assert health_client.fetch_calls == [Path("/tmp/rafa")]


# ---------------------------------------------------------------------------
# Popup dashboard
# ---------------------------------------------------------------------------


def test_open_dashboard_menu_item_opens_popup_dashboard() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    popups: list[_FakePopupWindow] = []

    def popup_factory(**kwargs) -> _FakePopupWindow:
        popup = _FakePopupWindow(**kwargs)
        popups.append(popup)
        return popup

    tray = _build_indicator(registry, popup_window_factory=popup_factory)

    dashboard_item = next(
        item for item in tray.menu.items if getattr(item, "label", None) == "Open dashboard"
    )
    dashboard_item.emit("activate")

    assert popups and popups[0].is_visible is True and popups[0].is_closed is False
    assert popups[0].auto_close is False
    assert popups[0].transient_for is tray.menu
    assert isinstance(tray._dashboard, _FakeDashboard)
    assert popups[0].content_area.children == [tray._dashboard.widget]


def test_indicator_activate_toggles_popup_dashboard() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    popups: list[_FakePopupWindow] = []

    def popup_factory(**kwargs) -> _FakePopupWindow:
        popup = _FakePopupWindow(**kwargs)
        popups.append(popup)
        return popup

    tray = _build_indicator(registry, popup_window_factory=popup_factory)
    activate_handlers = [
        callback for signal, callback in tray.indicator.connect_calls if signal == "activate"
    ]
    assert len(activate_handlers) == 1
    toggle = activate_handlers[0]

    toggle()
    assert popups and popups[0].is_visible is True and popups[0].is_closed is False

    toggle()
    assert popups[0].is_closed is True


def test_popup_open_forces_refresh_even_when_cache_is_fresh() -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    popups: list[_FakePopupWindow] = []

    def popup_factory(**kwargs) -> _FakePopupWindow:
        popup = _FakePopupWindow(**kwargs)
        popups.append(popup)
        return popup

    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={
            Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2),
            Path("/tmp/roy"): AccountSnapshot(HealthStatus.OK, 3, 4),
        },
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=popup_factory,
    )

    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]
    health_client.fetch_calls.clear()

    tray._time = lambda: 1100.0
    tray._open_popup()
    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]
    tray.popup.close()

    health_client.fetch_calls.clear()
    tray._time = lambda: 1500.0
    tray._open_popup()
    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]
    assert popups and popups[0].is_visible is True


def test_popup_builds_dashboard_once_and_rebuild_routes_to_dashboard_update() -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    popups: list[_FakePopupWindow] = []

    def popup_factory(**kwargs) -> _FakePopupWindow:
        popup = _FakePopupWindow(**kwargs)
        popups.append(popup)
        return popup

    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={
            Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2),
            Path("/tmp/roy"): AccountSnapshot(HealthStatus.BROKEN, None, None),
        },
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=popup_factory,
    )
    tray._time = lambda: 1100.0

    tray._open_popup()
    dashboard = tray._dashboard
    assert isinstance(dashboard, _FakeDashboard)
    assert len(popups) == 1
    assert popups[0].content_area.children == [dashboard.widget]
    update_count = len(dashboard.update_calls)
    assert update_count > 0

    vm = dashboard.update_calls[-1]
    assert [tab.id for tab in vm.tabs] == ["alerts", "codex"]
    assert [account.alias for account in vm.providers[0].accounts] == ["Rafa", "Roy"]
    assert vm.alerts.alerts[0].kind == "broken"
    assert vm.alerts.alerts[0].slug == "roy"

    health_client.fetch_calls.clear()
    popups[0].close()
    tray._open_popup()
    assert tray._dashboard is dashboard
    assert popups[0].content_area.children == [dashboard.widget]
    assert len(dashboard.update_calls) > update_count

    tray._rebuild_popup()
    assert len(dashboard.update_calls) > update_count + 1


def test_popup_open_shows_active_tab_from_vm() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)},
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
    )

    tray._open_popup()
    assert tray._dashboard.shown_tabs == ["codex"]

    tray.snapshots["codex:rafa"] = AccountSnapshot(HealthStatus.BROKEN, None, None)
    tray.popup.close()
    tray._open_popup()
    assert tray._dashboard.shown_tabs[-1] == "alerts"


def test_dashboard_reload_callback_dispatches_all_provider_and_single_account() -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )

    tray, health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={
            Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2),
            Path("/tmp/roy"): AccountSnapshot(HealthStatus.OK, 3, 4),
        },
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
    )
    tray._time = lambda: 1100.0
    tray._open_popup()
    callbacks = tray._dashboard.callbacks

    health_client.fetch_calls.clear()
    health_client.fetch_results[Path("/tmp/rafa")] = AccountSnapshot(HealthStatus.OK, 11, 22)
    callbacks.on_reload("codex", "rafa")
    assert health_client.fetch_calls == [Path("/tmp/rafa")]
    assert tray.snapshots["codex:rafa"].primary_used_pct == 11
    vm = tray._dashboard.update_calls[-1]
    rafa_vm = vm.providers[0].accounts[0]
    assert rafa_vm.primary.percent_text == "89% left"

    health_client.fetch_calls.clear()
    callbacks.on_reload("codex", None)
    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]

    health_client.fetch_calls.clear()
    callbacks.on_reload(None, None)
    assert health_client.fetch_calls == [Path("/tmp/rafa"), Path("/tmp/roy")]


def test_dashboard_action_callbacks_resolve_accounts_by_tool_and_slug(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    tray = _build_indicator(
        registry,
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
    )
    tray._open_popup()
    callbacks = tray._dashboard.callbacks
    repaired: list[str] = []
    tray._repair_account = lambda account: repaired.append(account.tray_key)
    added: list[tuple[str, str]] = []
    tray._add_account_for_tool = lambda tool, alias, login_hint=None, slug=None: added.append(
        (tool, alias)
    )
    tray._prompt_account_slug = lambda tool: "new-account"

    callbacks.on_set_default("codex", "roy")
    assert registry.set_default_calls == ["roy"]

    callbacks.on_repair("codex", "rafa")
    assert repaired == ["codex:rafa"]

    callbacks.on_repair("codex", "missing")
    assert repaired == ["codex:rafa"]

    callbacks.on_add("codex")
    assert added == [("codex", "new-account")]


def test_dashboard_dismissal_hides_alert_until_indicator_restarts() -> None:
    registry = _FakeRegistry(accounts=[_account("roy", "Roy")], default_slug="roy")
    tray, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/roy"): AccountSnapshot(HealthStatus.BROKEN, None, None)},
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
    )
    tray._time = lambda: 1100.0
    tray._open_popup()
    dashboard = tray._dashboard
    assert dashboard is not None

    dashboard.callbacks.on_dismiss_alert("broken", "codex", "roy")

    assert tray.popup.resize_to_content_calls == 1
    assert tray._dashboard is dashboard
    assert dashboard.update_calls[-1].alerts.alerts == ()
    assert tray._build_dashboard_vm().alerts.alerts == ()

    replacement, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/roy"): AccountSnapshot(HealthStatus.BROKEN, None, None)},
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
    )
    assert [(alert.kind, alert.slug) for alert in replacement._build_dashboard_vm().alerts.alerts] == [
        ("broken", "roy")
    ]


def test_dashboard_open_settings_callback_opens_dialog_and_save_applies() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    store = _FakeSettingsStore()
    _FakeSettingsDialog.instances = []

    tray, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)},
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
        settings_store=store,
    )
    tray._open_popup()

    tray._dashboard.callbacks.on_open_settings()
    assert len(_FakeSettingsDialog.instances) == 1
    dialog = _FakeSettingsDialog.instances[0]
    assert dialog.opened is True
    assert dialog.settings == _FakeSettings(last_tab="codex")

    new_settings = _FakeSettings(auto_close=True, refresh_interval_seconds=120)
    dialog.simulate_save(new_settings)

    assert store.write_calls == [_FakeSettings(last_tab="codex"), new_settings]
    assert tray._settings == new_settings
    assert tray.popup.set_auto_close_calls == [True]


def test_refresh_all_timer_keeps_running_after_a_failed_tick() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)},
    )
    logged: list[Exception] = []
    tray._log_exception = logged.append
    tray._refresh_all = _raise_cannot_start_thread

    assert tray._refresh_all_timer() is True
    assert [type(exc) for exc in logged] == [RuntimeError]


def _raise_cannot_start_thread(force: bool = False) -> None:
    raise RuntimeError("can't start new thread")


def test_apply_settings_reschedules_health_timer_and_rebuilds_popup() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    _FakeGLib.source_remove_calls = []

    tray, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)},
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
    )
    assert _FakeGtk.timeout_calls[0][0] == 60
    first_timer_id = tray._health_timer_id
    assert first_timer_id is not None

    tray._open_popup()
    update_count = len(tray._dashboard.update_calls)

    tray._apply_settings(_FakeSettings(refresh_interval_seconds=120))

    assert _FakeGLib.source_remove_calls == [first_timer_id]
    assert _FakeGtk.timeout_calls[-1][0] == 120
    assert tray._health_timer_id != first_timer_id
    assert len(tray._dashboard.update_calls) == update_count + 1


def test_popup_applies_theme_from_settings(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    monkeypatch.setattr(
        "ui.theme.detect_system_dark",
        lambda: True,
    )
    tray = _build_indicator(
        registry,
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
        settings_store=_FakeSettingsStore(_FakeSettings(theme="system")),
    )
    tray._open_popup()
    assert tray._dashboard.theme_calls[-1] == "dark"

    tray._apply_settings(_FakeSettings(theme="light"))
    assert tray._dashboard.theme_calls[-1] == "light"

    tray._apply_settings(_FakeSettings(theme="dark"))
    assert tray._dashboard.theme_calls[-1] == "dark"


def test_popup_auto_close_comes_from_persisted_settings() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    popups: list[_FakePopupWindow] = []

    def popup_factory(**kwargs) -> _FakePopupWindow:
        popup = _FakePopupWindow(**kwargs)
        popups.append(popup)
        return popup

    tray = _build_indicator(
        registry,
        popup_window_factory=popup_factory,
        settings_store=_FakeSettingsStore(_FakeSettings(auto_close=True)),
    )
    tray._open_popup()

    assert popups[0].auto_close is True


def test_tab_change_persists_last_tab_once_and_feeds_active_tab() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    store = _FakeSettingsStore()

    tray, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.OK, 1, 2)},
        time_values=[1000.0, 1001.0, 1002.0],
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
        settings_store=store,
    )
    tray._open_popup()
    callbacks = tray._dashboard.callbacks

    callbacks.on_tab_changed("codex")
    assert [settings.last_tab for settings in store.write_calls] == ["codex"]
    assert tray._settings.last_tab == "codex"

    callbacks.on_tab_changed("codex")
    assert len(store.write_calls) == 1

    assert tray._build_dashboard_vm().active_tab == "codex"


def test_popup_open_on_alerts_tab_does_not_clobber_last_tab() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    store = _FakeSettingsStore(_FakeSettings(last_tab="codex"))

    tray, _health_client = _build_indicator_with_refresh(
        registry,
        fetch_results={Path("/tmp/rafa"): AccountSnapshot(HealthStatus.BROKEN, None, None)},
        popup_window_factory=lambda **kwargs: _FakePopupWindow(**kwargs),
        settings_store=store,
    )
    tray._open_popup()

    assert tray._dashboard.shown_tabs == ["alerts"]
    assert store.write_calls == []
    assert tray._settings.last_tab == "codex"


def test_add_account_reentry_guarded_and_busy_fed_into_vm() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    started: list[object] = []
    tray = _build_indicator(registry, thread_runner=started.append)
    baseline = len(started)

    tray._add_account_for_tool("codex", "New Account")
    tray._add_account_for_tool("codex", "New Account")

    assert len(started) == baseline + 1
    assert tray._build_dashboard_vm().busy_add_tools == frozenset({"codex"})

    tray._set_provider_action_busy("codex", "add_account", False)
    assert tray._build_dashboard_vm().busy_add_tools == frozenset()


def test_check_limit_warning_uses_settings_warning_threshold(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))

    tray = _build_indicator(
        registry,
        settings_store=_FakeSettingsStore(_FakeSettings(warning_threshold_pct=30)),
    )

    tray._apply_snapshot(
        registry.list()[0],
        AccountSnapshot(HealthStatus.OK, 75, 10),
        generation=None,
    )

    assert len(notifications) == 1
    assert "under 30% remaining on its 5h limit (25% left)" in notifications[0][2]


# ---------------------------------------------------------------------------
# Repair / add / rename / remove account flows
# ---------------------------------------------------------------------------


def test_repair_default_account_commits_refreshes_default_and_closes_dialog(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=True,
    )

    tray, dialogs = _build_indicator_with_repair(registry, flow)
    tray._repair_account(registry.list()[0])

    assert flow.start_calls == [(Path("/tmp/rafa"), True)]
    assert flow.commit_calls == [Path("/tmp/rafa")]
    assert flow.rollback_calls == []
    assert registry.set_default_calls == ["rafa"]
    assert dialogs[0].closed_success is True
    assert dialogs[0].failure_retry_available is None
    assert notifications == [["notify-send", "Systray AI", "Re-authenticated Rafa"]]


def test_repair_non_default_account_commits_without_resetting_default(monkeypatch) -> None:
    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa"), _account("roy", "Roy")],
        default_slug="rafa",
    )
    import indicator
    from device_auth import DeviceAuthPrompt

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=True,
    )

    tray, dialogs = _build_indicator_with_repair(registry, flow)
    tray._repair_account(registry.list()[1])

    assert flow.commit_calls == [Path("/tmp/roy")]
    assert flow.rollback_calls == []
    assert registry.set_default_calls == []
    assert dialogs[0].closed_success is True


def test_repair_success_clears_stale_broken_snapshot_and_cache(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=True,
    )

    tray, _dialogs = _build_indicator_with_repair(registry, flow)
    tray.snapshots["codex:rafa"] = AccountSnapshot(HealthStatus.BROKEN, None, None)
    tray._update_account_menu_item(registry.list()[0])

    tray._repair_account(registry.list()[0])

    assert tray.snapshots["codex:rafa"].status == HealthStatus.OK
    assert "needs re-login" not in tray.menu.items[0].label
    persisted = tray._health_store.write_calls[-1]["rafa"]
    assert persisted.status == HealthStatus.OK
    assert persisted.checked_at is not None


def test_repair_failure_rolls_back_without_resetting_default(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=False,
    )

    tray, dialogs = _build_indicator_with_repair(registry, flow)
    tray._repair_account(registry.list()[0])

    assert flow.commit_calls == []
    assert flow.rollback_calls == [Path("/tmp/rafa")]
    assert registry.set_default_calls == []
    assert dialogs[0].closed_success is False
    assert dialogs[0].failure_retry_available is True
    assert notifications == [["notify-send", "Systray AI", "Re-authentication failed for Rafa"]]


def test_add_account_shows_existing_accounts_and_keeps_new_account_on_success(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=True,
    )

    tray, dialogs = _build_indicator_with_repair(registry, flow)
    tray._add_account("Team Account")

    assert registry.new_slug_calls == ["Team Account"]
    assert registry.add_dir_calls == [("team-account", "Team Account")]
    assert dialogs[0].existing_accounts == [("Rafa", "rafa@example.com")]
    assert registry.remove_calls == []
    assert [account.slug for account in registry.list()] == ["rafa", "team-account"]
    assert notifications == [["notify-send", "Systray AI", "Added account Team Account"]]


def test_add_account_collision_removes_new_account_and_marks_existing_account_broken(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=True,
    )

    tray, dialogs = _build_indicator_with_repair(registry, flow)
    original_add_dir = registry.add_dir

    def colliding_add_dir(slug: str, alias: str) -> Path:
        codex_home = original_add_dir(slug, alias)
        registry._accounts[-1] = Account(
            ref=AccountRef("codex", slug),
            alias=alias,
            account_home=codex_home,
            email="duplicate@example.com",
            plan="plus",
            account_id="acct-rafa",
        )
        return codex_home

    registry.add_dir = colliding_add_dir
    tray._add_account("Duplicate")

    assert dialogs[0].existing_accounts == [("Rafa", "rafa@example.com")]
    assert registry.remove_calls == ["duplicate"]
    assert tray.snapshots["codex:rafa"].status == HealthStatus.BROKEN
    assert tray._health_store.write_calls
    assert "🔓 Rafa · plus · ⚠ needs re-login" in [
        item.label for item in tray.menu.items if hasattr(item, "label")
    ]


def test_add_account_failure_removes_new_account_without_rollback(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=False,
    )

    tray, _dialogs = _build_indicator_with_repair(registry, flow)
    tray._add_account("Failed Add")

    assert registry.remove_calls == ["failed-add"]
    assert flow.rollback_calls == []
    assert [account.slug for account in registry.list()] == ["rafa"]
    assert notifications == [["notify-send", "Systray AI", "Adding account Failed Add failed"]]


def test_show_add_dialog_constructs_dialog_on_idle_thread_before_returning() -> None:
    import indicator
    from device_auth import DeviceAuthPrompt

    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    health_client = _FakeHealthClient()
    queued_calls: list[tuple[object, tuple[object, ...]]] = []
    construction_threads: list[str] = []
    returned_dialogs: list[_FakeDialog] = []

    def dialog_factory(prompt, on_retry=None, on_cancel=None, existing_accounts=None):
        construction_threads.append(threading.current_thread().name)
        return _FakeDialog(prompt, on_retry=on_retry, on_cancel=on_cancel, existing_accounts=existing_accounts)

    def idle_add(callback, *args):
        queued_calls.append((callback, args))
        return len(queued_calls)

    tray = indicator.Indicator(
        registry=registry,
        health_client=health_client,
        gtk_module=_FakeGtk,
        appindicator_module=_FakeAppIndicator3,
        health_snapshot_store=_FakeHealthSnapshotStore(),
        settings_store=_FakeSettingsStore(),
        dialog_factory=dialog_factory,
        idle_add=idle_add,
        thread_runner=lambda target: target(),
    )
    prompt = DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt")

    worker = threading.Thread(
        target=lambda: returned_dialogs.append(
            tray._show_add_dialog(prompt, [("Rafa", "rafa@example.com")], _FakeDeviceAuthSession())
        ),
        name="worker-thread",
    )
    worker.start()

    worker.join(timeout=0.05)
    assert worker.is_alive() is True
    assert construction_threads == []
    assert len(queued_calls) == 1

    callback, args = queued_calls.pop(0)
    callback(*args)
    worker.join(timeout=1.0)

    assert worker.is_alive() is False
    assert construction_threads == ["MainThread"]
    assert returned_dialogs[0].existing_accounts == [("Rafa", "rafa@example.com")]


def test_show_repair_dialog_constructs_dialog_on_idle_thread_before_returning() -> None:
    import indicator
    from device_auth import DeviceAuthPrompt

    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    health_client = _FakeHealthClient()
    queued_calls: list[tuple[object, tuple[object, ...]]] = []
    construction_threads: list[str] = []
    returned_dialogs: list[_FakeDialog] = []

    def dialog_factory(prompt, on_retry=None, on_cancel=None, existing_accounts=None):
        construction_threads.append(threading.current_thread().name)
        return _FakeDialog(prompt, on_retry=on_retry, on_cancel=on_cancel, existing_accounts=existing_accounts)

    def idle_add(callback, *args):
        queued_calls.append((callback, args))
        return len(queued_calls)

    tray = indicator.Indicator(
        registry=registry,
        health_client=health_client,
        gtk_module=_FakeGtk,
        appindicator_module=_FakeAppIndicator3,
        health_snapshot_store=_FakeHealthSnapshotStore(),
        settings_store=_FakeSettingsStore(),
        dialog_factory=dialog_factory,
        idle_add=idle_add,
        thread_runner=lambda target: target(),
    )
    prompt = DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt")
    account = registry.list()[0]

    worker = threading.Thread(
        target=lambda: returned_dialogs.append(tray._show_repair_dialog(prompt, account, _FakeDeviceAuthSession())),
        name="worker-thread",
    )
    worker.start()

    worker.join(timeout=0.05)
    assert worker.is_alive() is True
    assert construction_threads == []
    assert len(queued_calls) == 1

    callback, args = queued_calls.pop(0)
    callback(*args)
    worker.join(timeout=1.0)

    assert worker.is_alive() is False
    assert construction_threads == ["MainThread"]
    assert callable(returned_dialogs[0].on_retry)


def test_add_account_dialog_cancel_terminates_device_auth_process(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    monkeypatch.setattr(indicator.subprocess, "run", lambda *args, **kwargs: None)
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
        completion_result=False,
    )

    tray, dialogs = _build_indicator_with_repair(registry, flow)
    tray._add_account("Team Account")

    assert dialogs[0].on_cancel is not None
    session = flow.await_completion_calls[0]
    dialogs[0].on_cancel()

    assert session.process.terminate_calls == 1
    assert session.process.wait_calls == [5.0]
    assert session.process.kill_calls == 0


def test_repair_prompt_failure_terminates_process_and_rolls_back(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(prompt_error=RuntimeError("prompt failed"))

    tray, _dialogs = _build_indicator_with_repair(registry, flow)
    tray._repair_account(registry.list()[0])

    session = flow.read_prompt_calls[0]
    assert session.process.terminate_calls == 1
    assert session.process.wait_calls == [5.0]
    assert flow.rollback_calls == [Path("/tmp/rafa")]
    assert notifications == [["notify-send", "Systray AI", "Re-authentication failed for Rafa: prompt failed"]]


def test_add_prompt_failure_terminates_process_and_removes_new_account(monkeypatch) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(prompt_error=RuntimeError("prompt failed"))

    tray, _dialogs = _build_indicator_with_repair(registry, flow)
    tray._add_account("Broken Add")

    session = flow.read_prompt_calls[0]
    assert session.process.terminate_calls == 1
    assert session.process.wait_calls == [5.0]
    assert registry.remove_calls == ["broken-add"]
    assert notifications == [["notify-send", "Systray AI", "Adding account Broken Add failed: prompt failed"]]


def test_add_account_code_required_shows_dialog_and_submits_code(monkeypatch) -> None:
    import indicator

    codex_registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    claude_account = _account("work", "Work", tool="claude")
    claude_registry = _FakeRegistry(
        accounts=[claude_account],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    session = _FakeClaudeLoginSession(
        process=_FakeAuthProcess(),
        account_home=Path("/tmp/claude-work"),
    )
    prompt = _FakeClaudeLoginPrompt(
        url="https://claude.ai/authorize",
        raw_text="Paste code here if prompted >",
    )
    event = LifecycleEvent(
        kind="code_required",
        account=claude_account,
        prompt=prompt,
        session=session,
    )
    provider_services, _codex_lifecycle, claude_lifecycle = _provider_service_map_with_claude_events(
        codex_registry,
        claude_registry,
        claude_add_events=[event],
    )
    dialogs: list[object] = []

    class _FakeClaudeCodeEntryDialog:
        def __init__(self, url, on_submit=None, on_cancel=None) -> None:
            self.url = url
            self.on_submit = on_submit
            self.on_cancel = on_cancel
            dialogs.append(self)

        def submit(self, text: str) -> None:
            assert self.on_submit is not None
            self.on_submit(text)

        def cancel(self) -> None:
            assert self.on_cancel is not None
            self.on_cancel()

    monkeypatch.setattr(indicator, "ClaudeCodeEntryDialog", _FakeClaudeCodeEntryDialog)
    tray = _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        provider_services=provider_services,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )

    tray._add_account_for_tool("claude", "Claude Add")

    assert dialogs[0].url == "https://claude.ai/authorize"
    dialogs[0].submit(" 123-456 ")

    assert claude_lifecycle.submit_code_calls == [(session, " 123-456 ")]
    assert session.process.terminate_calls == 0


def test_add_account_code_required_cancel_terminates_process(monkeypatch) -> None:
    import indicator

    codex_registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    claude_account = _account("work", "Work", tool="claude")
    claude_registry = _FakeRegistry(
        accounts=[claude_account],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    session = _FakeClaudeLoginSession(
        process=_FakeAuthProcess(),
        account_home=Path("/tmp/claude-work"),
    )
    prompt = _FakeClaudeLoginPrompt(
        url="https://claude.ai/authorize",
        raw_text="Paste code here if prompted >",
    )
    event = LifecycleEvent(
        kind="code_required",
        account=claude_account,
        prompt=prompt,
        session=session,
    )
    provider_services, _codex_lifecycle, claude_lifecycle = _provider_service_map_with_claude_events(
        codex_registry,
        claude_registry,
        claude_add_events=[event],
    )
    dialogs: list[object] = []

    class _FakeClaudeCodeEntryDialog:
        def __init__(self, url, on_submit=None, on_cancel=None) -> None:
            self.url = url
            self.on_submit = on_submit
            self.on_cancel = on_cancel
            dialogs.append(self)

        def submit(self, text: str) -> None:
            assert self.on_submit is not None
            self.on_submit(text)

        def cancel(self) -> None:
            assert self.on_cancel is not None
            self.on_cancel()

    monkeypatch.setattr(indicator, "ClaudeCodeEntryDialog", _FakeClaudeCodeEntryDialog)
    tray = _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        provider_services=provider_services,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )

    tray._add_account_for_tool("claude", "Claude Add")

    assert dialogs[0].url == "https://claude.ai/authorize"
    dialogs[0].cancel()

    assert session.process.terminate_calls == 1
    assert session.process.wait_calls == [5.0]
    assert session.process.kill_calls == 0
    assert claude_lifecycle.submit_code_calls == []


def test_repair_account_code_required_shows_dialog_and_submits_code(monkeypatch) -> None:
    import indicator

    codex_registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    claude_account = _account("work", "Work", tool="claude")
    claude_registry = _FakeRegistry(
        accounts=[claude_account],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    session = _FakeClaudeLoginSession(
        process=_FakeAuthProcess(),
        account_home=Path("/tmp/claude-work"),
    )
    prompt = _FakeClaudeLoginPrompt(
        url="https://claude.ai/authorize",
        raw_text="Paste code here if prompted >",
    )
    event = LifecycleEvent(
        kind="code_required",
        account=claude_account,
        prompt=prompt,
        session=session,
    )
    provider_services, _codex_lifecycle, claude_lifecycle = _provider_service_map_with_claude_events(
        codex_registry,
        claude_registry,
        claude_reauthenticate_events=[event],
    )
    dialogs: list[object] = []

    class _FakeClaudeCodeEntryDialog:
        def __init__(self, url, on_submit=None, on_cancel=None) -> None:
            self.url = url
            self.on_submit = on_submit
            self.on_cancel = on_cancel
            dialogs.append(self)

        def submit(self, text: str) -> None:
            assert self.on_submit is not None
            self.on_submit(text)

        def cancel(self) -> None:
            assert self.on_cancel is not None
            self.on_cancel()

    monkeypatch.setattr(indicator, "ClaudeCodeEntryDialog", _FakeClaudeCodeEntryDialog)
    tray = _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        provider_services=provider_services,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )

    tray._repair_account(claude_account)

    assert dialogs[0].url == "https://claude.ai/authorize"
    dialogs[0].submit(" 123-456 ")

    assert claude_lifecycle.submit_code_calls == [(session, " 123-456 ")]
    assert session.process.terminate_calls == 0


def test_repair_account_code_required_cancel_terminates_process(monkeypatch) -> None:
    import indicator

    codex_registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    claude_account = _account("work", "Work", tool="claude")
    claude_registry = _FakeRegistry(
        accounts=[claude_account],
        default_slug="work",
        base_dir=Path("/tmp/fake-registry-claude"),
        kind=AccountRegistryKind.CLAUDE,
    )
    session = _FakeClaudeLoginSession(
        process=_FakeAuthProcess(),
        account_home=Path("/tmp/claude-work"),
    )
    prompt = _FakeClaudeLoginPrompt(
        url="https://claude.ai/authorize",
        raw_text="Paste code here if prompted >",
    )
    event = LifecycleEvent(
        kind="code_required",
        account=claude_account,
        prompt=prompt,
        session=session,
    )
    provider_services, _codex_lifecycle, claude_lifecycle = _provider_service_map_with_claude_events(
        codex_registry,
        claude_registry,
        claude_reauthenticate_events=[event],
    )
    dialogs: list[object] = []

    class _FakeClaudeCodeEntryDialog:
        def __init__(self, url, on_submit=None, on_cancel=None) -> None:
            self.url = url
            self.on_submit = on_submit
            self.on_cancel = on_cancel
            dialogs.append(self)

        def submit(self, text: str) -> None:
            assert self.on_submit is not None
            self.on_submit(text)

        def cancel(self) -> None:
            assert self.on_cancel is not None
            self.on_cancel()

    monkeypatch.setattr(indicator, "ClaudeCodeEntryDialog", _FakeClaudeCodeEntryDialog)
    tray = _build_indicator(
        codex_registry,
        claude_registry=claude_registry,
        provider_services=provider_services,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )

    tray._repair_account(claude_account)

    assert dialogs[0].url == "https://claude.ai/authorize"
    dialogs[0].cancel()

    assert session.process.terminate_calls == 1
    assert session.process.wait_calls == [5.0]
    assert session.process.kill_calls == 0
    assert claude_lifecycle.submit_code_calls == []


def test_repair_dialog_failure_logs_exception_and_rolls_back(monkeypatch, capsys) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
    )

    def dialog_factory(*_args, **_kwargs):
        raise RuntimeError("dialog failed")

    tray = indicator.Indicator(
        registry=registry,
        health_client=_FakeHealthClient(),
        gtk_module=_FakeGtk,
        appindicator_module=_FakeAppIndicator3,
        health_snapshot_store=_FakeHealthSnapshotStore(),
        settings_store=_FakeSettingsStore(),
        device_auth_flow=flow,
        dialog_factory=dialog_factory,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )
    tray.build()
    tray._repair_account(registry.list()[0])

    session = flow.read_prompt_calls[0]
    assert session.process.terminate_calls == 1
    assert flow.rollback_calls == [Path("/tmp/rafa")]
    assert notifications == [["notify-send", "Systray AI", "Re-authentication failed for Rafa: dialog failed"]]
    assert "RuntimeError: dialog failed" in capsys.readouterr().err


def test_add_dialog_failure_logs_exception_and_removes_new_account(monkeypatch, capsys) -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    import indicator
    from device_auth import DeviceAuthPrompt

    notifications: list[list[str]] = []
    monkeypatch.setattr(indicator.subprocess, "run", lambda argv, check=False: notifications.append(argv))
    flow = _FakeDeviceAuthFlow(
        prompt=DeviceAuthPrompt(url="https://auth.openai.com/codex/device", code="ABCD-EFGH", raw_text="prompt"),
    )

    def dialog_factory(*_args, **_kwargs):
        raise RuntimeError("dialog failed")

    tray = indicator.Indicator(
        registry=registry,
        health_client=_FakeHealthClient(),
        gtk_module=_FakeGtk,
        appindicator_module=_FakeAppIndicator3,
        health_snapshot_store=_FakeHealthSnapshotStore(),
        settings_store=_FakeSettingsStore(),
        device_auth_flow=flow,
        dialog_factory=dialog_factory,
        idle_add=lambda func, *args: func(*args),
        thread_runner=lambda target: target(),
    )
    tray.build()
    tray._add_account("Broken Add")

    session = flow.read_prompt_calls[0]
    assert session.process.terminate_calls == 1
    assert registry.remove_calls == ["broken-add"]
    assert notifications == [["notify-send", "Systray AI", "Adding account Broken Add failed: dialog failed"]]
    assert "RuntimeError: dialog failed" in capsys.readouterr().err


def test_rename_account_confirm_renames_and_rebuilds_menu() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray = _build_indicator(registry)
    _FakeGtk.entry_texts = ["Rafa Work"]
    _FakeGtk.dialog_responses = [_FakeGtk.ResponseType.OK]

    tray._rename_account(registry.list()[0])

    assert registry.rename_calls == [("rafa", "Rafa Work")]
    assert tray.indicator.title == "Codex: Rafa Work (plus)"
    assert tray.menu.items[0].label == "🔓 Rafa Work · plus"


def test_rename_account_moves_the_cached_health_snapshot_to_the_new_slug() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray = _build_indicator(registry)
    snapshot = AccountSnapshot(HealthStatus.OK, 12, 34)
    tray.snapshots["codex:rafa"] = snapshot
    tray.last_fetched["codex:rafa"] = 1_710_000_000.0
    _FakeGtk.entry_texts = ["Rafa Work"]
    _FakeGtk.dialog_responses = [_FakeGtk.ResponseType.OK]

    tray._rename_account(registry.list()[0])

    assert "codex:rafa" not in tray.snapshots
    assert tray.snapshots["codex:rafa-work"] is snapshot
    assert tray.last_fetched["codex:rafa-work"] == 1_710_000_000.0


def test_rename_account_cancel_is_noop() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray = _build_indicator(registry)
    _FakeGtk.entry_texts = ["Ignored"]
    _FakeGtk.dialog_responses = [_FakeGtk.ResponseType.CANCEL]

    tray._rename_account(registry.list()[0])

    assert registry.rename_calls == []
    assert tray.indicator.title == "Codex: Rafa (plus)"
    assert tray.menu.items[0].label == "🔓 Rafa · plus"


def test_remove_account_confirm_removes_and_falls_back_to_unknown_title() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray = _build_indicator(registry)
    _FakeGtk.dialog_responses = [_FakeGtk.ResponseType.YES]

    tray._remove_account(registry.list()[0])

    assert registry.remove_calls == ["rafa"]
    assert tray.indicator.title == "Codex: unknown account"
    assert [item.label for item in tray.menu.items if hasattr(item, "label")] == [
        "Open dashboard",
        "Manage accounts",
        "Quit",
    ]


def test_remove_account_cancel_is_noop() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")

    tray = _build_indicator(registry)
    _FakeGtk.dialog_responses = [_FakeGtk.ResponseType.NO]

    tray._remove_account(registry.list()[0])

    assert registry.remove_calls == []
    assert tray.indicator.title == "Codex: Rafa (plus)"
    assert tray.menu.items[0].label == "🔓 Rafa · plus"


# ---------------------------------------------------------------------------
# Misc controls
# ---------------------------------------------------------------------------


def test_run_enters_gtk_main() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray = _build_indicator(registry)

    tray.run()

    assert _FakeGtk.main_calls == 1


def test_quit_menu_item_calls_gtk_main_quit() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    tray = _build_indicator(registry)

    tray.menu.items[-1].emit("activate")

    assert _FakeGtk.quit_calls == 1


# ---------------------------------------------------------------------------
# PopupWindow (popup_window.py) — generic transient/auto-close popup
# ---------------------------------------------------------------------------


class _FakeTransientParent:
    def __init__(self) -> None:
        self.handlers: dict[str, list[object]] = {}

    def connect(self, signal: str, callback, *args: object):
        self.handlers.setdefault(signal, []).append((callback, args))
        return (signal, callback, args)

    def emit(self, signal: str, *args: object) -> None:
        for callback, callback_args in self.handlers.get(signal, []):
            callback(self, *args, *callback_args)


def test_popup_window_closes_when_focus_leaves_when_transient() -> None:
    import popup_window
    from popup_window import PopupWindow

    parent = _FakeTransientParent()
    popup = PopupWindow(
        "Accounts",
        transient_for=parent,
        auto_close=True,
        gtk_module=popup_window._FallbackGtk,
        gdk_module=popup_window._FallbackGdk,
    )

    popup.show()
    assert popup.is_visible is True
    popup._handle_focus_out()

    assert popup.is_closed is True
    assert popup.is_visible is False


def test_popup_window_keeps_open_when_auto_close_disabled() -> None:
    import popup_window
    from popup_window import PopupWindow

    popup = PopupWindow(
        "Accounts",
        transient_for=None,
        auto_close=False,
        gtk_module=popup_window._FallbackGtk,
        gdk_module=popup_window._FallbackGdk,
    )

    popup.show()
    popup._handle_focus_out()

    assert popup.is_closed is False
    assert popup.is_visible is True


def test_popup_window_closes_when_transient_parent_is_destroyed() -> None:
    import popup_window
    from popup_window import PopupWindow

    parent = _FakeTransientParent()
    popup = PopupWindow(
        "Accounts",
        transient_for=parent,
        auto_close=True,
        gtk_module=popup_window._FallbackGtk,
        gdk_module=popup_window._FallbackGdk,
    )
    popup.show()

    parent.emit("destroy")

    assert popup.is_closed is True


def test_popup_window_auto_closes_only_when_transient_and_auto_close_is_enabled() -> None:
    from popup_window import PopupWindow

    class _GtkWindow:
        def __init__(self) -> None:
            self.title = ""
            self.visible = False
            self.destroyed = False
            self.signals: dict[str, list[tuple[int, object]]] = {}
            self._last_signal_id = 0

        def set_title(self, title: str) -> None:
            self.title = title

        def set_modal(self, _value: bool) -> None:
            pass

        def set_skip_taskbar_hint(self, _value: bool) -> None:
            pass

        def set_keep_above(self, _value: bool) -> None:
            pass

        def set_default_size(self, width: int, height: int) -> None:
            self.default_size = (width, height)

        def set_position(self, _position: int) -> None:
            pass

        def set_destroy_with_parent(self, _value: bool) -> None:
            pass

        def set_transient_for(self, parent: object) -> None:
            self.transient_for = parent

        def connect(self, signal: str, callback) -> int:
            self._last_signal_id += 1
            self.signals.setdefault(signal, []).append((self._last_signal_id, callback))
            return self._last_signal_id

        def disconnect(self, handler_id: int) -> None:
            for signal, callbacks in list(self.signals.items()):
                self.signals[signal] = [(sid, cb) for sid, cb in callbacks if sid != handler_id]

        def add(self, child: object) -> None:
            self.child = child

        def show_all(self) -> None:
            self.visible = True

        def destroy(self) -> None:
            self.destroyed = True
            self.visible = False

    class _GtkBox:
        def __init__(self, *_args, **_kwargs) -> None:
            self.children: list[object] = []

        def add(self, child: object) -> None:
            self.children.append(child)

    class _Gtk:
        class WindowPosition:
            CENTER_ON_PARENT = 1

        Window = _GtkWindow
        Box = _GtkBox

        class Orientation:
            VERTICAL = 1

    parent = _GtkWindow()
    popup = PopupWindow("Test Popup", transient_for=parent, auto_close=True, gtk_module=_Gtk)

    assert popup.transient_for is parent
    assert popup.is_visible is False
    popup.show()
    assert popup.is_visible is True
    popup.simulate_focus_out()
    assert popup.is_closed is True


def test_popup_window_stays_open_when_transient_is_missing_or_auto_close_disabled() -> None:
    from popup_window import PopupWindow

    class _GtkWindow:
        def __init__(self) -> None:
            self.visible = False
            self.destroyed = False
            self.signals: dict[str, list[tuple[int, object]]] = {}
            self._last_signal_id = 0

        def set_title(self, _title: str) -> None:
            pass

        def set_modal(self, _value: bool) -> None:
            pass

        def set_skip_taskbar_hint(self, _value: bool) -> None:
            pass

        def set_keep_above(self, _value: bool) -> None:
            pass

        def set_default_size(self, _width: int, _height: int) -> None:
            pass

        def set_position(self, _position: int) -> None:
            pass

        def set_destroy_with_parent(self, _value: bool) -> None:
            pass

        def set_transient_for(self, _parent: object) -> None:
            pass

        def connect(self, signal: str, callback) -> int:
            self._last_signal_id += 1
            self.signals.setdefault(signal, []).append((self._last_signal_id, callback))
            return self._last_signal_id

        def disconnect(self, _handler_id: int) -> None:
            pass

        def add(self, child: object) -> None:
            self.child = child

        def show_all(self) -> None:
            self.visible = True

        def destroy(self) -> None:
            self.destroyed = True
            self.visible = False

    class _GtkBox:
        def __init__(self, *_args, **_kwargs) -> None:
            self.children: list[object] = []

        def add(self, child: object) -> None:
            self.children.append(child)

    class _Gtk:
        class WindowPosition:
            CENTER_ON_PARENT = 1

        Window = _GtkWindow
        Box = _GtkBox

        class Orientation:
            VERTICAL = 1

    popup = PopupWindow("Static Popup", transient_for=None, auto_close=False, gtk_module=_Gtk)

    popup.show()
    popup.simulate_focus_out()
    assert popup.is_closed is False

    popup.set_transient_for(_GtkWindow())
    popup.simulate_focus_out()
    assert popup.is_closed is False


def test_popup_window_ignores_invalid_transient_parent_and_still_shows() -> None:
    from popup_window import PopupWindow

    class _GtkWindow:
        def __init__(self) -> None:
            self.visible = False
            self.destroyed = False
            self.signals: dict[str, list[tuple[int, object]]] = {}
            self._last_signal_id = 0

        def set_title(self, _title: str) -> None:
            pass

        def set_modal(self, _value: bool) -> None:
            pass

        def set_skip_taskbar_hint(self, _value: bool) -> None:
            pass

        def set_keep_above(self, _value: bool) -> None:
            pass

        def set_resizable(self, _value: bool) -> None:
            pass

        def set_decorated(self, _value: bool) -> None:
            pass

        def set_focus_on_map(self, _value: bool) -> None:
            pass

        def set_transient_for(self, _parent: object) -> None:
            raise TypeError("transient parent must be a Gtk.Window")

        def connect(self, signal: str, callback) -> int:
            self._last_signal_id += 1
            self.signals.setdefault(signal, []).append((self._last_signal_id, callback))
            return self._last_signal_id

        def disconnect(self, _handler_id: int) -> None:
            pass

        def add(self, child: object) -> None:
            self.child = child

        def show_all(self) -> None:
            self.visible = True

        def hide(self) -> None:
            self.visible = False

    class _GtkBox:
        def __init__(self, *_args, **_kwargs) -> None:
            self.children: list[object] = []

        def add(self, child: object) -> None:
            self.children.append(child)

    class _Gtk:
        Window = _GtkWindow
        Box = _GtkBox

        class Orientation:
            VERTICAL = 1

        class WindowType:
            POPUP = 1
            TOPLEVEL = 2

    popup = PopupWindow("Accounts", transient_for=object(), auto_close=True, gtk_module=_Gtk)

    popup.show()

    assert popup.is_visible is True
    assert popup.transient_for is None


def test_popup_window_enables_window_controls_for_manual_move_and_close() -> None:
    from popup_window import PopupWindow

    class _GtkWindow:
        def __init__(self, *_args, **_kwargs) -> None:
            self.window_type = _kwargs.get("type") if _kwargs else None
            self.visible = False
            self.resizable = None
            self.decorated = None
            self.modal = None
            self.signals: dict[str, list[tuple[int, object]]] = {}
            self._last_signal_id = 0

        def set_title(self, _title: str) -> None:
            pass

        def set_modal(self, value: bool) -> None:
            self.modal = value

        def set_skip_taskbar_hint(self, _value: bool) -> None:
            pass

        def set_keep_above(self, _value: bool) -> None:
            pass

        def set_focus_on_map(self, _value: bool) -> None:
            pass

        def set_resizable(self, value: bool) -> None:
            self.resizable = value

        def set_decorated(self, value: bool) -> None:
            self.decorated = value

        def connect(self, signal: str, callback) -> int:
            self._last_signal_id += 1
            self.signals.setdefault(signal, []).append((self._last_signal_id, callback))
            return self._last_signal_id

        def add(self, child: object) -> None:
            self.child = child

        def show_all(self) -> None:
            self.visible = True

        def hide(self) -> None:
            self.visible = False

    class _GtkBox:
        def __init__(self, *_args, **_kwargs) -> None:
            self.children: list[object] = []

        def add(self, child: object) -> None:
            self.children.append(child)

    class _Gtk:
        Window = _GtkWindow
        Box = _GtkBox

        class Orientation:
            VERTICAL = 1

        class WindowType:
            POPUP = 1
            TOPLEVEL = 2

    popup = PopupWindow("Accounts", transient_for=None, auto_close=False, gtk_module=_Gtk)

    assert popup.window.window_type == _Gtk.WindowType.TOPLEVEL
    assert popup.window.decorated is True
    assert popup.window.resizable is True


def test_factory_integration_is_retired_from_systray() -> None:
    registry = _FakeRegistry(accounts=[_account("rafa", "Rafa")], default_slug="rafa")
    loader_calls: list[bool] = []
    opened: list[str] = []
    notifications: list[str] = []

    def loader():
        loader_calls.append(True)
        return _idle_factory_snapshot()

    tray = _build_indicator(
        registry,
        factory_status_loader=loader,
        factory_url_opener=opened.append,
        factory_decision_notification_store=FactoryDecisionNotificationStore(
            Path(tempfile.mkdtemp()) / "seen.json"
        ),
        thread_runner=lambda _target: None,
    )
    tray._notify = notifications.append
    tray._refresh_all(force=True)

    labels = [item.label for item in tray.menu.items if hasattr(item, "label")]
    assert "Factory" not in labels
    assert loader_calls == []
    assert opened == []
    assert notifications == []
    assert not hasattr(tray, "_schedule_factory_refresh")


def test_account_caps_ignore_slugs_missing_from_the_registry(tmp_path: Path) -> None:
    import json

    import indicator

    registry = _FakeRegistry(
        accounts=[_account("rafa", "Rafa")],
        default_slug="rafa",
        base_dir=tmp_path,
    )
    (tmp_path / "routing_rules.json").write_text(
        json.dumps(
            {
                "version": "routing/v2",
                "projects": {},
                "default": "rafa",
                "fallback_chain": [],
                "quota_exhausted_threshold_pct": 100,
                "account_caps": {"rafa": {"7d": 40}, "renamed-away": {"7d": 60}},
            }
        ),
        encoding="utf-8",
    )
    tray = indicator.Indicator(
        registry=registry,
        health_client=_FakeHealthClient(),
        health_snapshot_store=_FakeHealthSnapshotStore(),
        settings_store=_FakeSettingsStore(),
    )

    assert tray._account_caps_for_tool("codex") == {"rafa": {"7d": 40}}
