from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from juju.errors import JujuError
from textual.widgets import DataTable, Label, ListItem, ListView, TabbedContent
from textual.widgets._data_table import RowKey

from jujumate.app import JujuMateApp, _asyncio_exception_handler
from jujumate.client.watcher import (
    AppsUpdated,
    CloudsUpdated,
    ConnectionFailed,
    ControllersUpdated,
    DataRefreshed,
    JujuPoller,
    MachinesUpdated,
    ModelsUpdated,
    OffersUpdated,
    RelationsUpdated,
    SaasUpdated,
    UnitsUpdated,
)
from jujumate.config import JujuConfig, JujuConfigError
from jujumate.models.entities import (
    AppConfigEntry,
    AppInfo,
    CloudInfo,
    ControllerInfo,
    ControllerOfferInfo,
    MachineInfo,
    ModelInfo,
    OfferEndpoint,
    OfferInfo,
    RelationDataEntry,
    RelationInfo,
    SAASInfo,
    SecretInfo,
    UnitInfo,
)
from jujumate.screens.app_config_screen import AppConfigScreen
from jujumate.screens.log_screen import LogScreen
from jujumate.screens.main_screen import MainScreen
from jujumate.screens.offers_screen import OfferDetailScreen, OffersScreen, _ConsumerEntry
from jujumate.screens.relation_data_screen import RelationDataScreen
from jujumate.screens.secrets_screen import SecretDetailScreen, SecretsScreen
from jujumate.screens.theme_screen import ThemeScreen
from jujumate.settings import AppSettings
from jujumate.widgets.app_config_view import AppConfigView
from jujumate.widgets.clouds_view import CloudsView
from jujumate.widgets.controllers_view import ControllersView
from jujumate.widgets.models_view import ModelsView
from jujumate.widgets.navigable_table import NavigableTable
from jujumate.widgets.relation_data_view import RelationDataView
from jujumate.widgets.status_view import StatusView


def _make_juju_client_mock(**method_returns) -> AsyncMock:
    """Build a JujuClient async context manager mock.

    Pass method names and their return values as keyword arguments:
        _make_juju_client_mock(get_status_details=([], [], []))
    """
    mock = AsyncMock()
    mock.__aenter__ = AsyncMock(return_value=mock)
    mock.__aexit__ = AsyncMock(return_value=None)
    for method_name, return_value in method_returns.items():
        getattr(mock, method_name).return_value = return_value
    return mock


async def _run_connect_and_poll(
    screen: MainScreen, current_model: str | None = None
) -> tuple[AsyncMock, MagicMock]:
    """Call _connect_and_poll with mocked load_config and JujuPoller.

    Returns (mock_poller, MockPoller) for assertions.
    """
    config = JujuConfig(
        current_controller="prod", controllers=["prod"], current_model=current_model
    )
    with (
        patch("jujumate.screens.main_screen.load_config", return_value=config),
        patch("jujumate.screens.main_screen.JujuPoller") as MockPoller,
    ):
        mock_poller = AsyncMock()
        MockPoller.return_value = mock_poller
        await screen._connect_and_poll()
    return mock_poller, MockPoller


@pytest.mark.asyncio
async def test_initial_app_state(pilot):
    # GIVEN the application has just started
    # WHEN we inspect the current screen and active tab
    # THEN the screen is MainScreen and the default tab is clouds
    assert pilot.app.screen.__class__.__name__ == "MainScreen"
    assert pilot.app.screen.query_one(TabbedContent).active == "tab-clouds"


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "key,expected_tab_id",
    [
        pytest.param("m", "tab-models", id="m-to-models"),
        pytest.param("s", "tab-status", id="s-to-status"),
    ],
)
async def test_keybinding_switches_tab(pilot, key, expected_tab_id):
    # GIVEN the application is running on the clouds tab
    # WHEN the keybinding key is pressed
    await pilot.press(key)
    # THEN the tab switches to the expected tab
    assert pilot.app.screen.query_one(TabbedContent).active == expected_tab_id


@pytest.mark.asyncio
async def test_keybinding_q_exits(pilot):
    # GIVEN the application is running
    # WHEN the q key is pressed
    await pilot.press("q")
    # THEN the app exits with None return value
    assert pilot.app.return_value is None


@pytest.mark.asyncio
async def test_keybinding_r_triggers_refresh(pilot):
    # GIVEN the application is running
    screen = pilot.app.screen
    with patch.object(screen, "notify") as mock_notify:
        # WHEN the r key is pressed
        await pilot.press("r")
        await pilot.pause()
    # THEN the refresh notification was shown and the app is still running
    mock_notify.assert_called_once_with("Refreshing…")
    assert pilot.app.screen.__class__.__name__ == "MainScreen"


@pytest.mark.asyncio
async def test_app_falls_back_when_theme_not_found():
    # GIVEN a settings object with a nonexistent theme name
    settings = AppSettings(theme="nonexistent-theme")
    app = JujuMateApp(settings=settings)
    # WHEN the app is started
    async with app.run_test() as pilot:
        await pilot.pause()
        # THEN the app still mounts correctly
        assert app.screen.__class__.__name__ == "MainScreen"


@pytest.mark.asyncio
async def test_message_handlers_update_views(pilot):
    # GIVEN the application is running
    screen = pilot.app.screen
    # WHEN various data update messages are posted
    screen.on_clouds_updated(CloudsUpdated(clouds=[CloudInfo("aws", "ec2")]))
    screen.on_controllers_updated(
        ControllersUpdated(controllers=[ControllerInfo("ctrl", "aws", "", "3.4.0", model_count=1)])
    )
    screen.on_models_updated(ModelsUpdated(models=[ModelInfo("dev", "ctrl", "aws", "", "active")]))
    screen.on_apps_updated(AppsUpdated(apps=[AppInfo("pg", "dev", "pg", "14/stable", 1)]))
    screen.on_units_updated(UnitsUpdated(units=[UnitInfo("pg/0", "pg", "0", "active", "idle")]))
    screen.on_data_refreshed(DataRefreshed(timestamp=datetime(2024, 1, 1, 12, 0, 0)))
    await pilot.pause()
    # THEN the connection flag is set and timestamp is updated
    assert screen._is_connected is True
    assert screen._last_refresh_ts == "12:00:00"


@pytest.mark.asyncio
async def test_connection_failed_sets_subtitle(pilot):
    # GIVEN the application is running
    # WHEN a ConnectionFailed message is posted
    pilot.app.screen.on_connection_failed(ConnectionFailed(error="timeout"))
    # THEN the connected flag is cleared
    assert pilot.app.screen._is_connected is False


@pytest.mark.asyncio
async def test_action_refresh_data_without_poller(pilot):
    # GIVEN no poller is set (_poller is None)
    # WHEN action_refresh_data is called
    await pilot.app.screen.action_refresh_data()
    # THEN the app does not crash and stays on MainScreen
    assert pilot.app.screen.__class__.__name__ == "MainScreen"


@pytest.mark.asyncio
async def test_refresh_header_before_mount_does_not_crash():
    """_refresh_header guard: calling before widgets are mounted must not raise."""
    # GIVEN a MainScreen that has not been mounted in an app
    screen = MainScreen(settings=AppSettings(juju_data_dir=Path("/nonexistent")))
    # WHEN _refresh_header is called
    # THEN it silently returns without raising
    screen._refresh_header()


@pytest.mark.asyncio
async def test_connect_and_poll_connection_failure(pilot):
    # GIVEN load_config raises JujuConfigError
    screen = pilot.app.screen
    with patch(
        "jujumate.screens.main_screen.load_config",
        side_effect=JujuConfigError("no config"),
    ):
        # WHEN _connect_and_poll is called
        await screen._connect_and_poll()
    await pilot.pause()
    # THEN the screen is not connected
    assert screen._is_connected is False


@pytest.mark.asyncio
async def test_connect_and_poll_success(pilot):
    # GIVEN load_config returns a valid config and JujuPoller is mocked
    screen = pilot.app.screen
    # WHEN _connect_and_poll is called
    mock_poller, MockPoller = await _run_connect_and_poll(screen)
    # THEN the poller is created with the right controllers and poll_once is called
    MockPoller.assert_called_once_with(controller_names=["prod"], target=screen)
    mock_poller.poll_once.assert_awaited_once()


@pytest.mark.asyncio
async def test_connect_and_poll_sets_auto_select_from_config(pilot):
    # GIVEN load_config returns a config with current_model set
    screen = pilot.app.screen
    # WHEN _connect_and_poll is called
    await _run_connect_and_poll(screen, current_model="mymodel")
    # THEN _auto_select_model is set to the current_model from config
    assert screen._auto_select_model == "mymodel"


@pytest.mark.asyncio
async def test_action_refresh_data_with_poller(pilot):
    # GIVEN a poller is set
    screen = pilot.app.screen
    screen._poller = AsyncMock(spec=JujuPoller)
    # WHEN action_refresh_data is called
    await screen.action_refresh_data()
    # THEN poll_once is called
    screen._poller.poll_once.assert_awaited_once()


@pytest.mark.asyncio
async def test_action_refresh_data_with_poller_and_model(pilot):
    # GIVEN a poller is set and a controller+model are selected
    screen = pilot.app.screen
    screen._poller = AsyncMock(spec=JujuPoller)
    screen._selected_controller = "ctrl"
    screen._selected_model = "dev"
    # WHEN action_refresh_data is called
    with patch.object(screen, "_fetch_relations") as mock_fetch:
        await screen.action_refresh_data()
    # THEN poll_once is called and _fetch_relations is called with the selection
    screen._poller.poll_once.assert_awaited_once()
    mock_fetch.assert_called_once_with("ctrl", "dev")


@pytest.mark.asyncio
async def test_cloud_selected_switches_to_controllers_and_filters(pilot):
    # GIVEN two controllers on different clouds
    screen = pilot.app.screen
    screen._all_controllers = [
        ControllerInfo("prod", "aws", "", "3.4.0", 1),
        ControllerInfo("dev", "lxd", "", "3.4.0", 1),
    ]
    # WHEN a cloud is selected
    screen.on_clouds_view_cloud_selected(CloudsView.CloudSelected(name="aws"))
    await pilot.pause()
    # THEN the tab switches to controllers and only the matching controller is shown
    assert pilot.app.screen.query_one(TabbedContent).active == "tab-controllers"
    ctrl_view = screen.query_one("#controllers-view", ControllersView)
    assert len(ctrl_view.query_one(NavigableTable)._rows) == 1


@pytest.mark.asyncio
async def test_controller_selected_switches_to_models_and_filters(pilot):
    # GIVEN two models on different controllers
    screen = pilot.app.screen
    screen._all_models = [
        ModelInfo("dev", "prod", "aws", "", "available"),
        ModelInfo("staging", "other-ctrl", "aws", "", "available"),
    ]
    # WHEN a controller is selected
    screen.on_controllers_view_controller_selected(ControllersView.ControllerSelected(name="prod"))
    await pilot.pause()
    # THEN the tab switches to models and only the matching model is shown
    assert pilot.app.screen.query_one(TabbedContent).active == "tab-models"
    models_view = screen.query_one("#models-view", ModelsView)
    assert len(models_view.query_one(NavigableTable)._rows) == 1


@pytest.mark.asyncio
async def test_model_selected_switches_to_status_and_filters(pilot):
    # GIVEN two apps in different models
    screen = pilot.app.screen
    screen._all_apps = [
        AppInfo("pg", "dev", "pg", "14/stable", 1, controller="ctrl"),
        AppInfo("mysql", "prod", "mysql", "8/stable", 1, controller="other-ctrl"),
    ]
    mock_client = _make_juju_client_mock(get_status_details=([], [], []))
    with patch("jujumate.screens.main_screen.JujuClient", return_value=mock_client):
        screen._selected_controller = "ctrl"
        # WHEN a model with a controller prefix is selected
        screen.on_models_view_model_selected(ModelsView.ModelSelected(name="ctrl/dev"))
        await pilot.pause()
        await pilot.pause()
    # THEN the tab switches to status and only the matching app is shown
    assert pilot.app.screen.query_one(TabbedContent).active == "tab-status"
    status_view = screen.query_one("#status-view", StatusView)
    assert status_view.query_one("#status-apps-table DataTable").row_count == 1


@pytest.mark.asyncio
async def test_model_selected_without_slash_sets_model_only(pilot):
    # GIVEN no controller is preselected and the model name has no slash
    screen = pilot.app.screen
    screen._selected_controller = None
    # WHEN on_models_view_model_selected is called with a plain model name
    screen.on_models_view_model_selected(ModelsView.ModelSelected(name="mymodel"))
    await pilot.pause()
    # THEN _selected_model is set to the plain name
    assert screen._selected_model == "mymodel"


@pytest.mark.asyncio
async def test_clear_filter_resets_cloud_and_controller(pilot):
    # GIVEN a cloud and controller filter are active and no model is selected
    screen = pilot.app.screen
    screen._selected_cloud = "aws"
    screen._selected_controller = "prod"
    screen._selected_model = None
    screen._all_controllers = [
        ControllerInfo("prod", "aws", "", "3.4.0", 1),
        ControllerInfo("dev", "lxd", "", "3.4.0", 1),
    ]
    # WHEN action_clear_filter is called
    screen.action_clear_filter()
    await pilot.pause()
    # THEN cloud and controller filters are cleared and all controllers are shown
    assert screen._selected_cloud is None
    assert screen._selected_controller is None
    ctrl_view = screen.query_one("#controllers-view", ControllersView)
    assert len(ctrl_view.query_one(NavigableTable)._rows) == 2


@pytest.mark.asyncio
async def test_clear_filter_noop_when_model_selected(pilot):
    """Esc does nothing when a model is selected — preserves the full nav state."""
    # GIVEN a cloud, controller, and model are all selected
    screen = pilot.app.screen
    screen._selected_cloud = "aws"
    screen._selected_controller = "prod"
    screen._selected_model = "mymodel"
    # WHEN action_clear_filter is called
    screen.action_clear_filter()
    await pilot.pause()
    # THEN all selections are preserved
    assert screen._selected_cloud == "aws"
    assert screen._selected_controller == "prod"
    assert screen._selected_model == "mymodel"


@pytest.mark.parametrize(
    "context,should_suppress",
    [
        pytest.param({"exception": RuntimeError("Event loop is closed")}, True, id="closed-loop"),
        pytest.param({"exception": OSError("Bad file descriptor")}, True, id="bad-fd"),
        pytest.param(
            {"exception": RuntimeError("cannot reuse already awaited coroutine")},
            True,
            id="cannot-reuse",
        ),
        pytest.param(
            {"message": "Task was destroyed but it is pending!"}, True, id="task-destroyed"
        ),
        pytest.param({"exception": ValueError("something unexpected")}, False, id="value-error"),
        pytest.param({"message": "some asyncio message"}, False, id="asyncio-message"),
    ],
)
def test_asyncio_exception_handler(context, should_suppress):
    # GIVEN an asyncio event loop and an exception context
    loop = MagicMock()
    # WHEN _asyncio_exception_handler is called
    _asyncio_exception_handler(loop, context)
    # THEN expected exceptions are suppressed, others are delegated
    if should_suppress:
        loop.default_exception_handler.assert_not_called()
    else:
        loop.default_exception_handler.assert_called_once_with(context)


@pytest.mark.asyncio
async def test_relations_updated_populates_status_view(pilot):
    # GIVEN a model is selected and the Status tab is active
    screen = pilot.app.screen
    screen._selected_model = "dev"
    screen.action_switch_tab("tab-status")
    await pilot.pause()
    # WHEN a RelationsUpdated message is posted
    screen.on_relations_updated(
        RelationsUpdated(
            model="dev",
            relations=[
                RelationInfo("dev", "postgresql:db", "wordpress:db", "pgsql", "regular"),
            ],
        )
    )
    await pilot.pause()
    # THEN the relation is shown in the status view
    status_view = screen.query_one("#status-view", StatusView)
    assert status_view.query_one("#status-rels-table DataTable").row_count == 1


@pytest.mark.asyncio
async def test_relations_updated_replaces_existing_for_same_model(pilot):
    # GIVEN relations already exist for "dev" and "prod" models
    screen = pilot.app.screen
    screen._all_relations = [
        RelationInfo("dev", "a:x", "b:x", "iface", "regular"),
        RelationInfo("prod", "c:x", "d:x", "iface2", "regular"),
    ]
    screen._selected_model = "dev"
    # WHEN a RelationsUpdated message for "dev" is posted with a new relation
    screen.on_relations_updated(
        RelationsUpdated(
            model="dev",
            relations=[RelationInfo("dev", "new:x", "new2:x", "iface3", "regular")],
        )
    )
    await pilot.pause()
    # THEN only the new relation for "dev" remains; "prod" relations are untouched
    dev_rels = [r for r in screen._all_relations if r.model == "dev"]
    prod_rels = [r for r in screen._all_relations if r.model == "prod"]
    assert len(dev_rels) == 1
    assert dev_rels[0].provider == "new:x"
    assert len(prod_rels) == 1


@pytest.mark.asyncio
async def test_offers_updated_populates_status_view(pilot):
    # GIVEN a model is selected and the Status tab is active
    screen = pilot.app.screen
    screen._selected_model = "cos"
    screen.action_switch_tab("tab-status")
    await pilot.pause()
    # WHEN an OffersUpdated message is posted
    screen.on_offers_updated(
        OffersUpdated(
            model="cos",
            offers=[
                OfferInfo(
                    "cos",
                    "alertmanager-karma-dashboard",
                    "alertmanager",
                    "alertmanager-k8s",
                    180,
                    "0/0",
                    "karma-dashboard",
                    "karma_dashboard",
                    "provider",
                ),
            ],
        )
    )
    await pilot.pause()
    # THEN the offer is shown in the status view
    status_view = screen.query_one("#status-view", StatusView)
    assert status_view.query_one("#status-offers-table DataTable").row_count == 1


@pytest.mark.asyncio
async def test_machines_updated_populates_status_view(pilot):
    # GIVEN a model is selected and the Status tab is active
    screen = pilot.app.screen
    screen._selected_model = "dev"
    screen._all_models = [ModelInfo("dev", "prod", "aws", "us-east-1", "available")]
    screen.action_switch_tab("tab-status")
    await pilot.pause()
    # WHEN a MachinesUpdated message is posted
    screen.on_machines_updated(
        MachinesUpdated(
            machines=[
                MachineInfo(
                    "dev", "0", "started", "10.0.0.1", "i-1234", "ubuntu@22.04", "us-east-1a"
                ),
            ],
        )
    )
    await pilot.pause()
    # THEN the machine is shown in the status view
    status_view = screen.query_one("#status-view", StatusView)
    assert status_view.query_one("#status-machines-table DataTable").row_count == 1


@pytest.mark.asyncio
async def test_fetch_relations_worker_posts_message(pilot):
    # GIVEN a JujuClient mock that returns one relation
    screen = pilot.app.screen
    rel = RelationInfo("dev", "pg:db", "wp:db", "pgsql", "regular")
    mock_client = _make_juju_client_mock(get_status_details=([rel], [], []))
    with patch("jujumate.screens.main_screen.JujuClient", return_value=mock_client):
        # WHEN _fetch_relations is called
        screen._fetch_relations("ctrl", "dev")
        await pilot.pause()
        await pilot.pause()
    # THEN the relation is stored in _all_relations
    assert any(r.model == "dev" for r in screen._all_relations)


@pytest.mark.asyncio
async def test_fetch_relations_worker_handles_exception(pilot):
    # GIVEN a JujuClient mock that raises on connect
    screen = pilot.app.screen
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(side_effect=JujuError("boom"))
    mock_client.__aexit__ = AsyncMock(return_value=None)
    with patch("jujumate.screens.main_screen.JujuClient", return_value=mock_client):
        # WHEN _fetch_relations is called
        screen._fetch_relations("ctrl", "dev")
        await pilot.pause()
        await pilot.pause()
    # THEN no relations are stored (exception is handled gracefully)
    dev_rels = [r for r in screen._all_relations if r.model == "dev"]
    assert dev_rels == []


@pytest.mark.asyncio
async def test_auto_select_navigates_to_status_on_first_refresh(pilot):
    # GIVEN an auto-select model is set and data is available
    screen = pilot.app.screen
    screen._auto_select_model = "dev"
    screen.on_models_updated(ModelsUpdated(models=[ModelInfo("dev", "ctrl", "aws", "", "active")]))
    screen.on_apps_updated(AppsUpdated(apps=[AppInfo("pg", "dev", "pg", "14/stable", 1)]))
    screen.on_units_updated(UnitsUpdated(units=[UnitInfo("pg/0", "pg", "0", "active", "idle")]))
    with patch.object(screen, "_fetch_relations"):
        # WHEN on_data_refreshed is received
        screen.on_data_refreshed(DataRefreshed(timestamp=datetime(2024, 1, 1, 12, 0, 0)))
        await pilot.pause()
    # THEN the model is auto-selected and the status tab is shown
    assert screen._selected_model == "dev"
    assert screen._selected_controller == "ctrl"
    assert screen.query_one(TabbedContent).active == "tab-status"
    assert screen._auto_select_model is None


@pytest.mark.asyncio
async def test_auto_select_not_found_does_not_crash(pilot):
    # GIVEN an auto-select model that does not exist in loaded data
    screen = pilot.app.screen
    screen._auto_select_model = "nonexistent"
    # WHEN on_data_refreshed is received
    screen.on_data_refreshed(DataRefreshed(timestamp=datetime(2024, 1, 1, 12, 0, 0)))
    await pilot.pause()
    # THEN nothing is selected and auto_select is cleared
    assert screen._selected_model is None
    assert screen._auto_select_model is None


@pytest.mark.asyncio
async def test_help_screen_opens_and_closes_with_question_mark(pilot):
    # GIVEN the application is running on the main screen
    assert pilot.app.screen.__class__.__name__ == "MainScreen"
    # WHEN the question_mark key is pressed
    await pilot.press("question_mark")
    await pilot.pause()
    # THEN the HelpScreen is shown; pressing again closes it
    assert pilot.app.screen.__class__.__name__ == "HelpScreen"
    await pilot.press("question_mark")
    await pilot.pause()
    assert pilot.app.screen.__class__.__name__ == "MainScreen"


@pytest.mark.asyncio
async def test_help_screen_closes_with_escape(pilot):
    # GIVEN the HelpScreen is open
    await pilot.press("question_mark")
    await pilot.pause()
    assert pilot.app.screen.__class__.__name__ == "HelpScreen"
    # WHEN Escape is pressed
    await pilot.press("escape")
    await pilot.pause()
    # THEN we return to MainScreen
    assert pilot.app.screen.__class__.__name__ == "MainScreen"


# ─────────────────────────────────────────────────────────────────────────────
# _periodic_poll
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_periodic_poll_no_poller_is_noop(pilot):
    # GIVEN no poller is set
    screen = pilot.app.screen
    screen._poller = None
    # WHEN _periodic_poll is called
    await screen._periodic_poll()
    # THEN _is_connected is unchanged (False) and no error is raised
    assert screen._is_connected is False


@pytest.mark.asyncio
async def test_periodic_poll_non_status_tab_does_not_call_poll_once(pilot):
    # GIVEN a poller is set and the active tab is NOT "tab-status"
    screen = pilot.app.screen
    screen._poller = AsyncMock(spec=JujuPoller)
    screen.query_one(TabbedContent).active = "tab-clouds"
    # WHEN _periodic_poll is called
    await screen._periodic_poll()
    # THEN poll_once is NOT called
    screen._poller.poll_once.assert_not_awaited()


@pytest.mark.asyncio
async def test_periodic_poll_status_tab_calls_poll_and_fetch_relations(pilot):
    # GIVEN a poller is set, tab is "tab-status", and controller+model are selected
    screen = pilot.app.screen
    screen._poller = AsyncMock(spec=JujuPoller)
    screen._selected_controller = "ctrl"
    screen._selected_model = "dev"
    screen.query_one(TabbedContent).active = "tab-status"
    with patch.object(screen, "_fetch_relations") as mock_fetch:
        # WHEN _periodic_poll is called
        await screen._periodic_poll()
    # THEN poll_once is called and _fetch_relations is invoked with the selection
    screen._poller.poll_once.assert_awaited_once()
    mock_fetch.assert_called_once_with("ctrl", "dev")


# ─────────────────────────────────────────────────────────────────────────────
# on_tabbed_content_tab_activated — focus mapped tab
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_tab_activated_with_mapped_tab_calls_focus(pilot):
    # GIVEN the main screen is active
    screen = pilot.app.screen
    # WHEN on_tabbed_content_tab_activated is called with a tab id in _TAB_FOCUS_MAP
    tab = MagicMock()
    tab.id = "tab-clouds"
    event = MagicMock()
    event.tab = tab
    with patch.object(screen, "call_after_refresh") as mock_car:
        screen.on_tabbed_content_tab_activated(event)
    # THEN call_after_refresh is invoked
    mock_car.assert_called_once()


@pytest.mark.asyncio
async def test_tab_activated_status_schedules_refresh(pilot):
    # GIVEN the main screen is active
    screen = pilot.app.screen

    # WHEN on_tabbed_content_tab_activated fires for "tab-status"
    tab = MagicMock()
    tab.id = "tab-status"
    event = MagicMock()
    event.tab = tab
    calls = []
    with patch.object(screen, "call_after_refresh", side_effect=calls.append):
        screen.on_tabbed_content_tab_activated(event)

    # THEN call_after_refresh is called twice (focus + refresh_status_view)
    assert len(calls) == 2
    assert screen._refresh_status_view in calls


@pytest.mark.asyncio
async def test_active_tab_returns_empty_string_when_not_mounted(pilot):
    # GIVEN a MainScreen whose TabbedContent raises NoMatches
    screen = pilot.app.screen

    # WHEN _active_tab is called and query_one raises NoMatches
    from textual.css.query import NoMatches

    with patch.object(screen, "query_one", side_effect=NoMatches()):
        result = screen._active_tab()

    # THEN the method returns an empty string instead of raising
    assert result == ""


@pytest.mark.asyncio
async def test_action_toggle_health_filter_delegates_to_health_view(pilot):
    # GIVEN the Health tab is active
    screen = pilot.app.screen
    screen.action_switch_tab("tab-health")
    await pilot.pause()

    # WHEN action_toggle_health_filter is called
    from jujumate.widgets.health_view import HealthView

    hv = screen.query_one("#health-view", HealthView)
    assert hv._show_all is False
    screen.action_toggle_health_filter()
    await pilot.pause()

    # THEN the health view's _show_all is toggled
    assert hv._show_all is True


# ─────────────────────────────────────────────────────────────────────────────
# action_show_secrets / action_show_logs — no selection (parametrized)
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "action_name",
    [
        pytest.param("action_show_secrets", id="secrets"),
        pytest.param("action_show_logs", id="logs"),
        pytest.param("action_show_offers", id="offers"),
    ],
)
async def test_action_show_screen_no_selection_stays_on_main(pilot, action_name):
    # GIVEN no controller or model is selected
    screen = pilot.app.screen
    screen._selected_controller = None
    screen._selected_model = None
    # WHEN the action is invoked
    getattr(screen, action_name)()
    await pilot.pause()
    # THEN we stay on MainScreen (no push_screen occurred)
    assert isinstance(pilot.app.screen, MainScreen)


# ─────────────────────────────────────────────────────────────────────────────
# action_show_secrets / action_show_logs — with selection (parametrized)
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "action_name, screen_class, patch_attr",
    [
        pytest.param("action_show_secrets", SecretsScreen, "_fetch", id="secrets"),
        pytest.param("action_show_logs", LogScreen, "_start_stream", id="logs"),
    ],
)
async def test_action_show_screen_with_selection_pushes_screen(
    pilot, action_name, screen_class, patch_attr
):
    # GIVEN a controller and model are selected
    screen = pilot.app.screen
    screen._selected_controller = "ctrl"
    screen._selected_model = "dev"
    # WHEN the action is called
    with patch.object(screen_class, patch_attr):
        getattr(screen, action_name)()
        await pilot.pause()
    # THEN the appropriate screen is pushed
    assert isinstance(pilot.app.screen, screen_class)


# ─────────────────────────────────────────────────────────────────────────────
# action_show_themes
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_action_show_themes_pushes_theme_screen(pilot):
    # GIVEN the main screen is active
    screen = pilot.app.screen
    # WHEN action_show_themes is called
    screen.action_show_themes()
    await pilot.pause()
    # THEN ThemeScreen is pushed
    assert isinstance(pilot.app.screen, ThemeScreen)


# ─────────────────────────────────────────────────────────────────────────────
# on_status_view_app_selected
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_status_view_app_selected_pushes_app_config_screen(pilot):
    # GIVEN a controller and model are selected
    screen = pilot.app.screen
    screen._selected_controller = "ctrl"
    screen._selected_model = "dev"
    ai = AppInfo("pg", "dev", "pg", "14/stable", 1)
    # WHEN on_status_view_app_selected is called
    with patch.object(AppConfigScreen, "_fetch"):
        screen.on_status_view_app_selected(StatusView.AppSelected(app=ai))
        await pilot.pause()
    # THEN AppConfigScreen is pushed
    assert isinstance(pilot.app.screen, AppConfigScreen)


# ─────────────────────────────────────────────────────────────────────────────
# on_status_view_relation_selected
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_status_view_relation_selected_pushes_relation_data_screen(pilot):
    # GIVEN a controller is selected and the relation has a relation_id
    screen = pilot.app.screen
    screen._selected_controller = "ctrl"
    screen._selected_model = "dev"
    rel = RelationInfo("dev", "pg:db", "wp:db", "pgsql", "regular", relation_id=1)
    # WHEN on_status_view_relation_selected is called
    with patch.object(RelationDataScreen, "_fetch"):
        screen.on_status_view_relation_selected(StatusView.RelationSelected(relation=rel))
        await pilot.pause()
    # THEN RelationDataScreen is pushed
    assert isinstance(pilot.app.screen, RelationDataScreen)


# ─────────────────────────────────────────────────────────────────────────────
# on_status_view_offer_selected
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_status_view_offer_selected_calls_open_offer_detail(pilot):
    # GIVEN a controller is selected
    screen = pilot.app.screen
    screen._selected_controller = "ctrl"
    offer = OfferInfo("cos", "my-offer", "pg", "pg-k8s", 1, "0/0", "db", "pgsql", "provider")
    # WHEN on_status_view_offer_selected is called
    with patch.object(screen, "_open_offer_detail") as mock_open:
        screen.on_status_view_offer_selected(StatusView.OfferSelected(offer=offer))
        await pilot.pause()
    # THEN _open_offer_detail is called with the correct arguments
    mock_open.assert_called_once_with("ctrl", offer.model, offer.name)


@pytest.mark.asyncio
async def test_open_offer_detail_logs_and_returns_on_connection_error(pilot):
    # GIVEN get_offer_detail raises JujuError (model gone / connection error)
    screen = pilot.app.screen
    with patch("jujumate.screens.main_screen.JujuClient") as MockClient:
        instance = AsyncMock()
        instance.get_offer_detail = AsyncMock(side_effect=JujuError("gone"))
        MockClient.return_value.__aenter__ = AsyncMock(return_value=instance)
        MockClient.return_value.__aexit__ = AsyncMock(return_value=False)

        # WHEN _open_offer_detail is called
        screen._open_offer_detail("ctrl", "dev", "pg-offer")
        for _ in range(10):
            await pilot.pause()

    # THEN no screen is pushed (method returns gracefully)
    assert pilot.app.screen is screen


def _call_app_selected_no_ctrl(screen) -> None:
    screen._selected_controller = None
    screen._selected_model = None
    ai = AppInfo("pg", "dev", "pg", "14/stable", 1)
    screen.on_status_view_app_selected(StatusView.AppSelected(app=ai))


def _call_rel_selected_no_id(screen) -> None:
    screen._selected_controller = "ctrl"
    rel = RelationInfo("dev", "pg:db", "wp:db", "pgsql", "regular", relation_id=None)
    screen.on_status_view_relation_selected(StatusView.RelationSelected(relation=rel))


def _call_offer_selected_no_ctrl(screen) -> None:
    screen._selected_controller = None
    offer = OfferInfo("cos", "my-offer", "pg", "pg-k8s", 1, "0/0", "db", "pgsql", "provider")
    screen.on_status_view_offer_selected(StatusView.OfferSelected(offer=offer))


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "setup_fn",
    [
        pytest.param(_call_app_selected_no_ctrl, id="app-no-controller"),
        pytest.param(_call_rel_selected_no_id, id="relation-no-id"),
        pytest.param(_call_offer_selected_no_ctrl, id="offer-no-controller"),
    ],
)
async def test_status_view_handler_early_return_is_noop(pilot, setup_fn):
    # GIVEN a handler is called with missing required selection
    screen = pilot.app.screen
    # WHEN the handler is invoked
    setup_fn(screen)
    await pilot.pause()
    # THEN we stay on MainScreen (early return triggered)
    assert isinstance(pilot.app.screen, MainScreen)


# ─────────────────────────────────────────────────────────────────────────────
# _open_offer_detail worker
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_open_offer_detail_worker_pushes_offer_detail_screen(pilot):
    # GIVEN a JujuClient mock that returns a valid ControllerOfferInfo
    screen = pilot.app.screen
    offer_detail = ControllerOfferInfo(
        model="cos",
        name="my-offer",
        offer_url="admin/cos.my-offer",
        application="prometheus",
        charm="ch:prometheus-k8s-1",
        description="",
        endpoints=[OfferEndpoint("metrics-endpoint", "prometheus_scrape", "provider")],
        active_connections=0,
        total_connections=1,
    )
    mock_client = _make_juju_client_mock(get_offer_detail=offer_detail)
    with (
        patch("jujumate.screens.main_screen.JujuClient", return_value=mock_client),
        patch.object(OfferDetailScreen, "_fetch_consumers"),
    ):
        # WHEN _open_offer_detail is called
        screen._open_offer_detail("ctrl", "cos", "my-offer")
        await pilot.pause()
        await pilot.pause()
    # THEN OfferDetailScreen is pushed
    assert isinstance(pilot.app.screen, OfferDetailScreen)


# ─────────────────────────────────────────────────────────────────────────────
# SecretsScreen & SecretDetailScreen
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_secrets_screen_populate_with_secrets(pilot):
    # GIVEN a SecretsScreen with _fetch patched
    secrets = [
        SecretInfo(
            uri="csec:abc123",
            label="my-secret",
            owner="dev",
            description="",
            revision=1,
            rotate_policy="",
            created="2024-01-01",
            updated="2024-01-01",
        ),
        SecretInfo(
            uri="csec:def456",
            label="other",
            owner="dev",
            description="",
            revision=2,
            rotate_policy="",
            created="2024-01-02",
            updated="2024-01-02",
        ),
    ]
    with patch.object(SecretsScreen, "_fetch"):
        screen = SecretsScreen("ctrl", "dev")
        await pilot.app.push_screen(screen)
        await pilot.pause()
        # WHEN _populate is called with two secrets
        screen._populate(secrets)
        await pilot.pause()
    # THEN the data table has two rows
    dt = screen.query_one(DataTable)
    assert dt.row_count == 2


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "screen_cls, ctor_args",
    [
        pytest.param(SecretsScreen, ("ctrl", "dev"), id="secrets"),
        pytest.param(OffersScreen, ("my-ctrl",), id="offers"),
    ],
)
async def test_screen_populate_empty_shows_no_rows(pilot, screen_cls, ctor_args):
    # GIVEN a screen with _fetch patched
    with patch.object(screen_cls, "_fetch"):
        screen = screen_cls(*ctor_args)
        await pilot.app.push_screen(screen)
        await pilot.pause()
        # WHEN _populate is called with an empty list
        screen._populate([])
        await pilot.pause()
    # THEN the data table has no rows
    assert screen.query_one(DataTable).row_count == 0


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "screen_cls, ctor_args, loading_id",
    [
        pytest.param(SecretsScreen, ("ctrl", "dev"), "#secrets-loading", id="secrets"),
        pytest.param(OffersScreen, ("my-ctrl",), "#offers-loading", id="offers"),
    ],
)
async def test_screen_show_error_displays_loading(pilot, screen_cls, ctor_args, loading_id):
    # GIVEN a screen with _fetch patched
    with patch.object(screen_cls, "_fetch"):
        screen = screen_cls(*ctor_args)
        await pilot.app.push_screen(screen)
        await pilot.pause()
        # WHEN _show_error is called
        screen._show_error("connection refused")
        await pilot.pause()
    # THEN the loading label is still visible (showing the error)
    assert screen.query_one(loading_id).display is True


@pytest.mark.asyncio
async def test_secrets_screen_row_selected_pushes_detail(pilot):
    # GIVEN a SecretsScreen populated with one secret
    secrets = [
        SecretInfo(
            uri="csec:abc",
            label="my-secret",
            owner="dev",
            description="",
            revision=1,
            rotate_policy="",
            created="2024-01-01",
            updated="2024-01-01",
        )
    ]
    with patch.object(SecretsScreen, "_fetch"), patch.object(SecretDetailScreen, "_fetch"):
        screen = SecretsScreen("ctrl", "dev")
        await pilot.app.push_screen(screen)
        await pilot.pause()
        screen._populate(secrets)
        await pilot.pause()
        dt = screen.query_one(DataTable)
        # WHEN a row is selected
        screen.on_data_table_row_selected(
            DataTable.RowSelected(data_table=dt, cursor_row=0, row_key=RowKey("0"))
        )
        await pilot.pause()
    # THEN SecretDetailScreen is pushed
    assert isinstance(pilot.app.screen, SecretDetailScreen)


@pytest.mark.asyncio
async def test_secrets_screen_row_selected_out_of_range_safe(pilot):
    # GIVEN a SecretsScreen with no secrets
    with patch.object(SecretsScreen, "_fetch"):
        screen = SecretsScreen("ctrl", "dev")
        await pilot.app.push_screen(screen)
        await pilot.pause()
        screen._populate([])
        await pilot.pause()
    dt = screen.query_one(DataTable)
    # WHEN a row selection with an out-of-range index is received
    # THEN it does not raise
    screen.on_data_table_row_selected(
        DataTable.RowSelected(data_table=dt, cursor_row=99, row_key=RowKey("99"))
    )
    await pilot.pause()


@pytest.mark.asyncio
async def test_secret_detail_screen_shows_fields(pilot):
    # GIVEN a SecretDetailScreen with _fetch patched
    secret = SecretInfo(
        uri="csec:abc123",
        label="my-secret",
        owner="dev",
        description="A test secret",
        revision=1,
        rotate_policy="",
        created="2024-01-01T00:00:00",
        updated="2024-01-01T00:00:00",
    )
    with patch.object(SecretDetailScreen, "_fetch"):
        screen = SecretDetailScreen("ctrl", "dev", secret)
        await pilot.app.push_screen(screen)
        await pilot.pause()
    # WHEN we inspect the rendered labels
    labels = screen.query(Label)
    all_text = "\n".join(str(lbl.render()) for lbl in labels)
    # THEN the URI and label are visible
    assert "csec:abc123" in all_text
    assert "my-secret" in all_text


# ─────────────────────────────────────────────────────────────────────────────
# AppConfigScreen
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "call_view_method, expected_id",
    [
        pytest.param(
            lambda view, ai: view.update(
                ai, [AppConfigEntry("port", "5432", "5432", "int", "Port", "default")]
            ),
            "#ac-panel",
            id="success",
        ),
        pytest.param(
            lambda view, ai: view.show_error(ai, "timeout"), "#ac-empty", id="fetch-error"
        ),
    ],
)
async def test_app_config_screen_view_state(pilot, call_view_method, expected_id):
    # GIVEN an AppConfigScreen with _fetch patched
    ai = AppInfo("pg", "dev", "postgresql", "14/stable", 363, status="active")
    with patch.object(AppConfigScreen, "_fetch"):
        screen = AppConfigScreen("ctrl", "dev", ai)
        await pilot.app.push_screen(screen)
        await pilot.pause()
        # WHEN the view method is called
        call_view_method(screen.query_one(AppConfigView), ai)
        await pilot.pause()
    # THEN the expected panel is visible
    assert screen.query_one(AppConfigView).query_one(expected_id).display is True


# ─────────────────────────────────────────────────────────────────────────────
# RelationDataScreen
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "call_view_method, expected_id",
    [
        pytest.param(
            lambda view, rel: view.update(
                rel, [RelationDataEntry("provider", "pg", "host", "10.0.0.1", "app")]
            ),
            "#rd-panel",
            id="success",
        ),
        pytest.param(
            lambda view, rel: view.show_error(rel, "timeout"), "#rd-empty", id="fetch-error"
        ),
    ],
)
async def test_relation_data_screen_view_state(pilot, call_view_method, expected_id):
    # GIVEN a RelationDataScreen with _fetch patched
    rel = RelationInfo("dev", "pg:db", "wp:db", "pgsql", "regular", relation_id=1)
    with patch.object(RelationDataScreen, "_fetch"):
        screen = RelationDataScreen("ctrl", "dev", rel)
        await pilot.app.push_screen(screen)
        await pilot.pause()
        # WHEN the view method is called
        call_view_method(screen.query_one(RelationDataView), rel)
        await pilot.pause()
    # THEN the expected panel is visible
    assert screen.query_one(RelationDataView).query_one(expected_id).display is True


# ─────────────────────────────────────────────────────────────────────────────
# Model deletion — stale data cleanup
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_models_updated_prunes_stale_relations(pilot):
    """Stale relations for a deleted model are removed on next ModelsUpdated."""
    # GIVEN relations exist for both "deleted-model" and "surviving-model"
    screen = pilot.app.screen
    screen.on_relations_updated(
        RelationsUpdated(
            model="deleted-model",
            controller="ctrl",
            relations=[
                RelationInfo(
                    "deleted-model", "pg:db", "wp:db", "pgsql", "regular", controller="ctrl"
                )
            ],
        )
    )
    screen.on_relations_updated(
        RelationsUpdated(
            model="surviving-model",
            controller="ctrl",
            relations=[
                RelationInfo(
                    "surviving-model", "mysql:db", "wp:db", "mysql", "regular", controller="ctrl"
                )
            ],
        )
    )
    # (precondition: verify setup is correct before the WHEN)
    assert any(r.model == "deleted-model" for r in screen._all_relations)
    # WHEN ModelsUpdated is received without "deleted-model"
    screen.on_models_updated(
        ModelsUpdated(
            models=[
                ModelInfo("surviving-model", "ctrl", "aws", "", "active"),
            ]
        )
    )
    # THEN stale relations for the deleted model are pruned
    assert not any(r.model == "deleted-model" for r in screen._all_relations)
    assert any(r.model == "surviving-model" for r in screen._all_relations)


@pytest.mark.asyncio
async def test_models_updated_prunes_stale_offers_and_saas(pilot):
    """Stale offers and SAAS for a deleted model are removed on next ModelsUpdated."""
    # GIVEN offers and SAAS exist for "gone-model"
    screen = pilot.app.screen
    screen.on_offers_updated(
        OffersUpdated(
            model="gone-model",
            offers=[
                OfferInfo(
                    "gone-model", "my-offer", "pg", "pg-k8s", 1, "0/0", "db", "pgsql", "provider"
                )
            ],
        )
    )
    screen.on_saas_updated(
        SaasUpdated(
            model="gone-model",
            saas=[SAASInfo("gone-model", "remote-pg", "active", "mystore", "mystore:admin/pg")],
        )
    )
    # (precondition: verify setup is correct before the WHEN)
    assert any(o.model == "gone-model" for o in screen._all_offers)
    assert any(s.model == "gone-model" for s in screen._all_saas)
    # WHEN ModelsUpdated is received without "gone-model"
    screen.on_models_updated(
        ModelsUpdated(
            models=[
                ModelInfo("other-model", "ctrl", "aws", "", "active"),
            ]
        )
    )
    # THEN stale offers and SAAS are pruned
    assert not any(o.model == "gone-model" for o in screen._all_offers)
    assert not any(s.model == "gone-model" for s in screen._all_saas)


@pytest.mark.asyncio
async def test_models_updated_deselects_deleted_model(pilot):
    """When the selected model is deleted, _selected_model is reset and tab switches."""
    # GIVEN the selected model is about to be deleted
    screen = pilot.app.screen
    screen._selected_model = "doomed-model"
    screen._all_relations = [
        RelationInfo("doomed-model", "pg:db", "wp:db", "pgsql", "regular"),
    ]
    pilot.app.screen.query_one(TabbedContent).active = "tab-status"
    # WHEN ModelsUpdated is received without the selected model
    screen.on_models_updated(
        ModelsUpdated(
            models=[
                ModelInfo("other-model", "ctrl", "aws", "", "active"),
            ]
        )
    )
    # THEN the selection is cleared and we switch to the models tab
    assert screen._selected_model is None
    assert screen._all_relations == []
    assert pilot.app.screen.query_one(TabbedContent).active == "tab-models"


@pytest.mark.asyncio
async def test_models_updated_keeps_selected_model_when_still_exists(pilot):
    """When the selected model still exists, it stays selected and data is kept."""
    # GIVEN a model is selected and still exists in the update
    screen = pilot.app.screen
    screen._selected_model = "my-model"
    screen._selected_controller = "ctrl"
    screen._all_relations = [
        RelationInfo("my-model", "pg:db", "wp:db", "pgsql", "regular", controller="ctrl"),
    ]
    # WHEN ModelsUpdated is received and the selected model is still present
    screen.on_models_updated(
        ModelsUpdated(
            models=[
                ModelInfo("my-model", "ctrl", "aws", "", "active"),
                ModelInfo("other-model", "ctrl", "aws", "", "active"),
            ]
        )
    )
    # THEN the selection and relations are preserved
    assert screen._selected_model == "my-model"
    assert any(r.model == "my-model" for r in screen._all_relations)


# ─────────────────────────────────────────────────────────────────────────────
# OffersScreen & OfferDetailScreen
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_offers_screen_populate_with_offers(pilot):
    # GIVEN an OffersScreen with _fetch patched
    offers = [
        ControllerOfferInfo(
            model="cos",
            name="prometheus-scrape",
            offer_url="admin/cos.prometheus-scrape",
            application="prometheus",
            charm="ch:prometheus-k8s-1",
            description="Scrape endpoint",
            endpoints=[OfferEndpoint("metrics-endpoint", "prometheus_scrape", "provider")],
            active_connections=1,
            total_connections=2,
        ),
        ControllerOfferInfo(
            model="cos",
            name="alertmanager-karma",
            offer_url="admin/cos.alertmanager-karma",
            application="alertmanager",
            charm="ch:alertmanager-k8s-1",
            description="",
            endpoints=[],
            active_connections=0,
            total_connections=0,
        ),
    ]
    with patch.object(OffersScreen, "_fetch"):
        screen = OffersScreen("my-ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()
        # WHEN _populate is called with two offers
        screen._populate(offers)
        await pilot.pause()
    # THEN the data table has two rows
    dt = screen.query_one(DataTable)
    assert dt.row_count == 2


@pytest.mark.asyncio
async def test_offers_screen_row_selected_pushes_detail(pilot):
    # GIVEN an OffersScreen populated with one offer
    offers = [
        ControllerOfferInfo(
            model="cos",
            name="prom",
            offer_url="admin/cos.prom",
            application="prometheus",
            charm="ch:prometheus-k8s-1",
            description="",
            endpoints=[OfferEndpoint("metrics-endpoint", "prometheus_scrape", "provider")],
            active_connections=0,
            total_connections=1,
        )
    ]
    with patch.object(OffersScreen, "_fetch"), patch.object(OfferDetailScreen, "_fetch_consumers"):
        screen = OffersScreen("my-ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()
        screen._populate(offers)
        await pilot.pause()
        dt = screen.query_one(DataTable)
        # WHEN a row is selected
        screen.on_data_table_row_selected(
            DataTable.RowSelected(data_table=dt, cursor_row=0, row_key=RowKey("0"))
        )
        await pilot.pause()
    # THEN OfferDetailScreen is pushed
    assert isinstance(pilot.app.screen, OfferDetailScreen)


@pytest.mark.asyncio
async def test_offer_detail_screen_shows_fields(pilot):
    # GIVEN an OfferDetailScreen with _fetch_consumers patched
    offer = ControllerOfferInfo(
        model="cos",
        name="prom-scrape",
        offer_url="admin/cos.prom-scrape",
        application="prometheus",
        charm="ch:prometheus-k8s-1",
        description="Scrape metrics",
        endpoints=[OfferEndpoint("metrics-endpoint", "prometheus_scrape", "provider")],
        active_connections=2,
        total_connections=3,
    )
    with patch.object(OfferDetailScreen, "_fetch_consumers"):
        screen = OfferDetailScreen(offer, "my-ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()
    # WHEN we inspect the rendered labels
    labels = screen.query(Label)
    all_text = "\n".join(str(lbl.render()) for lbl in labels)
    # THEN key offer fields are visible
    assert "prom-scrape" in all_text
    assert "prometheus" in all_text
    assert "Scrape metrics" in all_text


@pytest.mark.asyncio
async def test_action_show_offers_pushes_screen(pilot):
    """Shift+O with a controller opens OffersScreen."""
    # GIVEN a controller is selected
    screen = pilot.app.screen
    screen._selected_controller = "my-ctrl"
    # WHEN action_show_offers is called
    with patch.object(OffersScreen, "_fetch"):
        screen.action_show_offers()
        await pilot.pause()
    # THEN OffersScreen is pushed
    assert isinstance(pilot.app.screen, OffersScreen)


@pytest.mark.asyncio
async def test_offer_detail_screen_populate_consumers(pilot):
    """_populate_consumers fills the connections table with SAASInfo rows."""
    # GIVEN an OfferDetailScreen with _fetch_consumers patched
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prometheus-k8s-1",
        description="",
        endpoints=[OfferEndpoint("metrics-endpoint", "prometheus_scrape", "provider")],
    )
    with patch.object(OfferDetailScreen, "_fetch_consumers"):
        screen = OfferDetailScreen(offer, "my-ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()
    consumers = [
        _ConsumerEntry(
            "ctrl-a",
            SAASInfo("monitoring", "prometheus-scrape", "active", "local", "admin/cos.prom"),
        ),
        _ConsumerEntry("ctrl-b", SAASInfo("prod", "metrics", "active", "local", "admin/cos.prom")),
    ]
    # WHEN _populate_consumers is called with two consumers
    screen._populate_consumers(consumers)
    await pilot.pause()
    # THEN the connections table has two rows
    conn_dt = screen.query_one("#connections-table", DataTable)
    assert conn_dt.row_count == 2


@pytest.mark.asyncio
async def test_offer_detail_fetch_consumers_scans_all_controllers(pilot):
    """_fetch_consumers scans models across all known controllers."""
    # GIVEN an OfferDetailScreen and mocked clients for two controllers
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prometheus-k8s-1",
        description="",
        endpoints=[OfferEndpoint("metrics-endpoint", "prometheus_scrape", "provider")],
    )
    consumer = SAASInfo("monitoring", "prom-scrape", "active", "local", "admin/cos.prom")
    mock_client_a = AsyncMock()
    mock_client_a.list_model_names = AsyncMock(return_value=["cos"])
    mock_client_a.get_saas = AsyncMock(return_value=[])
    mock_client_a.__aenter__ = AsyncMock(return_value=mock_client_a)
    mock_client_a.__aexit__ = AsyncMock(return_value=False)
    mock_client_b = AsyncMock()
    mock_client_b.list_model_names = AsyncMock(return_value=["monitoring"])
    mock_client_b.get_saas = AsyncMock(return_value=[consumer])
    mock_client_b.__aenter__ = AsyncMock(return_value=mock_client_b)
    mock_client_b.__aexit__ = AsyncMock(return_value=False)

    def _make_client(controller_name: str) -> AsyncMock:
        return mock_client_a if controller_name == "ctrl-a" else mock_client_b

    # Mount screen and call worker INSIDE the patch context so it runs with mocked deps
    with (
        patch(
            "jujumate.screens.offers_screen.load_config",
            return_value=JujuConfig(current_controller="ctrl-a", controllers=["ctrl-a", "ctrl-b"]),
        ),
        patch("jujumate.screens.offers_screen.JujuClient", side_effect=_make_client),
    ):
        screen = OfferDetailScreen(offer, "ctrl-a")
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN the consumer from ctrl-b is shown in the connections table
    conn_dt = screen.query_one("#connections-table", DataTable)
    assert conn_dt.row_count == 1


# ─────────────────────────────────────────────────────────────────────────────
# AppConfigScreen._fetch worker
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_app_config_screen_fetch_worker_success(pilot):
    """Lines 37-43: _fetch worker populates AppConfigView on success."""
    # GIVEN an AppConfigScreen with JujuClient patched to return entries
    ai = AppInfo("pg", "dev", "postgresql", "14/stable", 363)
    entries = [AppConfigEntry("port", "5432", "5432", "int", "Port", "default")]
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.get_app_config = AsyncMock(return_value=entries)

    with patch("jujumate.screens.app_config_screen.JujuClient", return_value=mock_client):
        screen = AppConfigScreen("ctrl", "dev", ai)
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN the config panel is visible (worker populated the view)
    assert screen.query_one(AppConfigView).query_one("#ac-panel").display is True


@pytest.mark.asyncio
async def test_app_config_screen_fetch_worker_error(pilot):
    """Lines 37-43: _fetch worker shows error on exception."""
    # GIVEN an AppConfigScreen with JujuClient that raises
    ai = AppInfo("pg", "dev", "postgresql", "14/stable", 363)
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(side_effect=Exception("connection refused"))
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("jujumate.screens.app_config_screen.JujuClient", return_value=mock_client):
        screen = AppConfigScreen("ctrl", "dev", ai)
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN the error panel is shown
    assert screen.query_one(AppConfigView).query_one("#ac-empty").display is True


# ─────────────────────────────────────────────────────────────────────────────
# RelationDataScreen._fetch worker
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_relation_data_screen_fetch_worker_success(pilot):
    """Lines 48-56: _fetch worker populates RelationDataView on success."""
    # GIVEN a RelationDataScreen with JujuClient patched to return entries
    rel = RelationInfo("dev", "pg:db", "wp:db", "pgsql", "regular", relation_id=1)
    entries = [RelationDataEntry("provider", "pg", "host", "10.0.0.1", "app")]
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.get_relation_data = AsyncMock(return_value=entries)

    with patch("jujumate.screens.relation_data_screen.JujuClient", return_value=mock_client):
        screen = RelationDataScreen("ctrl", "dev", rel)
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN the relation data panel is visible
    assert screen.query_one(RelationDataView).query_one("#rd-panel").display is True


@pytest.mark.asyncio
async def test_relation_data_screen_fetch_worker_error(pilot):
    """Lines 48-56: _fetch worker shows error on exception."""
    # GIVEN a RelationDataScreen with JujuClient that raises
    rel = RelationInfo("dev", "pg:db", "wp:db", "pgsql", "regular", relation_id=1)
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(side_effect=JujuError("failed"))
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("jujumate.screens.relation_data_screen.JujuClient", return_value=mock_client):
        screen = RelationDataScreen("ctrl", "dev", rel)
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN the error panel is shown
    assert screen.query_one(RelationDataView).query_one("#rd-empty").display is True


# ─────────────────────────────────────────────────────────────────────────────
# SecretDetailScreen._fetch worker
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_secret_detail_screen_fetch_worker_success(pilot):
    """Lines 89-100: _fetch worker populates list on success."""
    # GIVEN a SecretInfo and JujuClient that returns content
    secret = SecretInfo(
        uri="secret:abc123",
        label="db-pass",
        owner="app",
        description="",
        revision=1,
        rotate_policy="",
        created="2024-01-01",
        updated="2024-01-01",
    )
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.get_secret_content = AsyncMock(return_value={"password": "s3cr3t"})

    with patch("jujumate.screens.secrets_screen.JujuClient", return_value=mock_client):
        screen = SecretDetailScreen("ctrl", "dev", secret)
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN loading is hidden and secret-data is shown
    assert screen.query_one("#secret-loading").display is False
    assert screen.query_one("#secret-data").display is True


@pytest.mark.asyncio
async def test_secret_detail_screen_fetch_worker_empty_content(pilot):
    """Lines 72-74: _populate_list shows placeholder when data is empty."""
    # GIVEN a JujuClient that returns empty dict
    secret = SecretInfo(
        uri="secret:empty1",
        label="empty-secret",
        owner="app",
        description="",
        revision=1,
        rotate_policy="",
        created="2024-01-01",
        updated="2024-01-01",
    )
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.get_secret_content = AsyncMock(return_value={})

    with patch("jujumate.screens.secrets_screen.JujuClient", return_value=mock_client):
        screen = SecretDetailScreen("ctrl", "dev", secret)
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN loading is hidden and list shows the placeholder item
    assert screen.query_one("#secret-loading").display is False
    lv = screen.query_one("#secret-data", ListView)
    assert lv.display is True
    assert len(lv) == 1


@pytest.mark.asyncio
async def test_secret_detail_screen_fetch_worker_error(pilot):
    """Lines 98-100: _fetch worker updates loading label on exception."""
    # GIVEN a JujuClient that raises
    secret = SecretInfo(
        uri="secret:err1",
        label="err-secret",
        owner="app",
        description="",
        revision=1,
        rotate_policy="",
        created="2024-01-01",
        updated="2024-01-01",
    )
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(side_effect=JujuError("forbidden"))
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("jujumate.screens.secrets_screen.JujuClient", return_value=mock_client):
        screen = SecretDetailScreen("ctrl", "dev", secret)
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN loading label shows an error message
    loading = screen.query_one("#secret-loading", Label)
    assert loading.display is True
    assert "Error" in str(loading.render())


@pytest.mark.asyncio
async def test_secret_detail_screen_list_view_highlighted_adds_class(pilot):
    """Lines 103-106: on_list_view_highlighted adds kv-selected class to item."""
    # GIVEN a mounted SecretDetailScreen with _fetch patched
    secret = SecretInfo(
        uri="secret:hl1",
        label="hl-secret",
        owner="app",
        description="",
        revision=1,
        rotate_policy="",
        created="2024-01-01",
        updated="2024-01-01",
    )
    with patch.object(SecretDetailScreen, "_fetch"):
        screen = SecretDetailScreen("ctrl", "dev", secret)
        await pilot.app.push_screen(screen)
        await pilot.pause()

    # WHEN a list item is appended and a Highlighted event is fired
    lv = screen.query_one("#secret-data", ListView)
    item = ListItem(Label("test-key   test-val"))
    await lv.append(item)
    await pilot.pause()
    screen.on_list_view_highlighted(ListView.Highlighted(list_view=lv, item=item))
    await pilot.pause()

    # THEN the item has the kv-selected class
    assert "kv-selected" in item.classes


@pytest.mark.asyncio
async def test_secret_detail_screen_action_copy_value_no_display(pilot):
    """Lines 109-110: action_copy_value returns early when list is not displayed."""
    # GIVEN a SecretDetailScreen with _fetch patched and list hidden
    secret = SecretInfo(
        uri="secret:copy1",
        label="copy-secret",
        owner="app",
        description="",
        revision=1,
        rotate_policy="",
        created="2024-01-01",
        updated="2024-01-01",
    )
    with patch.object(SecretDetailScreen, "_fetch"):
        screen = SecretDetailScreen("ctrl", "dev", secret)
        await pilot.app.push_screen(screen)
        await pilot.pause()

    pilot.app.copy_to_clipboard = MagicMock()
    lv = screen.query_one("#secret-data", ListView)
    lv.display = False

    # WHEN action_copy_value is called
    screen.action_copy_value()

    # THEN copy_to_clipboard is not called (early return)
    pilot.app.copy_to_clipboard.assert_not_called()


@pytest.mark.asyncio
async def test_secret_detail_screen_action_copy_value_with_data(pilot):
    """Lines 113-118: action_copy_value copies the selected key's value."""
    # GIVEN a SecretDetailScreen with content data set
    secret = SecretInfo(
        uri="secret:copy2",
        label="copy-secret2",
        owner="app",
        description="",
        revision=1,
        rotate_policy="",
        created="2024-01-01",
        updated="2024-01-01",
    )
    with patch.object(SecretDetailScreen, "_fetch"):
        screen = SecretDetailScreen("ctrl", "dev", secret)
        await pilot.app.push_screen(screen)
        await pilot.pause()

    screen._content_data = {"password": "s3cr3t"}
    lv = screen.query_one("#secret-data", ListView)
    item = ListItem(Label("password"))
    await lv.append(item)
    await pilot.pause()
    lv.display = True
    # Move focus to index 0
    lv.index = 0
    await pilot.pause()

    pilot.app.copy_to_clipboard = MagicMock()
    screen.notify = MagicMock()

    # WHEN action_copy_value is called
    screen.action_copy_value()

    # THEN copy_to_clipboard is called with the secret value
    pilot.app.copy_to_clipboard.assert_called_once_with("s3cr3t")


# ─────────────────────────────────────────────────────────────────────────────
# SecretsScreen._fetch worker
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_secrets_screen_fetch_worker_error(pilot):
    """Lines 148-154: _fetch worker shows error when JujuClient raises."""
    # GIVEN a SecretsScreen with JujuClient that raises
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(side_effect=JujuError("no model"))
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("jujumate.screens.secrets_screen.JujuClient", return_value=mock_client):
        screen = SecretsScreen("ctrl", "dev")
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN loading label is visible with error text
    loading = screen.query_one("#secrets-loading")
    assert loading.display is True


# ─────────────────────────────────────────────────────────────────────────────
# OfferDetailScreen — no-endpoints placeholder and consumer edge cases
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_offer_detail_screen_no_endpoints_shows_placeholder(pilot):
    """Line 112: endpoints table shows placeholder row when offer has no endpoints."""
    # GIVEN an OfferDetailScreen with an offer that has no endpoints
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[],
    )

    with patch.object(OfferDetailScreen, "_fetch_consumers"):
        screen = OfferDetailScreen(offer, "ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()

    # THEN endpoints table has one row with placeholder "—"
    ep_dt = screen.query_one("#endpoints-table", DataTable)
    assert ep_dt.row_count == 1


@pytest.mark.asyncio
async def test_offer_detail_fetch_consumers_inner_exception_caught(pilot):
    """Lines 139-147: inner exception from get_saas is caught and logged."""
    # GIVEN an OfferDetailScreen and a client where get_saas raises JujuError
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[OfferEndpoint("metrics", "prom_scrape", "provider")],
    )
    with patch.object(OfferDetailScreen, "_fetch_consumers"):
        screen = OfferDetailScreen(offer, "ctrl-a")
        await pilot.app.push_screen(screen)
        await pilot.pause()

    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.list_model_names = AsyncMock(return_value=["monitoring"])
    mock_client.get_saas = AsyncMock(side_effect=JujuError("no saas"))

    with (
        patch(
            "jujumate.screens.offers_screen.load_config",
            return_value=JujuConfig(current_controller="ctrl-a", controllers=["ctrl-a"]),
        ),
        patch("jujumate.screens.offers_screen.JujuClient", return_value=mock_client),
    ):
        # WHEN _fetch_consumers is called (inner get_saas raises)
        screen._fetch_consumers(screen._controller_name, screen._offer)
        for _ in range(10):
            await pilot.pause()

    # THEN no exception propagates and the consumers section shows "No known consumers"
    loading = screen.query_one("#consumers-loading")
    assert loading.display is True


@pytest.mark.asyncio
async def test_offer_detail_screen_populate_consumers_no_consumers(pilot):
    """Lines 163-164: _populate_consumers shows 'No known consumers' when list is empty."""
    # GIVEN a mounted OfferDetailScreen with _fetch_consumers patched
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[OfferEndpoint("metrics", "prom_scrape", "provider")],
    )
    with patch.object(OfferDetailScreen, "_fetch_consumers"):
        screen = OfferDetailScreen(offer, "ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()

    # WHEN _populate_consumers is called with an empty list
    screen._populate_consumers([])
    await pilot.pause()

    # THEN the loading label shows "No known consumers."
    loading = screen.query_one("#consumers-loading")
    assert loading.display is True
    assert "No known consumers" in str(loading.render())


# ─────────────────────────────────────────────────────────────────────────────
# OffersScreen._fetch worker
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_offers_screen_fetch_worker_success(pilot):
    """Lines 193-199: _fetch worker populates table on success."""
    # GIVEN an OffersScreen with JujuClient patched to return offers
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[OfferEndpoint("metrics", "prom_scrape", "provider")],
        active_connections=1,
        total_connections=1,
    )
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.get_controller_offers = AsyncMock(return_value=[offer])

    with (
        patch("jujumate.screens.offers_screen.JujuClient", return_value=mock_client),
        patch.object(OfferDetailScreen, "_fetch_consumers"),
    ):
        screen = OffersScreen("ctrl")
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN the table has one row
    dt = screen.query_one("#offers-table", DataTable)
    assert dt.row_count == 1


@pytest.mark.asyncio
async def test_offers_screen_fetch_worker_error(pilot):
    """Lines 193-199: _fetch worker shows error when JujuClient raises."""
    # GIVEN an OffersScreen with JujuClient that raises
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(side_effect=Exception("connection refused"))
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("jujumate.screens.offers_screen.JujuClient", return_value=mock_client):
        screen = OffersScreen("ctrl")
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN loading label is visible
    loading = screen.query_one("#offers-loading")
    assert loading.display is True


@pytest.mark.asyncio
async def test_offers_screen_populate_all_active_connections(pilot):
    """Line 216: _populate colors connections green when active == total > 0."""
    # GIVEN an OffersScreen with _fetch patched
    with patch.object(OffersScreen, "_fetch"):
        screen = OffersScreen("ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()

    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[OfferEndpoint("metrics", "prom_scrape", "provider")],
        active_connections=2,
        total_connections=2,
    )

    # WHEN _populate is called with all connections active
    screen._populate([offer])
    await pilot.pause()

    # THEN the table has exactly one row
    dt = screen.query_one("#offers-table", DataTable)
    assert dt.row_count == 1


@pytest.mark.asyncio
async def test_offers_screen_populate_partial_active_connections(pilot):
    """_populate colors connections yellow when active < total."""
    # GIVEN an OffersScreen with _fetch patched
    with patch.object(OffersScreen, "_fetch"):
        screen = OffersScreen("ctrl")
        await pilot.app.push_screen(screen)
        await pilot.pause()

    offer = ControllerOfferInfo(
        model="cos",
        name="prom2",
        offer_url="admin/cos.prom2",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[OfferEndpoint("metrics", "prom_scrape", "provider")],
        active_connections=1,
        total_connections=3,
    )

    # WHEN _populate is called with partial active connections
    screen._populate([offer])
    await pilot.pause()

    # THEN the table has exactly one row
    dt = screen.query_one("#offers-table", DataTable)
    assert dt.row_count == 1


# ─────────────────────────────────────────────────────────────────────────────
# ThemeScreen
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_theme_screen_on_list_view_selected_saves_and_dismisses(pilot):
    """Lines 74-78: on_list_view_selected saves theme and dismisses the screen."""
    # GIVEN a ThemeScreen is pushed
    await pilot.app.push_screen(ThemeScreen())
    await pilot.pause()
    screen = pilot.app.screen
    assert isinstance(screen, ThemeScreen)
    lv = screen.query_one("#theme-list", ListView)

    # WHEN a list item is selected (simulate by calling handler directly)
    item = lv.highlighted_child
    if item is None:
        # Fallback: use first child
        item = next(iter(lv.query(ListItem)), None)

    with patch("jujumate.screens.theme_screen.save_theme") as mock_save:
        if item is not None:
            screen.on_list_view_selected(ListView.Selected(list_view=lv, item=item, index=0))
        await pilot.pause()

    # THEN save_theme was called and ThemeScreen was dismissed
    if item is not None:
        mock_save.assert_called_once()
    assert not isinstance(pilot.app.screen, ThemeScreen)


@pytest.mark.asyncio
async def test_theme_screen_action_cancel_restores_original_theme(pilot):
    """Lines 81-83: action_cancel restores original theme and dismisses."""
    # GIVEN a ThemeScreen pushed with an original theme set
    await pilot.app.push_screen(ThemeScreen())
    await pilot.pause()
    screen = pilot.app.screen
    assert isinstance(screen, ThemeScreen)
    screen._original_theme = pilot.app.theme or "dark"

    # WHEN action_cancel is called
    with patch.object(pilot.app, "switch_theme") as mock_switch:
        screen.action_cancel()
        await pilot.pause()

    # THEN switch_theme was called with the original theme and screen was dismissed
    mock_switch.assert_called_once_with(screen._original_theme)
    assert not isinstance(pilot.app.screen, ThemeScreen)


# ─────────────────────────────────────────────────────────────────────────────
# SecretsScreen._fetch worker — success path
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_secrets_screen_fetch_worker_success(pilot):
    """Lines 150-151: _fetch worker populates table on success."""
    # GIVEN a SecretsScreen with JujuClient that returns a secret
    secret = SecretInfo(
        uri="secret:abc123",
        label="db-pass",
        owner="application",
        description="DB password",
        revision=1,
        rotate_policy="never",
        created="2024-01-01",
        updated="2024-01-01",
    )
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.get_secrets = AsyncMock(return_value=[secret])

    with patch("jujumate.screens.secrets_screen.JujuClient", return_value=mock_client):
        screen = SecretsScreen("ctrl", "dev")
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN the secrets table is shown with one row
    dt = screen.query_one("#secrets-table", DataTable)
    assert dt.display is True
    assert dt.row_count == 1


# ─────────────────────────────────────────────────────────────────────────────
# OfferDetailScreen._fetch_consumers — outer controller exception (lines 146-147)
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_offer_detail_fetch_consumers_outer_exception_caught(pilot):
    """Lines 146-147: outer exception (controller unreachable) is caught and logged."""
    # GIVEN an OfferDetailScreen and a client whose __aenter__ raises OSError
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[OfferEndpoint("metrics", "prom_scrape", "provider")],
    )
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(side_effect=OSError("connection refused"))
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with (
        patch(
            "jujumate.screens.offers_screen.load_config",
            return_value=JujuConfig(current_controller="ctrl-a", controllers=["ctrl-a"]),
        ),
        patch("jujumate.screens.offers_screen.JujuClient", return_value=mock_client),
    ):
        screen = OfferDetailScreen(offer, "ctrl-a")
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN no exception propagates and consumers section shows "No known consumers"
    loading = screen.query_one("#consumers-loading")
    assert loading.display is True


# ─────────────────────────────────────────────────────────────────────────────
# OfferDetailScreen._fetch_consumers — JujuConfigError falls back to current controller
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_offer_detail_fetch_consumers_config_error_fallback(pilot):
    """Lines 126-127: when load_config raises JujuConfigError, fallback to current controller."""
    # GIVEN an OfferDetailScreen and a client that returns an empty SAAS list
    offer = ControllerOfferInfo(
        model="cos",
        name="prom",
        offer_url="admin/cos.prom",
        application="prometheus",
        charm="ch:prom-1",
        description="",
        endpoints=[OfferEndpoint("metrics", "prom_scrape", "provider")],
    )
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.list_model_names = AsyncMock(return_value=["cos"])
    mock_client.get_saas = AsyncMock(return_value=[])

    with (
        patch(
            "jujumate.screens.offers_screen.load_config",
            side_effect=JujuConfigError("no config"),
        ),
        patch("jujumate.screens.offers_screen.JujuClient", return_value=mock_client),
    ):
        # WHEN the screen is pushed (auto-starts _fetch_consumers)
        screen = OfferDetailScreen(offer, "ctrl-a")
        await pilot.app.push_screen(screen)
        for _ in range(10):
            await pilot.pause()

    # THEN worker completed normally (fallback to single controller, no consumers found)
    conn_dt = screen.query_one("#connections-table", DataTable)
    assert conn_dt.row_count == 0


# ─────────────────────────────────────────────────────────────────────────────
# on_models_updated / on_units_updated — health tab branch
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_models_updated_refreshes_health_view_when_health_tab_active(pilot):
    # GIVEN the Health tab is active and there is a blocked model
    screen = pilot.app.screen
    screen.action_switch_tab("tab-health")
    await pilot.pause()

    # WHEN on_models_updated is received
    screen.on_models_updated(
        ModelsUpdated(models=[ModelInfo("broken", "ctrl", "aws", "", "blocked")])
    )
    await pilot.pause()

    # THEN the health view data is updated
    assert any(m.name == "broken" for m in screen._all_models)


@pytest.mark.asyncio
async def test_models_updated_refreshes_status_view_when_status_tab_active(pilot):
    # GIVEN the Status tab is active
    screen = pilot.app.screen
    screen.action_switch_tab("tab-status")
    await pilot.pause()

    # WHEN on_models_updated is received
    screen.on_models_updated(
        ModelsUpdated(models=[ModelInfo("my-model", "ctrl", "aws", "", "active")])
    )
    await pilot.pause()

    # THEN the model list is updated
    assert any(m.name == "my-model" for m in screen._all_models)


@pytest.mark.asyncio
async def test_units_updated_refreshes_health_view_when_health_tab_active(pilot):
    # GIVEN the Health tab is active with a model that has an unhealthy unit
    screen = pilot.app.screen
    screen._all_models = [ModelInfo("dev", "ctrl", "aws", "", "active")]
    screen._all_apps = [
        AppInfo("pg", "dev", "pg", "14/stable", 1, controller="ctrl", status="blocked")
    ]
    screen.action_switch_tab("tab-health")
    await pilot.pause()

    # WHEN on_units_updated is received
    screen.on_units_updated(
        UnitsUpdated(
            units=[UnitInfo("pg/0", "pg", "0", "blocked", "idle", controller="ctrl", model="dev")]
        )
    )
    await pilot.pause()

    # THEN the units are stored and health view was refreshed
    assert any(u.name == "pg/0" for u in screen._all_units)


@pytest.mark.asyncio
async def test_units_updated_refreshes_status_view_when_status_tab_active(pilot):
    # GIVEN the Status tab is active
    screen = pilot.app.screen
    screen.action_switch_tab("tab-status")
    await pilot.pause()

    # WHEN on_units_updated is received
    screen.on_units_updated(
        UnitsUpdated(
            units=[UnitInfo("pg/0", "pg", "0", "active", "idle", controller="ctrl", model="dev")]
        )
    )
    await pilot.pause()

    # THEN the units are stored
    assert any(u.name == "pg/0" for u in screen._all_units)
