# #region Test.LLMAnalysisService.Coverage [C:3] [TYPE Module] [SEMANTICS test,llm,analysis,coverage,edge]
# @BRIEF Additional coverage tests for service.py — _launch_and_login, capture_dashboard_chunks,
#   capture_dashboard, LLMClient edge cases, DatasetHealthChecker edge cases.
# @RELATION BINDS_TO -> [LLMAnalysisService]
# @TEST_EDGE: launch_login_direct_form -> Direct form login fallback (no username field found)
# @TEST_EDGE: launch_login_redirect_to_login -> Dashboard navigation redirects to login page
# @TEST_EDGE: chunks_cdp_fallback -> CDP capture fails, falls back to Playwright
# @TEST_EDGE: chunks_no_tabs -> No tabs found, captures full page as single chunk
# @TEST_EDGE: json_completion_rate_limit -> RateLimit error triggers retry delay parsing
# @TEST_EDGE: json_completion_json_mode_fallback -> JSON mode fails, falls back to plain
# @TEST_EDGE: health_chart_data_list_result -> chart data returns list instead of dict
import base64
import json
import os
import ssl
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock, ANY

import pytest
from PIL import Image

from src.plugins.llm_analysis.models import LLMProviderType


# ═══════════════════════════════════════════════════════════════════
# ScreenshotService — remaining edge cases
# ═══════════════════════════════════════════════════════════════════

class TestFindFirstVisibleLocator:
    """Verify _find_first_visible_locator edge cases."""

    @pytest.mark.asyncio
    async def test_all_candidates_raise(self):
        """All candidates raise exceptions, returns None."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())

        mock_loc = MagicMock()
        mock_loc.count = AsyncMock(side_effect=Exception("count failed"))
        result = await svc._find_first_visible_locator([mock_loc, mock_loc])
        assert result is None

    @pytest.mark.asyncio
    async def test_first_visible_returns_early(self):
        """Returns first visible locator without checking rest."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())

        mock_visible = MagicMock()
        mock_visible.count = AsyncMock(return_value=1)
        mock_visible.nth.return_value = mock_visible
        mock_visible.is_visible = AsyncMock(return_value=True)

        result = await svc._find_first_visible_locator([mock_visible])
        assert result is not None

    @pytest.mark.asyncio
    async def test_none_visible_returns_none(self):
        """No visible candidates returns None."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())

        mock_invisible = MagicMock()
        mock_invisible.count = AsyncMock(return_value=1)
        mock_invisible.nth.return_value = mock_invisible
        mock_invisible.is_visible = AsyncMock(return_value=False)

        result = await svc._find_first_visible_locator([mock_invisible])
        assert result is None


class TestIterLoginRoots:
    """Verify _iter_login_roots remaining edge cases."""

    def test_duplicate_frames_skipped(self):
        """Frame already in roots is skipped."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())
        page = MagicMock()
        page.frames = [page]  # page itself is a frame
        roots = svc._iter_login_roots(page)
        assert len(roots) >= 1


class TestExtractHiddenLoginFields:
    """Verify _extract_hidden_login_fields edge cases."""

    @pytest.mark.asyncio
    async def test_locator_exception_handled(self):
        """Exception during locator iteration is caught and skipped."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())

        mock_page = MagicMock()
        mock_locator = MagicMock()
        mock_page.locator.return_value = mock_locator
        mock_locator.count = AsyncMock(side_effect=Exception("locator error"))

        result = await svc._extract_hidden_login_fields(mock_page)
        assert result == {}


class TestSubmitLoginViaFormPost:
    """Verify _submit_login_via_form_post remaining edge cases."""

    @pytest.mark.asyncio
    async def test_request_context_exception(self):
        """Exception getting request context returns False."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())
        svc.env = MagicMock()

        mock_page = MagicMock()
        # Make page.context raise
        type(mock_page).context = PropertyMock(side_effect=Exception("no context"))

        with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
            result = await svc._submit_login_via_form_post(mock_page, "https://example.com/login/")
            assert result is False

    @pytest.mark.asyncio
    async def test_empty_origin_fallback(self):
        """When parsed URL has no scheme/netloc, uses login_url as origin."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())
        svc.env = MagicMock()
        svc.env.username = "admin"
        svc.env.password = "pass"

        mock_page = MagicMock()
        mock_request = MagicMock()
        mock_page.context.request = mock_request
        mock_response = MagicMock()
        mock_response.url = ""
        mock_response.status = 200
        mock_response.headers = {}
        mock_response.text = AsyncMock(return_value="<html>ok</html>")
        mock_request.post = AsyncMock(return_value=mock_response)

        with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
            result = await svc._submit_login_via_form_post(mock_page, "http://localhost/login/")
            assert result is True


class TestCleanupTempFiles:
    """Verify _cleanup_temp_files remaining edge cases."""

    def test_cleanup_exception_handled(self):
        """Exception during delete is caught."""
        from src.plugins.llm_analysis.service import ScreenshotService
        with patch('os.path.exists', return_value=True), patch('os.remove', side_effect=PermissionError("denied")):
            ScreenshotService._cleanup_temp_files(["/tmp/test.png"])  # should not raise


# ═══════════════════════════════════════════════════════════════════
# _launch_and_login — mock internal methods for robust testing
# ═══════════════════════════════════════════════════════════════════

class TestLaunchAndLogin:
    """Verify _launch_and_login via method-level patching."""

    def _make_env(self):
        env = MagicMock()
        env.url = "https://superset.example.com"
        env.username = "admin"
        env.password = "pass123"
        return env

    def _make_basic_mocks(self):
        """Create basic Playwright mocks."""
        mock_page = MagicMock()
        mock_page.frames = []
        mock_page.url = "https://superset.example.com/superset/dashboard/42/"
        mock_page.goto = AsyncMock()
        mock_page.wait_for_load_state = AsyncMock()
        mock_page.wait_for_selector = AsyncMock()
        mock_page.wait_for_function = AsyncMock()
        mock_page.evaluate = AsyncMock()
        mock_page.add_init_script = AsyncMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.locator = MagicMock()
        mock_page.locator.count = AsyncMock(return_value=0)

        mock_context = MagicMock()
        mock_context.new_page = AsyncMock(return_value=mock_page)

        mock_browser = MagicMock()
        mock_browser.new_context = AsyncMock(return_value=mock_context)

        mock_playwright = MagicMock()
        mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)

        return mock_page, mock_context, mock_browser, mock_playwright

    @pytest.mark.asyncio
    async def test_launch_login_success(self):
        """Happy path via patched internal methods."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        # Patch internal methods
        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                browser, context, page = await svc._launch_and_login(mock_playwright, "42")
                assert page is not None
                username_loc.fill.assert_called_once_with("admin")
                password_loc.fill.assert_called_once_with("pass123")
                submit_loc.click.assert_called_once()

    @pytest.mark.asyncio
    async def test_launch_login_direct_form(self):
        """No username field → direct form login fallback."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(return_value=None)):
            with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=True)):
                browser, context, page = await svc._launch_and_login(mock_playwright, "42")
                assert page is not None

    @pytest.mark.asyncio
    async def test_launch_login_direct_form_fails(self):
        """No username field and form login fails → RuntimeError."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(return_value=None)):
            with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=False)):
                with pytest.raises(RuntimeError, match="Could not find username input field"):
                    await svc._launch_and_login(mock_playwright, "42")

    @pytest.mark.asyncio
    async def test_username_found_password_not_found(self):
        """Username field found but no password → RuntimeError."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, None])):
            with pytest.raises(RuntimeError, match="Could not find password input field"):
                await svc._launch_and_login(mock_playwright, "42")

    @pytest.mark.asyncio
    async def test_username_found_submit_not_found(self):
        """Username+password found but no submit → RuntimeError."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=None)):
                with pytest.raises(RuntimeError, match="Could not find submit button"):
                    await svc._launch_and_login(mock_playwright, "42")

    @pytest.mark.asyncio
    async def test_login_still_on_login_page(self):
        """After submit, still on /login page → RuntimeError."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()
        mock_page.url = "https://superset.example.com/login/"

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                with patch.object(svc, '_goto_resilient', new=AsyncMock()):
                    with pytest.raises(RuntimeError, match="Login failed"):
                        await svc._launch_and_login(mock_playwright, "42")

    @pytest.mark.asyncio
    async def test_dashboard_nav_redirects_to_login(self):
        """Dashboard navigation ends up on /login → RuntimeError."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        # Make page.url return /login/ after the second navigation
        url_values = [
            "https://superset.example.com/superset/dashboard/42/",  # during login
            "https://superset.example.com/login/",                  # after dashboard goto
        ]
        type(mock_page).url = PropertyMock(side_effect=lambda: url_values.pop(0))

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                with patch.object(svc, '_goto_resilient', new=AsyncMock()):
                    with pytest.raises(RuntimeError, match="Dashboard navigation redirected to login page"):
                        await svc._launch_and_login(mock_playwright, "42")

    @pytest.mark.asyncio
    async def test_launch_with_parsed_context(self):
        """Parsed context with native_filters and activeTabs."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                parsed_context = {"native_filters": "!((ekf:v))", "activeTabs": "tab-1"}
                browser, context, page = await svc._launch_and_login(
                    mock_playwright, "42", parsed_context=parsed_context,
                )
                assert page is not None

    @pytest.mark.asyncio
    async def test_login_exception_wrapped(self):
        """Unexpected login exception is wrapped in RuntimeError."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=ValueError("unexpected"))):
            with pytest.raises(RuntimeError, match="Login failed"):
                await svc._launch_and_login(mock_playwright, "42")

    @pytest.mark.asyncio
    async def test_https_upgrade_path(self):
        """HTTP dashboard_url upgraded to HTTPS when base is HTTPS."""
        from src.plugins.llm_analysis.service import ScreenshotService

        env = MagicMock()
        env.url = "https://superset.example.com"
        env.username = "admin"
        env.password = "pass"

        svc = ScreenshotService(env)
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                browser, context, page = await svc._launch_and_login(mock_playwright, "42")
                assert page is not None


# ═══════════════════════════════════════════════════════════════════
# capture_dashboard_chunks — simpler approach via _launch_and_login mock
# ═══════════════════════════════════════════════════════════════════

class TestCaptureDashboardChunks:
    """Verify capture_dashboard_chunks via method-level patching."""

    def _make_env(self):
        env = MagicMock()
        env.url = "https://superset.example.com"
        env.username = "admin"
        env.password = "pass"
        return env

    @pytest.mark.asyncio
    async def test_capture_with_tabs(self):
        """Happy path: tabs found, each tab captured via CDP."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        # Mock _launch_and_login to return browser, context, page
        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        mock_context = MagicMock()
        mock_cdp = MagicMock()
        mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
        mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        # Tab with unique class so it's not "ant-tabs-tab-active"
        tab_mock = MagicMock()
        tab_mock.inner_text = AsyncMock(return_value="Tab 1")
        tab_mock.is_visible = AsyncMock(return_value=True)
        tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab")
        tab_mock.click = AsyncMock()

        # Locator returns tabs list
        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])

        # Mock wait_for_charts_stabilized and wait_for_resize_rendered
        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        assert len(results) >= 1
                        assert results[0]["tab_name"] == "Tab 1"

    @pytest.mark.asyncio
    async def test_capture_no_tabs(self):
        """No tabs found → captures full page as single chunk."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        mock_context = MagicMock()
        mock_cdp = MagicMock()
        mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
        mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        # No tabs
        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[])

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        assert len(results) == 1
                        assert results[0]["tab_name"] == "full"

    @pytest.mark.asyncio
    async def test_capture_cdp_fallback(self):
        """CDP fails → falls back to Playwright screenshot."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        mock_context = MagicMock()
        mock_context.new_cdp_session = AsyncMock(side_effect=Exception("CDP unavailable"))

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        tab_mock = MagicMock()
        tab_mock.inner_text = AsyncMock(return_value="Tab A")
        tab_mock.is_visible = AsyncMock(return_value=True)
        tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab-active")
        tab_mock.click = AsyncMock()

        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        assert len(results) >= 1

    @pytest.mark.asyncio
    async def test_tab_processing_exception_skipped(self):
        """Exception during tab processing is logged and skipped."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        mock_context = MagicMock()
        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        # Tab that raises on inner_text
        bad_tab = MagicMock()
        bad_tab.inner_text = AsyncMock(side_effect=Exception("tab error"))
        bad_tab.is_visible = AsyncMock(return_value=True)

        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[bad_tab])

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with tempfile.TemporaryDirectory() as tmp:
                    results = await svc.capture_dashboard_chunks("42", tmp)
                    # Tabs found but processing failed, then no-tab fallback triggers
                    assert len(results) == 1
                    assert results[0]["tab_name"] == "full"

    @pytest.mark.asyncio
    async def test_duplicate_tabs_skipped(self):
        """Same tab_id processed only once. Prevent recursion by returning no tabs after first lookup."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        mock_context = MagicMock()
        mock_cdp = MagicMock()
        mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
        mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        tab_mock = MagicMock()
        tab_mock.inner_text = AsyncMock(return_value="SameTab")
        tab_mock.is_visible = AsyncMock(return_value=True)
        tab_mock.get_attribute = AsyncMock(return_value="")
        tab_mock.click = AsyncMock()

        # Return tabs only for the first level call, empty for recursion
        call_count = [0]
        async def all_side(*args, **kwargs):
            call_count[0] += 1
            if call_count[0] == 1:
                return [tab_mock, tab_mock]
            return []

        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(side_effect=all_side)
        mock_page.locator.return_value.count = AsyncMock(return_value=0)

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        # Two tabs at depth 0 (different indices → different tab_ids) → 2 results
                        # No recursion since all_side returns empty after first call
                        assert len(results) == 2


# ═══════════════════════════════════════════════════════════════════
# capture_dashboard — remaining paths
# ═══════════════════════════════════════════════════════════════════

class TestCaptureDashboard:
    """Verify capture_dashboard remaining branches."""

    @pytest.mark.asyncio
    async def test_capture_dashboard_full_flow(self):
        """Happy path: chunks captured, converted, archived."""
        from src.plugins.llm_analysis.service import ScreenshotService

        with tempfile.TemporaryDirectory() as tmp:
            svc = ScreenshotService(MagicMock())

            png_path = os.path.join(tmp, "test.png")
            Image.new("RGB", (100, 100)).save(png_path, "PNG")

            with patch.object(svc, 'capture_dashboard_chunks', AsyncMock(return_value=[
                {"tab_name": "Tab1", "path": png_path},
            ])):
                with patch.object(ScreenshotService, '_convert_screenshots_for_llm', return_value=[
                    os.path.join(tmp, "test_llm.jpg"),
                ]):
                    with patch.object(ScreenshotService, '_archive_screenshots_as_webp', return_value=[
                        {"original": png_path, "webp_path": os.path.join(tmp, "test.webp")},
                    ]):
                        jpeg_paths, archive_results = await svc.capture_dashboard("42", os.path.join(tmp, "output.png"))
                        assert len(jpeg_paths) == 1
                        assert len(archive_results) == 1

    @pytest.mark.asyncio
    async def test_capture_no_screenshots_returns_empty(self):
        """No screenshots captured → returns empty lists."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(MagicMock())

        with patch.object(svc, 'capture_dashboard_chunks', AsyncMock(return_value=[])):
            with tempfile.TemporaryDirectory() as tmp:
                jpeg_paths, archive_results = await svc.capture_dashboard("42", os.path.join(tmp, "out.png"))
                assert jpeg_paths == []
                assert archive_results == []


# ═══════════════════════════════════════════════════════════════════
# LLMClient — remaining edge cases
# ═══════════════════════════════════════════════════════════════════

class TestLLMClientEdgeCases:
    """Verify LLMClient remaining branches."""

    def test_ssl_verify_ca_dir_nonexistent(self):
        """When /etc/ssl/certs doesn't exist, still returns SSLContext."""
        from src.plugins.llm_analysis.service import LLMClient

        with patch.dict(os.environ, {}, clear=True):
            with patch('os.path.isdir', return_value=False):
                result = LLMClient._get_ssl_verify()
                assert isinstance(result, ssl.SSLContext)

    def test_optimize_images_fallback_to_raw(self):
        """When _reduce_image_quality fails, falls back to raw base64."""
        from src.plugins.llm_analysis.service import LLMClient

        with tempfile.TemporaryDirectory() as tmp:
            png_path = os.path.join(tmp, "test.png")
            Image.new("RGB", (100, 100)).save(png_path, "PNG")

            client = MagicMock()
            with patch.object(LLMClient, '_reduce_image_quality', side_effect=Exception("corrupt")):
                result = LLMClient._optimize_images(client, [png_path], 1024, 60)
                assert len(result) == 1
                assert isinstance(result[0], str)

    def test_deduplicate_preserves_order(self):
        """_deduplicate_issues preserves original order."""
        from src.plugins.llm_analysis.service import LLMClient
        client = MagicMock()
        issues = [
            {"severity": "HIGH", "message": "A", "location": "loc1"},
            {"severity": "LOW", "message": "B", "location": "loc2"},
            {"severity": "HIGH", "message": "A", "location": "loc1"},
            {"severity": "MED", "message": "C", "location": "loc3"},
        ]
        result = LLMClient._deduplicate_issues(client, issues)
        assert len(result) == 3
        assert result[0]["message"] == "A"
        assert result[1]["message"] == "B"
        assert result[2]["message"] == "C"

    def test_estimate_payload_size_edge_cases(self):
        """_estimate_payload_size with various inputs."""
        from src.plugins.llm_analysis.service import LLMClient
        # Zero images, zero text
        result = LLMClient._estimate_payload_size([], 0, 128000)
        assert result["estimated_tokens"] == 0
        assert result["exceeds_limit"] is False

        # Exactly at 80% limit
        result = LLMClient._estimate_payload_size([], 409600, 128000)
        assert abs(result["pct_of_limit"] - 80.0) < 0.1

    @pytest.mark.asyncio
    async def test_get_json_completion_rate_limit(self):
        """Rate limit error handled in get_json_completion."""
        from src.plugins.llm_analysis.service import LLMClient
        from openai import RateLimitError

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                rate_err = RateLimitError(
                    "rate_limit",
                    response=MagicMock(),
                    body={},
                )
                mock_success = MagicMock()
                mock_success.choices = [MagicMock()]
                mock_success.choices[0].message.content = '{"status": "PASS"}'

                mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
                    rate_err,
                    mock_success,
                ])

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    result = await real.get_json_completion([{"role": "user", "content": "test"}])
                    assert result["status"] == "PASS"

    @pytest.mark.asyncio
    async def test_get_json_completion_rate_limit_with_retry_delay_in_body(self):
        """Rate limit error with google.rpc.RetryInfo in body."""
        from src.plugins.llm_analysis.service import LLMClient
        from openai import RateLimitError

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                rate_err = RateLimitError(
                    "rate_limit",
                    response=MagicMock(),
                    body={"error": {"details": [{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "10s"}]}},
                )
                mock_success = MagicMock()
                mock_success.choices = [MagicMock()]
                mock_success.choices[0].message.content = '{"status": "PASS"}'

                mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
                    rate_err,
                    mock_success,
                ])

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    result = await real.get_json_completion([{"role": "user", "content": "test"}])
                    assert result["status"] == "PASS"

    @pytest.mark.asyncio
    async def test_get_json_completion_json_mode_fallback(self):
        """JSON mode fails, falls back to plain text."""
        from src.plugins.llm_analysis.service import LLMClient

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                mock_response = MagicMock()
                mock_response.choices = [MagicMock()]
                mock_response.choices[0].message.content = '{"status": "PASS"}'

                mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
                    Exception("400: JSON mode is not enabled"),
                    mock_response,
                ])

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                result = await real.get_json_completion([{"role": "user", "content": "test"}])
                assert result["status"] == "PASS"


class TestCallLlmForImages:
    """Verify _call_llm_for_images."""

    @pytest.mark.asyncio
    async def test_sends_images_in_content(self):
        """Messages constructed with image_url content blocks."""
        from src.plugins.llm_analysis.service import LLMClient

        client = MagicMock()
        client.get_json_completion = AsyncMock(return_value={"status": "PASS"})

        result = await LLMClient._call_llm_for_images(client, ["base64img"], "Analyze this")
        assert result["status"] == "PASS"

    @pytest.mark.asyncio
    async def test_multiple_images(self):
        """Multiple images each get their own content block."""
        from src.plugins.llm_analysis.service import LLMClient

        client = MagicMock()

        async def capture_messages(messages):
            content = messages[0]["content"]
            assert len(content) == 4  # text + 3 images
            assert content[0]["type"] == "text"
            assert content[1]["type"] == "image_url"
            return {"status": "PASS"}

        client.get_json_completion = AsyncMock(side_effect=capture_messages)

        result = await LLMClient._call_llm_for_images(client, ["a", "b", "c"], "Analyze")
        assert result["status"] == "PASS"


class TestAnalyzeDashboardMultimodalChunking:
    """Verify analyze_dashboard_multimodal chunking edge cases."""

    @pytest.mark.asyncio
    async def test_chunk_failure_returns_unknown_for_that_chunk(self):
        """When a chunk call fails, that chunk's status is UNKNOWN."""
        from src.plugins.llm_analysis.service import LLMClient

        with tempfile.TemporaryDirectory() as tmp:
            paths = []
            for i in range(3):
                p = os.path.join(tmp, f"t_{i}.png")
                Image.new("RGB", (10, 10)).save(p, "PNG")
                paths.append(p)

            client = MagicMock()
            client._optimize_images.return_value = ["a", "b", "c"]
            client._estimate_payload_size.return_value = {"exceeds_limit": False, "pct_of_limit": 10}
            client._call_llm_for_images = AsyncMock(side_effect=[
                {"status": "PASS", "summary": "OK", "issues": []},
                RuntimeError("Chunk 2 failed"),
                {"status": "WARN", "summary": "Issues", "issues": []},
            ])
            client._deduplicate_issues.return_value = []
            # Bind the real method so self is passed correctly
            import types
            client._merge_chunk_results = types.MethodType(LLMClient._merge_chunk_results, client)

            result = await LLMClient.analyze_dashboard_multimodal(
                client, paths, ["log"], max_images=1,
            )
            assert "status" in result
            assert result["chunk_count"] == 3


class TestAnalyzeDashboardTextBatchEdge:
    """Verify analyze_dashboard_text_batch edge cases."""

    @pytest.mark.asyncio
    async def test_prompt_renders_total_dashboards(self):
        """{total_dashboards} placeholder replaced in prompt."""
        from src.plugins.llm_analysis.service import LLMClient

        client = MagicMock()
        client.get_json_completion = AsyncMock(return_value={"dashboards": []})

        result = await LLMClient.analyze_dashboard_text_batch(
            client,
            [{"dashboard_id": "1", "topology": "", "dataset_health": "", "log_text": ""}],
            "Analyzing {total_dashboards} dashboards",
        )
        assert "dashboards" in result


# ═══════════════════════════════════════════════════════════════════
# DatasetHealthChecker — remaining edge cases
# ═══════════════════════════════════════════════════════════════════

class TestDatasetHealthCheckerEdge:
    """Verify DatasetHealthChecker remaining branches."""

    @pytest.mark.asyncio
    async def test_check_dashboard_datasets_no_datasource_ids(self):
        """Charts without datasource_id produce empty dataset results."""
        from src.plugins.llm_analysis.service import DatasetHealthChecker

        mock_client = MagicMock()
        checker = DatasetHealthChecker(mock_client)

        chart_list = [
            {"slice_id": 1, "slice_name": "Chart 1"},
            {"slice_id": 2, "slice_name": "Chart 2", "datasource_id": None},
        ]
        result = await checker.check_dashboard_datasets(chart_list, execute_chart_data=False)
        assert result["datasets"] == []

    @pytest.mark.asyncio
    async def test_check_chart_data_list_result(self):
        """When chart data response is a list (not dict), handles correctly."""
        from src.plugins.llm_analysis.service import DatasetHealthChecker

        mock_client = MagicMock()
        mock_client.network.request.return_value = [{"val": 1}, {"val": 2}]

        checker = DatasetHealthChecker(mock_client)
        result = await checker.check_chart_data(1, {"viz_type": "table"})
        assert result["executed"] is True
        assert result["row_count"] == 2

    @pytest.mark.asyncio
    async def test_check_dataset_health_flat_response(self):
        """When response has no 'result' wrapper, uses response directly."""
        from src.plugins.llm_analysis.service import DatasetHealthChecker

        mock_client = MagicMock()
        mock_client.get_dataset.return_value = {
            "table_name": "flat_table",
            "database": {"database_name": "flat_db", "backend": "mysql"},
        }

        checker = DatasetHealthChecker(mock_client)
        result = await checker.check_dataset_health(1)
        assert result["dataset_name"] == "flat_table"
        assert result["metadata_accessible"] is True

    @pytest.mark.asyncio
    async def test_check_dataset_health_non_dict_response(self):
        """When response is not a dict, uses empty dict as fallback (metadata_accessible remains True)."""
        from src.plugins.llm_analysis.service import DatasetHealthChecker

        mock_client = MagicMock()
        mock_client.get_dataset.return_value = "not a dict"

        checker = DatasetHealthChecker(mock_client)
        result = await checker.check_dataset_health(1)
        # Non-dict response does not raise; empty result dict with defaults
        assert result["metadata_accessible"] is True

    @pytest.mark.asyncio
    async def test_check_dashboard_datasets_empty_chart_list(self):
        """Empty chart list produces empty results."""
        from src.plugins.llm_analysis.service import DatasetHealthChecker

        mock_client = MagicMock()
        checker = DatasetHealthChecker(mock_client)
        result = await checker.check_dashboard_datasets([], execute_chart_data=True)
        assert result["datasets"] == []
        assert result["chart_data"] == []

    @pytest.mark.asyncio
    async def test_call_sync_sync_method(self):
        """_call_sync wraps sync method in to_thread."""
        from src.plugins.llm_analysis.service import DatasetHealthChecker

        def sync_method(arg):
            return f"sync_{arg}"

        result = await DatasetHealthChecker._call_sync(sync_method, "test")
        assert result == "sync_test"

    @pytest.mark.asyncio
    async def test_check_dashboard_datasets_with_affected_charts(self):
        """Datasets map affected charts correctly."""
        from src.plugins.llm_analysis.service import DatasetHealthChecker

        mock_client = MagicMock()
        mock_client.get_dataset.return_value = {
            "result": {"table_name": "t", "database": {"database_name": "db", "backend": "pg"}},
        }
        mock_client.network.request.return_value = {"result": []}

        checker = DatasetHealthChecker(mock_client)
        chart_list = [
            {"slice_id": 1, "slice_name": "C1", "datasource_id": 101},
            {"slice_id": 2, "slice_name": "C2", "datasource_id": 101},
            {"slice_id": 3, "slice_name": "C3", "datasource_id": 102},
        ]
        result = await checker.check_dashboard_datasets(chart_list, execute_chart_data=False)
        assert len(result["datasets"]) == 2
        # Check affected_charts mapping
        ds_101 = [d for d in result["datasets"] if d["dataset_id"] == 101][0]
        assert len(ds_101["affected_charts"]) == 2

# ═══════════════════════════════════════════════════════════════════
# Remaining edge cases for 98%+ coverage
# ═══════════════════════════════════════════════════════════════════

class TestIterLoginRootsException:
    """_iter_login_roots exception handler (line 79-80)."""

    def test_frames_iteration_raises(self):
        """Exception during frames iteration is caught."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(MagicMock())
        page = MagicMock()
        # frames accessible, but equality check raises when checked with `in`
        bad_frame = MagicMock()
        bad_frame.__eq__ = MagicMock(side_effect=Exception("frames error"))
        page.frames = [bad_frame]
        roots = svc._iter_login_roots(page)
        assert len(roots) == 1  # only the page itself


class TestLaunchLoginApiV1Suffix:
    """_launch_and_login with env.url ending in /api/v1 (line 371)."""

    def _make_env(self):
        env = MagicMock()
        env.url = "https://superset.example.com/api/v1"
        env.username = "admin"
        env.password = "pass"
        return env

    @pytest.mark.asyncio
    async def test_strips_api_v1(self):
        """env.url ending with /api/v1 strips it."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())
        mock_page = MagicMock()
        mock_page.frames = []
        mock_page.url = "https://superset.example.com/"
        mock_page.goto = AsyncMock()
        mock_page.wait_for_load_state = AsyncMock()
        mock_page.wait_for_selector = AsyncMock()
        mock_page.wait_for_function = AsyncMock()
        mock_page.evaluate = AsyncMock()
        mock_page.add_init_script = AsyncMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.locator = MagicMock()
        mock_page.locator.count = AsyncMock(return_value=0)

        mock_context = MagicMock()
        mock_context.new_page = AsyncMock(return_value=mock_page)

        mock_browser = MagicMock()
        mock_browser.new_context = AsyncMock(return_value=mock_context)

        mock_playwright = MagicMock()
        mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                with patch.object(svc, '_goto_resilient', new=AsyncMock()):
                    browser, context, page = await svc._launch_and_login(mock_playwright, "42")
                    assert page is not None


class TestLaunchAndLoginWaitTimeouts:
    """Wait_for timeout exception handlers (lines 443-444, 492-493, 499-500, 523-524)."""

    def _make_env(self):
        env = MagicMock()
        env.url = "https://superset.example.com"
        env.username = "admin"
        env.password = "pass"
        return env

    def _make_basic_mocks(self):
        mock_page = MagicMock()
        mock_page.frames = []
        mock_page.url = "https://superset.example.com/superset/dashboard/42/"
        mock_page.goto = AsyncMock()
        mock_page.wait_for_load_state = AsyncMock()
        mock_page.wait_for_selector = AsyncMock()
        mock_page.wait_for_function = AsyncMock()
        mock_page.evaluate = AsyncMock()
        mock_page.add_init_script = AsyncMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.locator = MagicMock()
        mock_page.locator.count = AsyncMock(return_value=0)

        mock_context = MagicMock()
        mock_context.new_page = AsyncMock(return_value=mock_page)

        mock_browser = MagicMock()
        mock_browser.new_context = AsyncMock(return_value=mock_context)

        mock_playwright = MagicMock()
        mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)

        return mock_page, mock_context, mock_browser, mock_playwright

    @pytest.mark.asyncio
    async def test_wait_for_load_state_timeout(self):
        """wait_for_load_state('load') raising Exception is caught (line 443-444)."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        # First call ("domcontentloaded") succeeds, second ("load") raises after login
        mock_page.wait_for_load_state = AsyncMock(side_effect=[None, Exception("timeout")])

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                browser, context, page = await svc._launch_and_login(mock_playwright, "42")
                assert page is not None  # should not raise

    @pytest.mark.asyncio
    async def test_wait_for_selector_timeouts(self):
        """wait_for_selector timeouts during dashboard load are caught (lines 492-493, 499-500)."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        # First wait_for_selector succeeds, second and third raise
        mock_page.wait_for_selector = AsyncMock(side_effect=[
            MagicMock(),                               # first: success
            Exception("loading timeout"),              # second: loading hidden timeout
            Exception("chart canvas timeout"),         # third: chart canvas timeout
        ])

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                    browser, context, page = await svc._launch_and_login(mock_playwright, "42")
                    assert page is not None

    @pytest.mark.asyncio
    async def test_wait_for_function_timeout(self):
        """wait_for_function timeout during dashboard load is caught (line 523-524)."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()

        # wait_for_function raises timeout
        mock_page.wait_for_function = AsyncMock(side_effect=Exception("function timeout"))

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                    browser, context, page = await svc._launch_and_login(mock_playwright, "42")
                    assert page is not None

    @pytest.mark.asyncio
    async def test_login_failed_after_submit(self):
        """After submit, still on /login page with alert (line 452)."""
        from src.plugins.llm_analysis.service import ScreenshotService
        svc = ScreenshotService(self._make_env())
        mock_page, _, _, mock_playwright = self._make_basic_mocks()
        mock_page.url = "https://superset.example.com/login/"

        # Locator for alert-danger returns 1 count and text content
        alert_locator = MagicMock()
        alert_locator.count = AsyncMock(return_value=1)
        alert_locator.text_content = AsyncMock(return_value="Invalid credentials")

        def locator_side(selector):
            result = MagicMock()
            result.count = AsyncMock(return_value=0)
            return result
        mock_page.locator = MagicMock(side_effect=locator_side)
        mock_page.locator.return_value = alert_locator

        username_loc = MagicMock()
        username_loc.fill = AsyncMock()
        password_loc = MagicMock()
        password_loc.fill = AsyncMock()
        submit_loc = MagicMock()
        submit_loc.click = AsyncMock()

        with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
            with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
                with patch.object(svc, '_goto_resilient', new=AsyncMock()):
                    with pytest.raises(RuntimeError, match="Login failed"):
                        await svc._launch_and_login(mock_playwright, "42")


class TestCaptureDashboardChunksEdgeCoverage:
    """Edge cases for capture_dashboard_chunks (lines 594, 597, 632)."""

    def _make_env(self):
        env = MagicMock()
        env.url = "https://superset.example.com"
        env.username = "admin"
        env.password = "pass"
        return env

    @pytest.mark.asyncio
    async def test_tab_not_visible_skipped(self):
        """Tab not visible is skipped (line 597)."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        mock_context = MagicMock()
        mock_cdp = MagicMock()
        mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
        mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        # Tab that is NOT visible
        tab_mock = MagicMock()
        tab_mock.inner_text = AsyncMock(return_value="Tab 1")
        tab_mock.is_visible = AsyncMock(return_value=False)  # not visible → skip
        tab_mock.get_attribute = AsyncMock(return_value="")

        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        # Tab was skipped → falls back to full page
                        assert len(results) == 1
                        assert results[0]["tab_name"] == "full"

    @pytest.mark.asyncio
    async def test_safe_tab_empty_fallback(self):
        """Empty tab name after sanitization uses fallback (line 632)."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        mock_context = MagicMock()
        mock_cdp = MagicMock()
        mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
        mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        # Tab with name that sanitizes to EMPTY string — only special chars, no word chars
        # re.sub(r"[^\w\-_ ]", "", "!!!???") → "" (all special chars removed)
        tab_mock = MagicMock()
        tab_mock.inner_text = AsyncMock(return_value="!!!???")
        tab_mock.is_visible = AsyncMock(return_value=True)
        tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab-active")
        tab_mock.click = AsyncMock()

        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        assert len(results) >= 1


class TestShouldRetryEdgeCases:
    """_should_retry predicate edge cases."""

    def test_auth_error_returns_false(self):
        """OpenAIAuthenticationError returns False (line 962)."""
        from src.plugins.llm_analysis.service import LLMClient
        from openai import AuthenticationError as OpenAIAuthenticationError

        # We use the actual error class
        err = OpenAIAuthenticationError(
            "Invalid API key",
            response=MagicMock(),
            body={},
        )
        # Access the internal method via the bound reference
        assert LLMClient._should_retry(err) is False

    def test_rate_limit_returns_true(self):
        """RateLimitError returns True."""
        from src.plugins.llm_analysis.service import LLMClient
        from openai import RateLimitError

        err = RateLimitError(
            "rate limited",
            response=MagicMock(),
            body={},
        )
        assert LLMClient._should_retry(err) is True

    def test_null_content_returns_false(self):
        """Exception with 'null content' in message returns False."""
        from src.plugins.llm_analysis.service import LLMClient

        err = RuntimeError("null content returned")
        assert LLMClient._should_retry(err) is False

    def test_other_exception_returns_true(self):
        """Generic exception returns True."""
        from src.plugins.llm_analysis.service import LLMClient

        err = RuntimeError("transient error")
        assert LLMClient._should_retry(err) is True


class TestGetJsonCompletionEdgeCases:
    """Edge cases for get_json_completion."""

    @pytest.mark.asyncio
    async def test_auth_error_handler(self):
        """OpenAIAuthenticationError is logged and re-raised (lines 1021-1023)."""
        from src.plugins.llm_analysis.service import LLMClient
        from src.plugins.llm_analysis.models import LLMProviderType
        from openai import AuthenticationError as OpenAIAuthenticationError

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                auth_err = OpenAIAuthenticationError(
                    "Invalid API key",
                    response=MagicMock(),
                    body={},
                )
                mock_client_instance.chat.completions.create = AsyncMock(
                    side_effect=auth_err
                )

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    with pytest.raises(OpenAIAuthenticationError):
                        await real.get_json_completion([{"role": "user", "content": "test"}])

    @pytest.mark.asyncio
    async def test_rate_limit_retry_delay_regex(self):
        """RateLimitError with retryDelay in error_str (line 1039)."""
        from src.plugins.llm_analysis.service import LLMClient
        from src.plugins.llm_analysis.models import LLMProviderType
        from openai import RateLimitError

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                # RateLimitError whose str() contains retryDelay
                class RateLimitWithRetryDelay(RateLimitError):
                    def __str__(self):
                        return '{"retryDelay": "15s"}'

                rate_err = RateLimitWithRetryDelay(
                    "rate limit",
                    response=MagicMock(),
                    body={},
                )
                mock_success = MagicMock()
                mock_success.choices = [MagicMock()]
                mock_success.choices[0].message.content = '{"status": "PASS"}'

                mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
                    rate_err,
                    mock_success,
                ])

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    result = await real.get_json_completion([{"role": "user", "content": "test"}])
                    assert result["status"] == "PASS"

    @pytest.mark.asyncio
    async def test_rate_limit_parse_error(self):
        """Exception during retry delay parsing is caught (lines 1050-1051)."""
        from src.plugins.llm_analysis.service import LLMClient
        from src.plugins.llm_analysis.models import LLMProviderType
        from openai import RateLimitError

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                # RateLimitError with a body dict that raises on access of 'error' key
                # This triggers the except Exception handler at line 1050-1051
                class ExplodingDetailsDict(dict):
                    def __getitem__(self, key):
                        if key == 'error':
                            raise ValueError("body parse error")
                        return super().__getitem__(key)

                rate_err = RateLimitError(
                    "rate limit",
                    response=MagicMock(),
                    body=ExplodingDetailsDict(),
                )
                mock_success = MagicMock()
                mock_success.choices = [MagicMock()]
                mock_success.choices[0].message.content = '{"status": "PASS"}'

                mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
                    rate_err,
                    mock_success,
                ])

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    result = await real.get_json_completion([{"role": "user", "content": "test"}])
                    assert result["status"] == "PASS"

    @pytest.mark.asyncio
    async def test_generic_exception_handler(self):
        """Generic exception in outer handler is logged and re-raised (lines 1058-1063)."""
        from src.plugins.llm_analysis.service import LLMClient
        from src.plugins.llm_analysis.models import LLMProviderType

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                mock_client_instance.chat.completions.create = AsyncMock(
                    side_effect=ConnectionError("Connection refused")
                )

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    with pytest.raises(ConnectionError):
                        await real.get_json_completion([{"role": "user", "content": "test"}])

    @pytest.mark.asyncio
    async def test_json_parse_fallback_bare_raise(self):
        """Non-JSON, non-code-block content re-raises JSONDecodeError (line 1086)."""
        from src.plugins.llm_analysis.service import LLMClient
        from src.plugins.llm_analysis.models import LLMProviderType
        import json

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                mock_response = MagicMock()
                mock_response.choices = [MagicMock()]
                mock_response.choices[0].message.content = "This is plain text, not JSON"

                mock_client_instance.chat.completions.create = AsyncMock(
                    return_value=mock_response
                )

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    with pytest.raises(json.JSONDecodeError):
                        await real.get_json_completion([{"role": "user", "content": "test"}])


# ═══════════════════════════════════════════════════════════════════
# Remaining uncovered lines for service.py:
#   459 — HTTPS upgrade (DEAD CODE: dashboard_url constructed from base_ui_url, same scheme)
#   594 — Duplicate tab detection (DEAD CODE: tab_id includes unique loop index)
#   639-649 — CDP screenshot success in tab loop
#   687-697 — CDP full-page fallback in no-tab case
#   1050-1051 — Retry delay parsing exception
# ═══════════════════════════════════════════════════════════════════

class TestCaptureDashboardChunksCDPEdgeCoverage:
    """Edge cases for CDP screenshot paths in capture_dashboard_chunks."""

    def _make_env(self):
        env = MagicMock()
        env.url = "https://superset.example.com"
        env.username = "admin"
        env.password = "pass"
        return env

    @pytest.mark.asyncio
    async def test_cdp_success_in_tab_loop(self):
        """CDP screenshot success in tab loop (lines 639-649).

        NOTE: This test verifies the CDP fallback path (lines 650-655) because
        full CDP success requires a real Playwright CDP session. The mock for
        page.context.new_cdp_session() is set up with AsyncMock but Playwright's
        CDP session protocol requires real browser integration.

        Lines 637-649 (CDP success) require Playwright integration tests.
        Lines 650-655 (CDP fallback → Playwright screenshot) are tested here.
        """
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        # Wire page.context to a mock context with failing CDP
        mock_cdp_ctx = MagicMock()
        mock_cdp_ctx.new_cdp_session = AsyncMock(side_effect=Exception("CDP not available"))
        mock_page.context = mock_cdp_ctx

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        tab_mock = MagicMock()
        tab_mock.inner_text = AsyncMock(return_value="Revenue")
        tab_mock.is_visible = AsyncMock(return_value=True)
        tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab")
        tab_mock.click = AsyncMock()

        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_cdp_ctx, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        assert len(results) >= 1
                        # Playwright fallback was called for each tab + recursion
                        assert mock_page.screenshot.call_count >= 1

    @pytest.mark.asyncio
    async def test_cdp_full_page_fallback_in_no_tab_case(self):
        """CDP fails in no-tab full page capture → falls back to Playwright screenshot (lines 687-697)."""
        from src.plugins.llm_analysis.service import ScreenshotService

        svc = ScreenshotService(self._make_env())

        mock_page = MagicMock()
        mock_page.set_viewport_size = AsyncMock()
        mock_page.screenshot = AsyncMock()
        mock_page.url = "https://example.com/dashboard/42/"
        mock_page.frames = []

        # CDP session raises — triggers fallback to Playwright screenshot
        mock_context = MagicMock()
        mock_context.new_cdp_session = AsyncMock(side_effect=Exception("CDP unavailable for full page"))

        mock_browser = MagicMock()
        mock_browser.close = AsyncMock()

        # No tabs found (empty list)
        mock_page.locator = MagicMock()
        mock_page.locator.return_value.all = AsyncMock(return_value=[])

        with patch.object(svc, '_launch_and_login',
                          AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
            with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
                with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
                    with tempfile.TemporaryDirectory() as tmp:
                        results = await svc.capture_dashboard_chunks("42", tmp)
                        assert len(results) == 1
                        assert results[0]["tab_name"] == "full"
                        # Playwright fallback was called
                        mock_page.screenshot.assert_called_once()


class TestGetJsonCompletionRetryDelayParseException:
    """Verify get_json_completion retry delay parsing exception handler (lines 1050-1051)."""

    @pytest.mark.asyncio
    async def test_rate_limit_parse_exception_caught(self):
        """When retryDelay parsing raises, exception is caught and logged (line 1050-1051)."""
        from src.plugins.llm_analysis.service import LLMClient
        from src.plugins.llm_analysis.models import LLMProviderType
        from openai import RateLimitError

        with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
            with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
                mock_client_instance = MagicMock()
                MockOpenAI.return_value = mock_client_instance

                # Body where e.body.get('error', {}) returns a dict whose .get('details') raises
                # We need to trigger: for detail in details: detail.get('retryDelay', '5s')
                # So we make details[0].get('retryDelay') raise
                class ExplodingDetail(dict):
                    def get(self, key, default=None):
                        if key == 'retryDelay':
                            raise ValueError("boom: retryDelay parse failure")
                        return super().get(key, default)

                rate_err = RateLimitError(
                    "rate limit",
                    response=MagicMock(),
                    body={"error": {"details": [ExplodingDetail({"@type": "type.googleapis.com/google.rpc.RetryInfo"})]}},
                )
                mock_success = MagicMock()
                mock_success.choices = [MagicMock()]
                mock_success.choices[0].message.content = '{"status": "PASS"}'

                mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
                    rate_err,
                    mock_success,
                ])

                real = LLMClient(
                    LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
                )
                with patch.object(real, '_supports_json_response_format', return_value=False):
                    result = await real.get_json_completion([{"role": "user", "content": "test"}])
                    assert result["status"] == "PASS"


# #region Dead Code Documentation for service.py
# @RATIONALE
#   Line 459 (HTTPS upgrade): `dashboard_url` is always constructed from `base_ui_url`
#   by concatenation, so the scheme is always the same. This conditional can never be True
#   in the current code path. Keeping for safety in case env.url scheme diverges from
#   dashboard_url in future refactors.
#
#   Line 594 (Duplicate tab detection): `tab_id = f"{depth}_{i}_{tab_text}"` includes the
#   unique loop index `i`, making duplicate tab_ids impossible within the same `_capture_tabs`
#   call. Keeping for safety if the `found_tabs` list ever contains duplicates.
# #endregion
# #endregion Test.LLMAnalysisService.Coverage
