# #region LLMAnalysisService [C:5] [TYPE Module] [SEMANTICS llm, screenshot, playwright, openai, tenacity]
# @BRIEF Services for LLM interaction and dashboard screenshots.
# @LAYER Plugin
# @RELATION DEPENDS_ON -> [EXT:Library:tenacity]
# @INVARIANT Screenshots must be 1920px width and capture full page height.
# @DATA_CONTRACT DashboardSpec -> Screenshot + Analysis
# @RATIONALE Extracted all hardcoded timeouts into named module-level constants (PLAYWRIGHT_NAVIGATION_TIMEOUT_MS, PLAYWRIGHT_WAIT_TIMEOUT_MS, PLAYWRIGHT_SHORT_TIMEOUT_MS, HTTP_REQUEST_TIMEOUT_MS, SCREENSHOT_SERVICE_TIMEOUT_MS, LLM_HTTP_TIMEOUT_S) and DEFAULT_USER_AGENT. Zero remaining numeric timeout literals.

import asyncio
import base64
import io
import json
import os
import re
import ssl
from typing import Any
from urllib.parse import urlsplit

import httpx
from openai import AsyncOpenAI, AuthenticationError as OpenAIAuthenticationError, RateLimitError
from PIL import Image
from playwright.async_api import async_playwright
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential

from ...core.config_models import Environment
from ...core.logger import belief_scope, logger
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
from .models import LLMProviderType

# Timeout constants (milliseconds unless noted)
PLAYWRIGHT_NAVIGATION_TIMEOUT_MS = 30000
PLAYWRIGHT_WAIT_TIMEOUT_MS = 10000
PLAYWRIGHT_SHORT_TIMEOUT_MS = 5000
HTTP_REQUEST_TIMEOUT_MS = 60000
SCREENSHOT_SERVICE_TIMEOUT_MS = 120000
LLM_HTTP_TIMEOUT_S = 120  # seconds (httpx client timeout)
DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"

# #region ScreenshotService [TYPE Class]
# @BRIEF Handles capturing screenshots of Superset dashboards.
class ScreenshotService:
    # region ScreenshotService.__init__ [TYPE Function]
    # @PURPOSE Initializes the ScreenshotService with environment configuration.
    # @PRE env is a valid Environment object.
    def __init__(self, env: Environment):
        self.env = env
    # endregion ScreenshotService.__init__

    # region ScreenshotService._find_first_visible_locator [TYPE Function]
    # @PURPOSE Resolve the first visible locator from multiple Playwright locator strategies.
    # @PRE candidates is a non-empty list of locator-like objects.
    # @POST Returns a locator ready for interaction or None when nothing matches.
    async def _find_first_visible_locator(self, candidates) -> Any:
        for locator in candidates:
            try:
                match_count = await locator.count()
                for index in range(match_count):
                    candidate = locator.nth(index)
                    if await candidate.is_visible():
                        return candidate
            except Exception:
                continue
        return None
    # endregion ScreenshotService._find_first_visible_locator

    # region ScreenshotService._iter_login_roots [TYPE Function]
    # @PURPOSE Enumerate page and child frames where login controls may be rendered.
    # @PRE page is a Playwright page-like object.
    # @POST Returns ordered roots starting with main page followed by frames.
    def _iter_login_roots(self, page) -> list[Any]:
        roots = [page]
        page_frames = getattr(page, "frames", [])
        try:
            for frame in page_frames:
                if frame not in roots:
                    roots.append(frame)
        except Exception:
            pass
        return roots
    # endregion ScreenshotService._iter_login_roots

    # region ScreenshotService._extract_hidden_login_fields [TYPE Function]
    # @PURPOSE Collect hidden form fields required for direct login POST fallback.
    # @PRE Login page is loaded.
    # @POST Returns hidden input name/value mapping aggregated from page and child frames.
    async def _extract_hidden_login_fields(self, page) -> dict[str, str]:
        hidden_fields: dict[str, str] = {}
        for root in self._iter_login_roots(page):
            try:
                locator = root.locator("input[type='hidden'][name]")
                count = await locator.count()
                for index in range(count):
                    candidate = locator.nth(index)
                    field_name = str(await candidate.get_attribute("name") or "").strip()
                    if not field_name or field_name in hidden_fields:
                        continue
                    hidden_fields[field_name] = str(await candidate.input_value()).strip()
            except Exception:
                continue
        return hidden_fields
    # endregion ScreenshotService._extract_hidden_login_fields

    # region ScreenshotService._extract_csrf_token [TYPE Function]
    # @PURPOSE Resolve CSRF token value from main page or embedded login frame.
    # @PRE Login page is loaded.
    # @POST Returns first non-empty csrf token or empty string.
    async def _extract_csrf_token(self, page) -> str:
        hidden_fields = await self._extract_hidden_login_fields(page)
        return str(hidden_fields.get("csrf_token") or "").strip()
    # endregion ScreenshotService._extract_csrf_token

    # region ScreenshotService._response_looks_like_login_page [TYPE Function]
    # @PURPOSE Detect when fallback login POST returned the login form again instead of an authenticated page.
    # @PRE response_text is normalized HTML or text from login POST response.
    # @POST Returns True when login-page markers dominate the response body.
    def _response_looks_like_login_page(self, response_text: str) -> bool:
        normalized = str(response_text or "").strip().lower()
        if not normalized:
            return False

        markers = [
            "enter your login and password below",
            "username:",
            "password:",
            "sign in",
            'name="csrf_token"',
        ]
        return sum(marker in normalized for marker in markers) >= 3
    # endregion ScreenshotService._response_looks_like_login_page

    # region ScreenshotService._redirect_looks_authenticated [TYPE Function]
    # @PURPOSE Treat non-login redirects after form POST as successful authentication without waiting for redirect target.
    # @PRE redirect_location may be empty or relative.
    # @POST Returns True when redirect target does not point back to login flow.
    def _redirect_looks_authenticated(self, redirect_location: str) -> bool:
        normalized = str(redirect_location or "").strip().lower()
        if not normalized:
            return True
        return "/login" not in normalized
    # endregion ScreenshotService._redirect_looks_authenticated

    # region ScreenshotService._submit_login_via_form_post [TYPE Function]
    # @PURPOSE Fallback login path that submits credentials directly with csrf token.
    # @PRE login_url is same-origin and csrf token can be read from DOM.
    # @POST Browser context receives authenticated cookies when login succeeds.
    async def _submit_login_via_form_post(self, page, login_url: str) -> bool:
        hidden_fields = await self._extract_hidden_login_fields(page)
        csrf_token = str(hidden_fields.get("csrf_token") or "").strip()
        if not csrf_token:
            logger.warning("[DEBUG] Direct form login fallback skipped: csrf_token not found")
            return False

        try:
            request_context = page.context.request
        except Exception as context_error:
            logger.warning(f"[DEBUG] Direct form login fallback skipped: request context unavailable: {context_error}")
            return False

        parsed_url = urlsplit(login_url)
        origin = f"{parsed_url.scheme}://{parsed_url.netloc}" if parsed_url.scheme and parsed_url.netloc else login_url
        payload = dict(hidden_fields)
        payload["username"] = self.env.username
        payload["password"] = self.env.password
        logger.info(
            f"[DEBUG] Attempting direct form login fallback via browser context request with hidden fields: {sorted(hidden_fields.keys())}"
        )

        response = await request_context.post(
            login_url,
            form=payload,
            headers={
                "Origin": origin,
                "Referer": login_url,
            },
            timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS,
            fail_on_status_code=False,
            max_redirects=0,
        )
        response_url = str(getattr(response, "url", "") or "")
        response_status = int(getattr(response, "status", 0) or 0)
        response_headers = dict(getattr(response, "headers", {}) or {})
        redirect_location = str(
            response_headers.get("location")
            or response_headers.get("Location")
            or ""
        ).strip()
        redirect_statuses = {301, 302, 303, 307, 308}
        if response_status in redirect_statuses:
            redirect_authenticated = self._redirect_looks_authenticated(redirect_location)
            logger.info(
                f"[DEBUG] Direct form login fallback redirect response: status={response_status} url={response_url} location={redirect_location!r} authenticated={redirect_authenticated}"
            )
            return redirect_authenticated

        response_text = await response.text()
        text_snippet = " ".join(response_text.split())[:200]
        looks_like_login_page = self._response_looks_like_login_page(response_text)
        logger.info(
            f"[DEBUG] Direct form login fallback response: status={response_status} url={response_url} login_markup={looks_like_login_page} snippet={text_snippet!r}"
        )
        return not looks_like_login_page
    # endregion ScreenshotService._submit_login_via_form_post

    # region ScreenshotService._find_login_field_locator [TYPE Function]
    # @PURPOSE Resolve login form input using semantic label text plus generic visible-input fallbacks.
    # @PRE field_name is `username` or `password`.
    # @POST Returns a locator for the corresponding input or None.
    async def _find_login_field_locator(self, page, field_name: str) -> Any:
        normalized = str(field_name or "").strip().lower()
        for root in self._iter_login_roots(page):
            if normalized == "username":
                input_candidates = [
                    root.get_by_label("Username", exact=False),
                    root.get_by_label("Login", exact=False),
                    root.locator("label:text-matches('Username|Login', 'i')").locator("xpath=following::input[1]"),
                    root.locator("text=/Username|Login/i").locator("xpath=following::input[1]"),
                    root.locator("input[name='username']"),
                    root.locator("input#username"),
                    root.locator("input[placeholder*='Username']"),
                    root.locator("input[type='text']"),
                    root.locator("input:not([type='password'])"),
                ]
                locator = await self._find_first_visible_locator(input_candidates)
                if locator:
                    return locator

            if normalized == "password":
                input_candidates = [
                    root.get_by_label("Password", exact=False),
                    root.locator("label:text-matches('Password', 'i')").locator("xpath=following::input[1]"),
                    root.locator("text=/Password/i").locator("xpath=following::input[1]"),
                    root.locator("input[name='password']"),
                    root.locator("input#password"),
                    root.locator("input[placeholder*='Password']"),
                    root.locator("input[type='password']"),
                ]
                locator = await self._find_first_visible_locator(input_candidates)
                if locator:
                    return locator

        return None
    # endregion ScreenshotService._find_login_field_locator

    # region ScreenshotService._find_submit_locator [TYPE Function]
    # @PURPOSE Resolve login submit button from main page or embedded auth frame.
    # @PRE page is ready for login interaction.
    # @POST Returns visible submit locator or None.
    async def _find_submit_locator(self, page) -> Any:
        selectors = [
            lambda root: root.get_by_role("button", name="Sign in", exact=False),
            lambda root: root.get_by_role("button", name="Login", exact=False),
            lambda root: root.locator("button[type='submit']"),
            lambda root: root.locator("button#submit"),
            lambda root: root.locator(".btn-primary"),
            lambda root: root.locator("input[type='submit']"),
        ]
        for root in self._iter_login_roots(page):
            locator = await self._find_first_visible_locator([factory(root) for factory in selectors])
            if locator:
                return locator
        return None
    # endregion ScreenshotService._find_submit_locator

    # region ScreenshotService._goto_resilient [TYPE Function]
    # @PURPOSE Navigate without relying on networkidle for pages with long-polling or persistent requests.
    # @PRE page is a valid Playwright page and url is non-empty.
    # @POST Returns last navigation response or raises when both primary and fallback waits fail.
    async def _goto_resilient(
        self,
        page,
        url: str,
        primary_wait_until: str = "domcontentloaded",
        fallback_wait_until: str = "load",
        timeout: int = HTTP_REQUEST_TIMEOUT_MS,
    ):
        try:
            return await page.goto(url, wait_until=primary_wait_until, timeout=timeout)
        except Exception as primary_error:
            logger.warning(
                f"[ScreenshotService._goto_resilient] Primary navigation wait '{primary_wait_until}' failed for {url}: {primary_error}"
            )
            return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout)
    # endregion ScreenshotService._goto_resilient

    # region ScreenshotService._wait_for_charts_stabilized [TYPE Function] [C:2]
    # @BRIEF Wait until chart elements have non-zero dimensions, with polling.
    # @PRE page is a valid Playwright page.
    # @POST Waits for chart stabilization or raises on timeout (handled internally).
    # @RATIONALE Polls for actual chart rendering dimensions rather than using a fixed delay — charts may load at different speeds depending on dashboard complexity and network conditions.
    # @REJECTED Fixed sleep-based wait rejected — would either waste time (too long) or produce blank screenshots (too short); polling for actual canvas/svg dimensions is more reliable.
    async def _wait_for_charts_stabilized(self, page, timeout_ms: int = 15000):
        """Wait until chart elements have non-zero dimensions, with polling."""
        # Short initial delay for rendering pipeline to start
        await asyncio.sleep(0.5)
        try:
            await page.wait_for_function("""() => {
                const charts = document.querySelectorAll('.chart-container canvas, .slice_container svg, .grid-content canvas');
                if (charts.length === 0) return true;
                return Array.from(charts).some(c => {
                    if (c.tagName === 'CANVAS') return c.width > 10 && c.height > 10;
                    if (c.tagName === 'svg') {
                        const bbox = c.getBoundingClientRect();
                        return bbox.width > 10 && bbox.height > 10;
                    }
                    return false;
                });
            }""", timeout=timeout_ms)
        except Exception:
            logger.warning("[ScreenshotService] Chart stabilization wait timed out, proceeding anyway")
    # endregion ScreenshotService._wait_for_charts_stabilized

    # region ScreenshotService._wait_for_resize_rendered [TYPE Function] [C:2]
    # @BRIEF Wait for charts to re-render after viewport resize.
    # @PRE page is a valid Playwright page; chart_count_before contains pre-resize element counts.
    # @POST Waits for chart content to return or timeout.
    # @RATIONALE After viewport resize, Superset triggers lazy chart re-rendering — this function polls for chart elements to reappear before taking the screenshot.
    # @REJECTED Single fixed wait after resize rejected — some dashboards re-render instantly while others take seconds; fixed wait is brittle across dashboard types.
    async def _wait_for_resize_rendered(self, page, chart_count_before: dict, timeout_ms: int = 10000):
        """Wait for charts to re-render after viewport resize, with polling."""
        try:
            await page.wait_for_function("""(preCounts) => {
                const currentCharts = document.querySelectorAll('.chart-container, .slice_container').length;
                const currentCanvases = document.querySelectorAll('canvas').length;
                const currentSvgs = document.querySelectorAll('.chart-container svg, .slice_container svg').length;
                // At least one chart element must be present
                return currentCharts > 0 && (currentCanvases > 0 || currentSvgs > 0);
            }""", arg=chart_count_before, timeout=timeout_ms)
        except Exception:
            logger.warning("[ScreenshotService] Re-render wait timed out after viewport resize, proceeding anyway")
    # endregion ScreenshotService._wait_for_resize_rendered

    # region ScreenshotService._save_debug_screenshot [TYPE Function] [C:1]
    # @BRIEF Save a debug screenshot to a temp directory for diagnostic purposes.
    # @PRE debug_dir exists and is writable.
    # @POST Returns the debug path or None on failure.
    # @RATIONALE Uses tempfile.mkdtemp() to avoid accumulating .png files in production storage. Temp dir is cleaned up on success (rmtree) or preserved on failure for debugging.
    # @REJECTED Old approach saved debug .png next to output path — accumulated _debug_failed_login.png, _preresize.png permanently in screenshots dir (F1). In-memory-only debug logging rejected — screenshot state is visual and cannot be captured in logs.
    async def _save_debug_screenshot(self, page, debug_dir: str, suffix: str) -> str | None:
        debug_path = os.path.join(debug_dir, suffix)
        try:
            await page.screenshot(path=debug_path)
            return debug_path
        except Exception:
            return None
    # endregion ScreenshotService._save_debug_screenshot

    # region ScreenshotService._launch_and_login [TYPE Function] [C:4]
    # @PURPOSE Launch browser, log in to Superset, navigate to dashboard URL.
    # @PRE dashboard_id is valid, playwright instance is provided.
    # @POST Returns (browser, context, page) tuple with active session.
    # @SIDE_EFFECT Launches headless Chromium, performs UI login, navigates to dashboard.
    # @RATIONALE Extracted from capture_dashboard to share login/navigation logic
    #   between capture_dashboard (backward compat) and capture_dashboard_chunks (multi-tab).
    # @REJECTED Duplicating login logic rejected — any change to auth flow would require
    #   updating two code paths, leading to accidental drift.
    async def _launch_and_login(
        self,
        playwright,
        dashboard_id: str,
        parsed_context: dict | None = None,
    ) -> tuple[Any, Any, Any]:
        """Launch browser, log in to Superset, and navigate to the dashboard.

        Returns (browser, context, page).
        """
        user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        base_ui_url = self.env.url.rstrip("/")
        if base_ui_url.endswith("/api/v1"):
            base_ui_url = base_ui_url[: -len("/api/v1")]

        browser = await playwright.chromium.launch(
            headless=True,
            args=[
                "--disable-blink-features=AutomationControlled",
                "--disable-infobars",
                "--no-sandbox",
            ],
        )
        context = await browser.new_context(
            viewport={"width": 1280, "height": 720},
            user_agent=user_agent,
            extra_http_headers={
                "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
                "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
                "Upgrade-Insecure-Requests": "1",
                "Sec-Fetch-Dest": "document",
                "Sec-Fetch-Mode": "navigate",
                "Sec-Fetch-Site": "none",
                "Sec-Fetch-User": "?1",
            },
        )
        page = await context.new_page()
        await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")

        # 1. Navigate to login page and authenticate
        login_url = f"{base_ui_url.rstrip('/')}/login/"
        await self._goto_resilient(
            page,
            login_url,
            primary_wait_until="domcontentloaded",
            fallback_wait_until="load",
            timeout=HTTP_REQUEST_TIMEOUT_MS,
        )
        await page.wait_for_load_state("domcontentloaded")

        try:
            used_direct_form_login = False
            username_locator = await self._find_login_field_locator(page, "username")

            if not username_locator:
                used_direct_form_login = await self._submit_login_via_form_post(page, login_url)
                if not used_direct_form_login:
                    raise RuntimeError("Could not find username input field on login page")

            if username_locator is not None:
                await username_locator.fill(self.env.username)

            password_locator = (
                await self._find_login_field_locator(page, "password")
                if username_locator is not None
                else None
            )
            if username_locator is not None and not password_locator:
                raise RuntimeError("Could not find password input field on login page")
            if password_locator is not None:
                await password_locator.fill(self.env.password)

            submit_locator = (
                await self._find_submit_locator(page)
                if username_locator is not None
                else None
            )
            if username_locator is not None and not submit_locator:
                raise RuntimeError("Could not find submit button on login page")
            if submit_locator is not None:
                await submit_locator.click()

            if not used_direct_form_login:
                try:
                    await page.wait_for_load_state("load", timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)
                except Exception:
                    pass

            if not used_direct_form_login and "/login" in page.url:
                error_msg = (
                    await page.locator(".alert-danger, .error-message").text_content()
                    if await page.locator(".alert-danger, .error-message").count() > 0
                    else "Unknown error"
                )
                raise RuntimeError(f"Login failed: {error_msg}")
        except Exception as e:
            raise RuntimeError(f"Login failed: {e!s}")

        # 2. Navigate to dashboard
        dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
        if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
            dashboard_url = dashboard_url.replace("http://", "https://")

        if parsed_context:
            native_filters = parsed_context.get("native_filters")
            active_tabs = parsed_context.get("activeTabs")
            if native_filters:
                dashboard_url += f"&native_filters={native_filters}"
            if active_tabs:
                dashboard_url += f"&activeTabs={active_tabs}"

        await self._goto_resilient(
            page,
            dashboard_url,
            primary_wait_until="domcontentloaded",
            fallback_wait_until="load",
            timeout=HTTP_REQUEST_TIMEOUT_MS,
        )

        if "/login" in page.url:
            raise RuntimeError("Dashboard navigation redirected to login page after authentication")

        # 3. Wait for dashboard content
        try:
            await page.wait_for_selector(
                '.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]',
                timeout=PLAYWRIGHT_NAVIGATION_TIMEOUT_MS,
            )
            try:
                await page.wait_for_selector(
                    ".loading, .ant-skeleton, .spinner",
                    state="hidden",
                    timeout=HTTP_REQUEST_TIMEOUT_MS,
                )
            except Exception:
                pass
            try:
                await page.wait_for_selector(
                    ".chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container",
                    timeout=HTTP_REQUEST_TIMEOUT_MS,
                )
            except Exception:
                pass
            await page.wait_for_function(
                """() => {
                    const charts = document.querySelectorAll('.chart-container, .slice_container');
                    if (charts.length === 0) return true;
                    return Array.from(charts).every(chart => {
                        const hasCanvas = chart.querySelector('canvas') !== null;
                        const hasSvg = chart.querySelector('svg') !== null;
                        const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
                        return hasCanvas || hasSvg || hasContent;
                    });
                }""",
                timeout=HTTP_REQUEST_TIMEOUT_MS,
            )
            await page.evaluate("""async () => {
                const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
                for (let i = 0; i < document.body.scrollHeight; i += 500) {
                    window.scrollTo(0, i);
                    await delay(100);
                }
                window.scrollTo(0, 0);
                await delay(500);
            }""")
        except Exception:
            pass

        await self._wait_for_charts_stabilized(page)
        logger.info(f"[_launch_and_login] Login + navigation successful for dashboard {dashboard_id}")
        return browser, context, page
    # endregion ScreenshotService._launch_and_login

    # region ScreenshotService.capture_dashboard_chunks [TYPE Function] [C:4]
    # @PURPOSE Capture per-tab screenshots: login → navigate → switch tabs → per-tab CDP screenshots.
    # @PRE dashboard_id is valid, browser available.
    # @POST Returns list of {tab_name, path} dicts — one per tab.
    # @SIDE_EFFECT Launches browser, logs in, switches tabs, captures screenshots.
    # @RATIONALE Multi-chunk: one screenshot per tab instead of one full-page.
    #   All screenshots written to output_dir; CDP fallback to Playwright full_page.
    # @REJECTED Single full-page screenshot rejected for v2 — per-tab captures give LLM
    #   better visibility into individual tab content, especially for dashboards with
    #   many tabs where the full-page capture may miss lazy-loaded tab content.
    async def capture_dashboard_chunks(
        self,
        dashboard_id: str,
        output_dir: str,
        parsed_context: dict | None = None,
    ) -> list[dict]:
        """Capture per-tab screenshots instead of one full-page.

        Args:
            dashboard_id: Superset dashboard ID
            output_dir: Directory to save screenshots
            parsed_context: Optional parsed context with activeTabs, native_filters from URL parse

        Returns:
            list of {tab_name, path} — one per tab
        """
        import time as _time

        timestamp = int(_time.time())
        os.makedirs(output_dir, exist_ok=True)

        async with async_playwright() as p:
            browser, context, page = await self._launch_and_login(p, dashboard_id, parsed_context)
            try:
                results: list[dict] = []
                processed_tabs: set[str] = set()

                async def _capture_tabs(depth: int = 0) -> None:
                    if depth > 3:
                        return

                    tab_selectors = [
                        ".ant-tabs-nav-list .ant-tabs-tab",
                        ".dashboard-component-tabs .ant-tabs-tab",
                        '[data-test="dashboard-component-tabs"] .ant-tabs-tab',
                    ]

                    found_tabs = []
                    for selector in tab_selectors:
                        found_tabs = await page.locator(selector).all()
                        if found_tabs:
                            break

                    if not found_tabs:
                        return

                    logger.info(f"[capture_dashboard_chunks] Found {len(found_tabs)} tabs at depth {depth}")
                    for i, tab in enumerate(found_tabs):
                        try:
                            tab_text = (await tab.inner_text()).strip()
                            tab_id = f"{depth}_{i}_{tab_text}"

                            if tab_id in processed_tabs:
                                continue

                            if not await tab.is_visible():
                                continue

                            processed_tabs.add(tab_id)
                            logger.info(f"[capture_dashboard_chunks] Switching to tab: {tab_text}")

                            is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
                            if not is_active:
                                await tab.click()
                                try:
                                    await page.wait_for_function(
                                        """() => {
                                            const activeTab = document.querySelector('.ant-tabs-tab-active');
                                            if (!activeTab) return true;
                                            const tabPane = activeTab.closest('.ant-tabs')?.querySelector('.ant-tabs-content-holder');
                                            if (!tabPane) return true;
                                            const charts = tabPane.querySelectorAll('canvas, svg');
                                            return charts.length > 0;
                                        }""",
                                        timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS,
                                    )
                                except Exception:
                                    logger.warning(
                                        f"[capture_dashboard_chunks] Content verification timed out for tab: {tab_text}"
                                    )

                            # Wait for charts to stabilize
                            await self._wait_for_charts_stabilized(page)

                            # Resize viewport to 1920x1200 for consistent screenshots
                            await page.set_viewport_size({"width": 1920, "height": 1200})
                            await self._wait_for_resize_rendered(page, {})

                            # CDP screenshot with fallback
                            safe_tab = re.sub(r"[^\w\-_ ]", "", tab_text).strip().replace(" ", "_")[:40]
                            if not safe_tab:
                                safe_tab = f"tab_{depth}_{i}"

                            tab_filename = f"{dashboard_id}_{safe_tab}_{timestamp}_d{depth}.png"
                            tab_path = os.path.join(output_dir, tab_filename)

                            try:
                                cdp = await page.context.new_cdp_session(page)
                                screenshot_data = await cdp.send(
                                    "Page.captureScreenshot",
                                    {
                                        "format": "png",
                                        "fromSurface": True,
                                        "captureBeyondViewport": True,
                                    },
                                )
                                image_data = base64.b64decode(screenshot_data["data"])
                                with open(tab_path, "wb") as f:
                                    f.write(image_data)
                            except Exception as cdp_err:
                                logger.warning(
                                    f"[capture_dashboard_chunks] CDP failed for tab {tab_text}: {cdp_err}. "
                                    "Falling back to Playwright full_page."
                                )
                                await page.screenshot(path=tab_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)

                            logger.info(f"[capture_dashboard_chunks] Saved screenshot: {tab_path}")
                            results.append({"tab_name": tab_text, "path": tab_path})

                            # Recurse into nested tabs
                            await _capture_tabs(depth + 1)

                        except Exception as tab_e:
                            logger.warning(
                                f"[capture_dashboard_chunks] Failed to process tab {i} at depth {depth}: {tab_e}"
                            )

                    # Return to first tab
                    try:
                        first_tab = found_tabs[0]
                        if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
                            await first_tab.click()
                    except Exception:
                        pass

                await _capture_tabs()

                # If no tabs found, capture the whole page as a single chunk
                if not results:
                    logger.info("[capture_dashboard_chunks] No tabs found, capturing full-page as single chunk")
                    await self._wait_for_charts_stabilized(page)
                    await page.set_viewport_size({"width": 1920, "height": 1200})

                    tab_path = os.path.join(output_dir, f"{dashboard_id}_full_{timestamp}.png")
                    try:
                        cdp = await page.context.new_cdp_session(page)
                        screenshot_data = await cdp.send(
                            "Page.captureScreenshot",
                            {
                                "format": "png",
                                "fromSurface": True,
                                "captureBeyondViewport": True,
                            },
                        )
                        image_data = base64.b64decode(screenshot_data["data"])
                        with open(tab_path, "wb") as f:
                            f.write(image_data)
                    except Exception as cdp_err:
                        logger.warning(f"[capture_dashboard_chunks] CDP full fallback failed: {cdp_err}")
                        await page.screenshot(path=tab_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS)

                    results.append({"tab_name": "full", "path": tab_path})

                return results
            finally:
                await browser.close()
    # endregion ScreenshotService.capture_dashboard_chunks

    # region ScreenshotService.capture_dashboard [TYPE Function] [C:4]
    # @PURPOSE Captures multi-chunk screenshots, converts for LLM, archives to WebP.
    # @PRE dashboard_id is a valid string, output_path is a writable path.
    # @POST Returns list of {original, webp_path} dicts from WebP archive.
    #   Empty list on complete failure.
    # @SIDE_EFFECT Launches browser, performs UI login, writes PNG/JPEG/WebP files;
    #   deletes intermediate PNG and JPEG files after conversion.
    # @RATIONALE Refactored v2: delegates to capture_dashboard_chunks for per-tab PNGs,
    #   then converts for LLM (JPEG) and archive (WebP). Temp intermediates are cleaned up.
    # @REJECTED Returning bool rejected for v2 — callers need access to archived WebP paths
    #   for persistence and LLM pipeline.
    async def capture_dashboard(self, dashboard_id: str, output_path: str) -> tuple[list[str], list[dict]]:
        """Capture dashboard screenshots (multi-chunk), convert for LLM, archive to WebP.

        Returns (jpeg_paths, archive_results) tuple.
        jpeg_paths — list of JPEG paths ready for LLM analysis (caller must clean up).
        archive_results — list of {original, webp_path} dicts from WebP archive.
        """
        output_dir = os.path.dirname(output_path) or "."
        os.makedirs(output_dir, exist_ok=True)

        with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
            logger.info(f"Capturing screenshots for dashboard {dashboard_id}")

            # 1. Capture per-tab screenshots
            chunks = await self.capture_dashboard_chunks(dashboard_id, output_dir)
            png_paths = [c["path"] for c in chunks if c.get("path")]

            if not png_paths:
                logger.warning(f"[capture_dashboard] No screenshots captured for dashboard {dashboard_id}")
                return [], []

            # 2. Convert PNGs to JPEGs for LLM
            jpeg_paths = self._convert_screenshots_for_llm(png_paths, output_dir)
            logger.info(
                f"[capture_dashboard] Converted {len(jpeg_paths)}/{len(png_paths)} PNGs to JPEGs for LLM"
            )

            # 3. Archive to WebP (deletes PNGs on success)
            archive_results = self._archive_screenshots_as_webp(png_paths, output_dir)
            logger.info(
                f"[capture_dashboard] Archived {sum(1 for r in archive_results if r.get('webp_path'))} "
                f"of {len(archive_results)} screenshots to WebP"
            )

            # 4. Return JPEGs for LLM — caller cleans up after analysis
            return jpeg_paths, archive_results
    # endregion ScreenshotService.capture_dashboard

    # region ScreenshotService._convert_screenshots_for_llm [TYPE Function] [C:2]
    # @BRIEF Convert PNG screenshots to JPEG for LLM transmission.
    # @PRE png_paths is a list of existing PNG file paths.
    # @POST Returns list of JPEG paths. JPEG files should be deleted after LLM call.
    @staticmethod
    def _convert_screenshots_for_llm(png_paths: list[str], output_dir: str) -> list[str]:
        """Convert PNG screenshots to JPEG for LLM transmission.

        PNG → Pillow → JPEG quality=60, max 1024px width.
        Returns list of JPEG paths.
        """
        jpeg_paths: list[str] = []
        for png_path in png_paths:
            try:
                img = Image.open(png_path)
                if img.mode in ("RGBA", "P"):
                    img = img.convert("RGB")

                max_width = 1024
                if img.width > max_width:
                    scale = max_width / img.width
                    new_w = int(img.width * scale)
                    new_h = int(img.height * scale)
                    img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)

                base = os.path.splitext(os.path.basename(png_path))[0]
                jpeg_path = os.path.join(output_dir, f"{base}_llm.jpg")
                img.save(jpeg_path, format="JPEG", quality=60, optimize=True)
                jpeg_paths.append(jpeg_path)
            except Exception as e:
                logger.warning(f"[convert_for_llm] Failed to convert {png_path}: {e}")

        return jpeg_paths
    # endregion ScreenshotService._convert_screenshots_for_llm

    # region ScreenshotService._archive_screenshots_as_webp [TYPE Function] [C:2]
    # @BRIEF Convert PNG screenshots to WebP for archive.
    # @PRE png_paths is a list of existing PNG file paths.
    # @POST Returns list of {original, webp_path} dicts. PNG deleted after successful WebP save.
    @staticmethod
    def _archive_screenshots_as_webp(png_paths: list[str], archive_dir: str) -> list[dict]:
        """Convert PNG screenshots to WebP for archive.

        PNG → Pillow → WebP lossy quality=80.
        Deletes PNG after WebP saved.
        Returns list of {original, webp_path} dicts.
        """
        results: list[dict] = []
        for png_path in png_paths:
            try:
                img = Image.open(png_path)
                if img.mode in ("RGBA", "P"):
                    img = img.convert("RGB")

                base = os.path.splitext(os.path.basename(png_path))[0]
                webp_path = os.path.join(archive_dir, f"{base}.webp")
                img.save(webp_path, format="WEBP", quality=80, lossless=False)

                # Delete PNG after successful WebP save
                os.remove(png_path)

                results.append({"original": png_path, "webp_path": webp_path})
            except Exception as e:
                logger.warning(f"[archive_webp] Failed to convert {png_path}: {e}. Keeping PNG.")
                results.append({"original": png_path, "webp_path": None})

        return results
    # endregion ScreenshotService._archive_screenshots_as_webp

    # region ScreenshotService._cleanup_temp_files [TYPE Function] [C:1]
    # @BRIEF Delete temporary files (PNG, JPEG intermediates).
    @staticmethod
    def _cleanup_temp_files(paths: list[str]) -> None:
        """Delete temporary files (PNG, JPEG intermediates)."""
        for path in paths:
            try:
                if os.path.exists(path):
                    os.remove(path)
            except Exception as e:
                logger.warning(f"[cleanup] Failed to delete {path}: {e}")
    # endregion ScreenshotService._cleanup_temp_files
# #endregion ScreenshotService

# #region LLMClient [TYPE Class]
# @BRIEF Wrapper for LLM provider APIs.
class LLMClient:
    # region LLMClient.__init__ [TYPE Function]
    # @PURPOSE Initializes the LLMClient with provider settings.
    # @PRE api_key, base_url, and default_model are non-empty strings.
    def __init__(self, provider_type: LLMProviderType, api_key: str, base_url: str, default_model: str):
        self.provider_type = provider_type
        normalized_key = (api_key or "").strip()
        if normalized_key.lower().startswith("bearer "):
            normalized_key = normalized_key[7:].strip()
        self.api_key = normalized_key
        self.base_url = base_url
        self.default_model = default_model

        # DEBUG: Log initialization parameters (without exposing full API key)
        logger.info("[LLMClient.__init__] Initializing LLM client:")
        logger.info(f"[LLMClient.__init__]   Provider Type: {provider_type}")
        logger.info(f"[LLMClient.__init__]   Base URL: {base_url}")
        logger.info(f"[LLMClient.__init__]   Default Model: {default_model}")
        logger.info(f"[LLMClient.__init__]   API Key (first 8 chars): {self.api_key[:8] if self.api_key and len(self.api_key) > 8 else 'EMPTY_OR_NONE'}...")
        logger.info(f"[LLMClient.__init__]   API Key Length: {len(self.api_key) if self.api_key else 0}")

        # Some OpenAI-compatible gateways are strict about auth header naming.
        default_headers = {"Authorization": f"Bearer {self.api_key}"}
        if self.provider_type == LLMProviderType.OPENROUTER:
            default_headers["HTTP-Referer"] = (
                os.getenv("OPENROUTER_SITE_URL", "").strip()
                or os.getenv("APP_BASE_URL", "").strip()
                or "http://localhost:8000"
            )
            default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "ss-tools"
        if self.provider_type == LLMProviderType.KILO:
            default_headers["Authentication"] = f"Bearer {self.api_key}"
            default_headers["X-API-Key"] = self.api_key
        # LiteLLM proxy uses standard OpenAI-compatible Bearer auth — no special headers needed.
        # It routes to upstream providers transparently, and the default Authorization header
        # is sufficient. No additional headers like HTTP-Referer or X-API-Key are required.

        ssl_verify = self._get_ssl_verify()
        logger.info(f"[LLMClient.__init__]   SSL Verify: {ssl_verify} (set LLM_SSL_VERIFY=false to disable)")

        http_client = httpx.AsyncClient(
            headers=default_headers,
            timeout=LLM_HTTP_TIMEOUT_S,
            verify=ssl_verify,
        )
        self.client = AsyncOpenAI(
            api_key=self.api_key,
            base_url=base_url,
            default_headers=default_headers,
            http_client=http_client,
        )
    # endregion LLMClient.__init__

    # region LLMClient._get_ssl_verify [TYPE Function]
    # @PURPOSE Resolve SSL verification flag from environment.
    # @POST Returns SSLContext with system CA dir when enabled,
    #   False when LLM_SSL_VERIFY env var is "false"/"0"/"no"/"off".
    # @RATIONALE Используем capath=/etc/ssl/certs/ вместо cafile, потому что
    #   OpenSSL 3.x не использует intermediate CA сертификаты из cafile для
    #   построения цепочки (verify code 20). capath с хеш-симлинками работает
    #   корректно (verify code 0). Оба пути — cafile и capath — указывают на
    #   один и тот же набор сертификатов, но capath правильно обрабатывает
    #   цепочку Root → Policy → Issuing.
    # @REJECTED verify=<string> отвергнут — httpx 0.28.x депрекейтит строковый
    #   путь в verify=, требует SSLContext.
    # @REJECTED cafile отвергнут — OpenSSL 3.x не использует intermediate CA
    #   из единого bundle-файла. Только capath с хеш-симлинками даёт code 0.
    @staticmethod
    def _get_ssl_verify() -> ssl.SSLContext | bool:
        raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower()
        if raw in ("false", "0", "no", "off"):
            return False
        ca_dir = "/etc/ssl/certs"
        if os.path.isdir(ca_dir):
            return ssl.create_default_context(capath=ca_dir)
        # fallback: если директории нет (редко), используем дефолтный
        return ssl.create_default_context()
    # endregion LLMClient._get_ssl_verify

    # region LLMClient._format_connection_error [TYPE Function]
    # @PURPOSE Format exception chain for diagnostics, extracting httpx cause details.
    # @POST Returns a human-readable string with the full error chain.
    @staticmethod
    def _format_connection_error(exc: Exception) -> str:
        parts = [f"{type(exc).__name__}: {exc!s}"]
        cause = exc.__cause__ or exc.__context__
        while cause:
            parts.append(f"  └─ {type(cause).__name__}: {cause!s}")
            cause = cause.__cause__ or cause.__context__
        return "\n".join(parts)
    # endregion LLMClient._format_connection_error

    # region LLMClient._supports_json_response_format [TYPE Function]
    # @PURPOSE Detect whether provider/model is likely compatible with response_format=json_object.
    # @PRE Client initialized with base_url and default_model.
    # @POST Returns False for known-incompatible combinations to avoid avoidable 400 errors.
    def _supports_json_response_format(self) -> bool:
        model = (self.default_model or "").lower()

        # Free-tier models from ANY gateway often reject json_object mode
        # (Nvidia NeMo free via OpenRouter or Kilo, stepfun free, etc.)
        if ":free" in model:
            return False
        # stepfun models (even non-free) don't support json_object mode
        if "stepfun/" in model or model.startswith("step-"):
            return False
        return True
    # endregion LLMClient._supports_json_response_format

    # region LLMClient.get_json_completion [TYPE Function]
    # @PURPOSE Helper to handle LLM calls with JSON mode and fallback parsing.
    # @PRE messages is a list of valid message dictionaries.
    # @POST Returns a parsed JSON dictionary.
    # @SIDE_EFFECT Calls external LLM API.
    def _should_retry(exception: Exception) -> bool:
        """Custom retry predicate that excludes non-recoverable errors."""
        # Don't retry on authentication errors
        if isinstance(exception, OpenAIAuthenticationError):
            return False
        # Don't retry on null content / model errors — retrying won't help
        msg = str(exception).lower()
        if "null content" in msg or "none" in msg:
            return False
        # Retry on rate limit errors
        if isinstance(exception, RateLimitError):
            return True
        # For other exceptions, limit retries
        return True

    @retry(
        stop=stop_after_attempt(5),
        wait=wait_exponential(multiplier=2, min=5, max=60),
        retry=retry_if_exception(_should_retry),
        reraise=True
    )
    async def get_json_completion(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
        with belief_scope("get_json_completion"):
            response = None
            try:
                use_json_mode = self._supports_json_response_format()
                try:
                    logger.info(
                        f"[get_json_completion] Attempting LLM call for model: {self.default_model} "
                        f"(json_mode={'on' if use_json_mode else 'off'})"
                    )
                    logger.info(f"[get_json_completion] Base URL being used: {self.base_url}")
                    logger.info(f"[get_json_completion] Number of messages: {len(messages)}")
                    logger.info(f"[get_json_completion] API Key present: {bool(self.api_key and len(self.api_key) > 0)}")

                    if use_json_mode:
                        response = await self.client.chat.completions.create(
                            model=self.default_model,
                            messages=messages,
                            response_format={"type": "json_object"}
                        )
                    else:
                        response = await self.client.chat.completions.create(
                            model=self.default_model,
                            messages=messages
                        )
                except Exception as e:
                    if use_json_mode and (
                        "JSON mode is not enabled" in str(e)
                        or "json_object is not supported" in str(e).lower()
                        or "response_format" in str(e).lower()
                        or "400" in str(e)
                    ):
                        logger.warning(f"[get_json_completion] JSON mode failed or not supported: {e!s}. Falling back to plain text response.")
                        response = await self.client.chat.completions.create(
                            model=self.default_model,
                            messages=messages
                        )
                    else:
                        raise e

                logger.debug(f"[get_json_completion] LLM Response: {response}")
            except OpenAIAuthenticationError as e:
                logger.error(f"[get_json_completion] Authentication error: {e!s}")
                # Do not retry on auth errors - re-raise to stop retry
                raise
            except RateLimitError as e:
                logger.warning(f"[get_json_completion] Rate limit hit: {e!s}")

                # Extract retry_delay from error metadata if available
                retry_delay = 5.0 # Default fallback
                try:
                    # Based on logs, the raw response is in e.body or e.response.json()
                    # The logs show 'metadata': {'raw': '...'} which suggests a proxy or specific client wrapper
                    # Let's try to find the 'retryDelay' in the error message or response
                    import re

                    # Try to find "retryDelay": "XXs" in the string representation of the error
                    error_str = str(e)
                    match = re.search(r'"retryDelay":\s*"(\d+)s"', error_str)
                    if match:
                        retry_delay = float(match.group(1))
                    else:
                        # Try to parse from response if it's a standard OpenAI-like error with body
                        if hasattr(e, 'body') and isinstance(e.body, dict):
                            # Some providers put it in details
                            details = e.body.get('error', {}).get('details', [])
                            for detail in details:
                                if detail.get('@type') == 'type.googleapis.com/google.rpc.RetryInfo':
                                    delay_str = detail.get('retryDelay', '5s')
                                    retry_delay = float(delay_str.rstrip('s'))
                                    break
                except Exception as parse_e:
                    logger.debug(f"[get_json_completion] Failed to parse retry delay: {parse_e}")

                # Add a small safety margin (0.5s) as requested
                wait_time = retry_delay + 0.5
                logger.info(f"[get_json_completion] Waiting for {wait_time}s before retry...")
                await asyncio.sleep(wait_time)
                raise
            except Exception as e:
                logger.error(
                    f"[get_json_completion] LLM call failed.\n"
                    f"{self._format_connection_error(e)}"
                )
                raise

            if not response or not hasattr(response, 'choices') or not response.choices:
                raise RuntimeError(f"Invalid LLM response: {response}")

            content = response.choices[0].message.content
            logger.debug(f"[get_json_completion] Raw content to parse: {content}")

            # LLM returned null content — likely content filter or rate limit
            if content is None:
                raise RuntimeError("LLM returned null content (content filter or rate limit)")

            try:
                return json.loads(content)
            except json.JSONDecodeError:
                logger.warning("[get_json_completion] Failed to parse JSON directly, attempting to extract from code blocks")
                if "```json" in content:
                    json_str = content.split("```json")[1].split("```")[0].strip()
                    return json.loads(json_str)
                elif "```" in content:
                    json_str = content.split("```")[1].split("```")[0].strip()
                    return json.loads(json_str)
                else:
                    raise
    # endregion LLMClient.get_json_completion

    # region LLMClient.test_runtime_connection [TYPE Function]
    # @PURPOSE Validate provider credentials using the same chat completions transport as runtime analysis.
    # @PRE Client is initialized with provider credentials and default_model.
    # @POST Returns lightweight JSON payload when runtime auth/model path is valid.
    # @SIDE_EFFECT Calls external LLM API.
    async def test_runtime_connection(self) -> dict[str, Any]:
        with belief_scope("test_runtime_connection"):
            messages = [
                {
                    "role": "user",
                    "content": 'Return exactly this JSON object and nothing else: {"ok": true}',
                }
            ]
            return await self.get_json_completion(messages)
    # endregion LLMClient.test_runtime_connection

    # region LLMClient.fetch_models [TYPE Function]
    # @PURPOSE Fetch available models from the provider's API.
    # @PRE Client is initialized with provider credentials.
    # @POST Returns a list of model ID strings.
    # @SIDE_EFFECT Calls external LLM API /v1/models endpoint.
    async def fetch_models(self) -> list[str]:
        with belief_scope("LLMClient.fetch_models"):
            try:
                response = await self.client.models.list()
                model_ids = [m.id for m in response.data]
                model_ids.sort()
                logger.reason(
                    f"[LLMClient.fetch_models] Fetched {len(model_ids)} models from {self.base_url}",
                    extra={"src": "LLMClient.fetch_models"},
                )
                return model_ids
            except Exception as e:
                logger.warning(
                    f"[LLMClient.fetch_models] Failed to fetch models.\n"
                    f"{self._format_connection_error(e)}",
                )
                raise
    # endregion LLMClient.fetch_models

    # region LLMClient.analyze_dashboard [TYPE Function]
    # @PURPOSE Sends dashboard data (screenshot + logs) to LLM for health analysis.
    # @PRE screenshot_path exists, logs is a list of strings.
    # @POST Returns a structured analysis dictionary (status, summary, issues).
    # @SIDE_EFFECT Reads screenshot file and calls external LLM API.
    # @RATIONALE Delegates to analyze_dashboard_multimodal for single-screenshot
    #   backward compatibility. Keeps the same contract for v1 consumers.
    async def analyze_dashboard(
        self,
        screenshot_path: str,
        logs: list[str],
        prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
    ) -> dict[str, Any]:
        # Delegate to multimodal variant for backward compat with v1 consumers.
        return await self.analyze_dashboard_multimodal(
            screenshot_paths=[screenshot_path],
            logs=logs,
            prompt_template=prompt_template,
        )
    # endregion LLMClient.analyze_dashboard

    # region LLMClient._reduce_image_quality [TYPE Function] [C:2]
    # @PURPOSE Open, resize, and compress a screenshot image for LLM consumption.
    # @PRE path points to an existing image file.
    # @POST Returns (base64_str, byte_size) tuple.
    @staticmethod
    def _reduce_image_quality(
        path: str,
        max_width: int = 1024,
        image_quality: int = 60,
    ) -> tuple[str, int]:
        """
        Open, resize, compress, and base64-encode an image.

        Returns (base64_str, byte_size).
        """
        with Image.open(path) as img:
            if img.mode in ("RGBA", "P"):
                img = img.convert("RGB")
            if img.width > max_width or img.height > 2048:
                scale = min(max_width / img.width, 2048 / img.height)
                if scale < 1.0:
                    new_width = int(img.width * scale)
                    new_height = int(img.height * scale)
                    img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
            buffer = io.BytesIO()
            img.save(buffer, format="JPEG", quality=image_quality, optimize=True)
            raw = buffer.getvalue()
            return base64.b64encode(raw).decode("utf-8"), len(raw)
    # endregion LLMClient._reduce_image_quality

    # region LLMClient._estimate_payload_size [TYPE Function] [C:2]
    # @PURPOSE Estimate LLM payload size in tokens before sending.
    # @POST Returns {estimated_tokens, exceeds_limit, pct_of_limit} dict.
    # @RATIONALE FR-056: if >80% of model context window, trigger quality reduction.
    @staticmethod
    def _estimate_payload_size(
        image_paths: list[str],
        text_length: int,
        model_context: int = 128000,
    ) -> dict[str, Any]:
        """
        Estimate token usage for multimodal payload.

        Rough heuristic: 1 image token ~ 258 tokens (GPT-4o), text ~4 chars/token.
        Returns {estimated_tokens, exceeds_limit, pct_of_limit}
        """
        image_tokens = len(image_paths) * 258 * 5  # rough upper bound for compressed images
        text_tokens = text_length // 4
        total_tokens = image_tokens + text_tokens
        exceeds_limit = total_tokens > (model_context * 0.8)
        return {
            "estimated_tokens": total_tokens,
            "exceeds_limit": exceeds_limit,
            "pct_of_limit": round(total_tokens / model_context * 100, 1),
        }
    # endregion LLMClient._estimate_payload_size

    # region LLMClient._deduplicate_issues [TYPE Function] [C:2]
    # @PURPOSE Deduplicate issues by (severity, message, location) while preserving order.
    def _deduplicate_issues(self, issues: list[dict]) -> list[dict]:
        seen: set[tuple[str, str, str]] = set()
        result: list[dict] = []
        for issue in issues:
            key = (issue.get("severity", ""), issue.get("message", ""), issue.get("location", "") or "")
            if key not in seen:
                seen.add(key)
                result.append(issue)
        return result

    # endregion LLMClient._deduplicate_issues

    # region LLMClient._optimize_images [TYPE Function] [C:2]
    # @PURPOSE Convert screenshot paths to base64 at given quality, with fallback to raw read.
    def _optimize_images(self, paths: list[str], max_width: int, quality: int) -> list[str]:
        encoded: list[str] = []
        for path in paths:
            try:
                b64, _ = self._reduce_image_quality(path, max_width, quality)
                encoded.append(b64)
            except Exception as e:
                logger.warning(f"[_optimize_images] Optimization failed for {path}: {e}")
                with open(path, "rb") as f:
                    raw = f.read()
                b64 = base64.b64encode(raw).decode("utf-8")
                encoded.append(b64)
        return encoded

    # endregion LLMClient._optimize_images

    # region LLMClient._merge_chunk_results [TYPE Function] [C:2]
    # @PURPOSE Merge multiple chunk analyses into one. Takes the worst status,
    #   concatenates summaries, and deduplicates issues.
    # @PRE chunks is a non-empty list of {status, summary, issues} dicts.
    # @POST Returns a single merged dict with chunk_count.
    def _merge_chunk_results(self, chunks: list[dict[str, Any]]) -> dict[str, Any]:
        STATUS_ORDER = {"FAIL": 0, "WARN": 1, "PASS": 2, "UNKNOWN": 3}
        worst_status = "UNKNOWN"
        all_summaries: list[str] = []
        all_issues: list[dict] = []

        for i, chunk in enumerate(chunks):
            s = chunk.get("status", "UNKNOWN")
            if STATUS_ORDER.get(s, 3) < STATUS_ORDER.get(worst_status, 3):
                worst_status = s
            all_summaries.append(f"[Chunk {i + 1}/{len(chunks)}] {chunk.get('summary', 'No summary')}")
            all_issues.extend(chunk.get("issues", []))

        merged: dict[str, Any] = {
            "status": worst_status,
            "summary": " | ".join(all_summaries),
            "issues": self._deduplicate_issues(all_issues),
            "chunk_count": len(chunks),
        }
        return merged

    # endregion LLMClient._merge_chunk_results

    # region LLMClient._call_llm_for_images [TYPE Function] [C:2]
    # @PURPOSE Send a single chunk of images to the LLM and return parsed result.
    async def _call_llm_for_images(
        self, encoded_images: list[str], prompt: str
    ) -> dict[str, Any]:
        content: list[dict] = [{"type": "text", "text": prompt}]
        for b64_img in encoded_images:
            content.append({
                "type": "image_url",
                "image_url": {"url": f"data:image/jpeg;base64,{b64_img}"},
            })
        messages = [{"role": "user", "content": content}]
        return await self.get_json_completion(messages)

    # endregion LLMClient._call_llm_for_images

    # region LLMClient.analyze_dashboard_multimodal [TYPE Function] [C:3]
    # @PURPOSE Path A: send screenshots + logs to multimodal LLM, with chunking support.
    # @PRE screenshot_paths is a non-empty list of paths.
    #   tab_labels, if provided, must have the same length as screenshot_paths.
    # @POST Returns dict {status, summary, issues} with optional chunk_count.
    # @SIDE_EFFECT Compresses images, calls external LLM API (possibly multiple times for chunks).
    # @RATIONALE Screenshots are split into chunks of max_images to respect provider image limits.
    #   Quality reduction is skipped when chunking — each chunk fits the limit by definition.
    #   Results are merged via _merge_chunk_results.
    async def analyze_dashboard_multimodal(
        self,
        screenshot_paths: list[str],
        logs: list[str],
        prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
        max_width: int = 1024,
        image_quality: int = 60,
        max_images: int | None = None,
        tab_labels: list[str] | None = None,
    ) -> dict[str, Any]:
        with belief_scope("analyze_dashboard_multimodal"):
            if not screenshot_paths:
                raise ValueError("screenshot_paths must be a non-empty list")

            # 1. Optimize all images at requested quality
            encoded_images = self._optimize_images(screenshot_paths, max_width, image_quality)

            log_text = "\n".join(logs)
            tab_list_text = "\n".join(
                f"  Screenshot {i}: {label}" for i, label in enumerate(tab_labels or [])
            ) or "Screenshots are in order."
            prompt = render_prompt(prompt_template, {
                "logs": log_text,
                "tab_list": tab_list_text,
                "total_chunks": str(len(encoded_images)),
            })

            # 2. Determine chunking
            # Default to 8 images per chunk as a safe fallback when max_images is 0 or None
            # (0 means probe failed — e.g. Kilo gateway doesn't support OpenAI image format)
            DEFAULT_CHUNK_SIZE = 8
            effective_max = max_images if (max_images is not None and max_images > 0) else DEFAULT_CHUNK_SIZE
            n_total = len(encoded_images)
            chunk_size = effective_max if effective_max < n_total else n_total
            is_chunking = chunk_size < n_total

            if is_chunking:
                logger.reason(
                    f"[analyze_dashboard_multimodal] Chunking {n_total} images into "
                    f"{(n_total + chunk_size - 1) // chunk_size} chunks of {chunk_size}",
                    extra={"src": "analyze_dashboard_multimodal", "total": n_total, "chunk_size": chunk_size},
                )
                # Skip quality reduction: each chunk has ≤ max_images images,
                # well within the context window at normal quality.
            else:
                # Single batch: estimate payload and reduce quality if needed
                estimate = self._estimate_payload_size(
                    screenshot_paths, len(prompt) + len(log_text)
                )
                if estimate["exceeds_limit"] and image_quality > 30:
                    logger.info(
                        f"[analyze_dashboard_multimodal] Payload estimated at {estimate['pct_of_limit']}% "
                        f"of context window. Reducing image quality to 30."
                    )
                    encoded_images = self._optimize_images(screenshot_paths, max_width, image_quality=30)

            # 3. Split into chunks
            chunks: list[list[str]] = [
                encoded_images[i:i + chunk_size]
                for i in range(0, n_total, chunk_size)
            ]

            # 4. Call LLM — parallel for multiple chunks, single for one
            try:
                if len(chunks) == 1:
                    result = await self._call_llm_for_images(chunks[0], prompt)
                else:
                    tasks = [self._call_llm_for_images(chunk, prompt) for chunk in chunks]
                    chunk_results = await asyncio.gather(*tasks, return_exceptions=True)

                    valid_results: list[dict] = []
                    for i, cr in enumerate(chunk_results):
                        if isinstance(cr, Exception):
                            logger.error(f"[analyze_dashboard_multimodal] Chunk {i + 1}/{len(chunks)} failed: {cr!s}")
                            valid_results.append({
                                "status": "UNKNOWN",
                                "summary": f"Chunk {i + 1} failed: {cr!s}",
                                "issues": [],
                            })
                        else:
                            valid_results.append(cr)

                    result = self._merge_chunk_results(valid_results)
            except Exception as e:
                logger.error(f"[analyze_dashboard_multimodal] Failed to get analysis: {e!s}")
                return {
                    "status": "UNKNOWN",
                    "summary": f"Failed to get response from LLM: {e!s}",
                    "issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}],
                }

            return result
    # endregion LLMClient.analyze_dashboard_multimodal

    # region LLMClient.analyze_dashboard_text_batch [TYPE Function] [C:3]
    # @PURPOSE Path B batch: multiple dashboards in a single text-only LLM call.
    # @PRE payloads is a non-empty list of {dashboard_id, topology, dataset_health, log_text} dicts.
    # @POST Returns dict {dashboards: [{dashboard_id, status, summary, issues}]}.
    #   Missing/parse-error dashboard_id -> marked UNKNOWN individually.
    # @RATIONALE Text-only batch avoids image token costs. Uses per-dashboard sections
    #   with explicit JSON response contract. Fallback ensures partial results survive
    #   single-dashboard parse failures.
    @retry(
        stop=stop_after_attempt(5),
        wait=wait_exponential(multiplier=2, min=5, max=60),
        retry=retry_if_exception(_should_retry),
        reraise=True,
    )
    async def analyze_dashboard_text_batch(
        self,
        payloads: list[dict],
        prompt_template: str,
    ) -> dict[str, Any]:
        """
        Batch analyze multiple dashboards in one LLM call.

        payloads: list of dicts with keys:
          - dashboard_id (str)
          - topology (str) — dashboard structure
          - dataset_health (str) — health results
          - log_text (str) — execution logs

        Returns dict like {dashboards: [{dashboard_id, status, summary, issues}]}
        """
        if not payloads:
            return {"dashboards": []}

        # 1. Build per-dashboard sections
        sections = []
        for i, p in enumerate(payloads):
            did = p.get("dashboard_id", "UNKNOWN")
            top = p.get("topology", "")
            first_line = top.split("\n")[0] if top else "(no topology)"
            section = (
                f'─── Dashboard {i + 1}: "{first_line}" (id: {did}) ───\n'
                f"{top}\n\n"
                f"Dataset health:\n{p.get('dataset_health', '')}\n\n"
                f"Logs:\n{p.get('log_text', '')}"
            )
            sections.append(section)

        full_prompt = prompt_template.replace("{total_dashboards}", str(len(payloads)))
        full_prompt += (
            "\n\nRespond with a JSON object containing EACH dashboard's results:\n"
            '{"dashboards": [{"dashboard_id": "...", "status": "...", "summary": "...", "issues": [...]}]}\n\n'
        )
        full_prompt += "\n---\n".join(sections)

        messages = [{"role": "user", "content": full_prompt}]
        return await self.get_json_completion(messages)
    # endregion LLMClient.analyze_dashboard_text_batch

# #endregion LLMClient

# #region DatasetHealthChecker [C:3] [TYPE Class]
# @BRIEF Checks dataset accessibility and KXD connectivity via Superset API.
# @LAYER Service
# @RELATION CALLS -> [SupersetClient]
# @INVARIANT Every unique dataset referenced by dashboard charts is checked.
# @RATIONALE Without dataset health checking, silent KXD errors (connection refused, timeout)
#   are invisible to the LLM validation. Screenshot captures visual state but doesn't
#   verify that data actually arrived (vs. cache).
class DatasetHealthChecker:
    # region DatasetHealthChecker.__init__ [TYPE Function]
    # @PURPOSE Initialize with a SupersetClient-compatible instance.
    # @PRE client is a SupersetClient (sync, wrapped via asyncio.to_thread) or AsyncSupersetClient.
    # @POST self.client is ready for health checks.
    def __init__(self, client: Any):
        self.client = client
    # endregion DatasetHealthChecker.__init__

    # region DatasetHealthChecker._call_sync [TYPE Function]
    # @PURPOSE Wrap a sync client method call in asyncio.to_thread for async compat.
    # @PRE method is a callable on self.client.
    # @POST Returns the result of method(*args, **kwargs) executed in a thread.
    @staticmethod
    async def _call_sync(method, *args: Any, **kwargs: Any) -> Any:
        """Call a potentially sync method in a thread, or await if already async."""
        if asyncio.iscoroutinefunction(method):
            return await method(*args, **kwargs)
        return await asyncio.to_thread(method, *args, **kwargs)
    # endregion DatasetHealthChecker._call_sync

    # region DatasetHealthChecker.check_dataset_health [TYPE Function]
    # @PURPOSE Fetch dataset metadata and verify level 1-2 accessibility.
    # @PRE dataset_id is a valid Superset dataset ID.
    # @POST Returns dict with level 1-2 health fields.
    # @SIDE_EFFECT Calls GET /api/v1/dataset/{id} via client.get_dataset
    async def check_dataset_health(self, dataset_id: int) -> dict:
        """
        Check a single dataset's accessibility (levels 1-2 per FR-044).

        Level 1: metadata_accessible — HTTP 200 from GET /api/v1/dataset/{id}
        Level 2: datasource_resolvable — database info available

        Returns dict with:
          dataset_id, dataset_name, database_name, backend, kind,
          metadata_accessible (bool), error (str|None)
        """
        try:
            dataset = await self._call_sync(self.client.get_dataset, dataset_id)
            # The response from Superset may have a 'result' wrapper or be flat
            result_data = dataset.get("result", dataset) if isinstance(dataset, dict) else {}
            # Extract database info
            database = result_data.get("database", {}) or {}
            result = {
                "dataset_id": dataset_id,
                "dataset_name": result_data.get("table_name", f"dataset_{dataset_id}"),
                "database_name": database.get("database_name", "unknown"),
                "backend": database.get("backend", "unknown"),
                "kind": result_data.get("kind", "physical"),
                "metadata_accessible": True,
                "error": None,
            }
            return result
        except Exception as e:
            return {
                "dataset_id": dataset_id,
                "dataset_name": f"dataset_{dataset_id}",
                "database_name": "unknown",
                "backend": "unknown",
                "kind": "unknown",
                "metadata_accessible": False,
                "error": str(e),
            }
    # endregion DatasetHealthChecker.check_dataset_health

    # region DatasetHealthChecker.check_chart_data [TYPE Function]
    # @PURPOSE Execute chart data query (level 3-4 per FR-044).
    # @PRE chart_id is valid, form_data is constructed from chart params.
    # @POST Returns dict with execution result.
    # @SIDE_EFFECT Calls POST /api/v1/chart/data via client.network.request
    async def check_chart_data(self, chart_id: int, form_data: dict) -> dict:
        """
        Execute a chart query to verify data returns.

        Level 3: query_executable — POST /api/v1/chart/data succeeds
        Level 4: data_returned — row_count > 0 or no error

        Returns dict with:
          chart_id, executed (bool), duration_ms (int|None),
          row_count (int|None), error (str|None)
        """
        import time
        start = time.time()
        try:
            # Use the client's network layer for the chart data POST.
            # For sync SupersetClient: network.request(...) is synchronous.
            # We wrap it via asyncio.to_thread if it's a sync method.
            payload = json.dumps(form_data)
            headers = {"Content-Type": "application/json"}
            network_request = self.client.network.request
            result = await self._call_sync(
                network_request,
                "POST", "/chart/data",
                data=payload,
                headers=headers,
            )
            duration_ms = int((time.time() - start) * 1000)

            # Normalize response — may have 'result' wrapper
            rows = []
            if isinstance(result, dict):
                rows = result.get("result", []) or []
            elif isinstance(result, list):
                rows = result

            return {
                "chart_id": chart_id,
                "executed": True,
                "duration_ms": duration_ms,
                "row_count": len(rows),
                "error": None,
            }
        except Exception as e:
            duration_ms = int((time.time() - start) * 1000)
            return {
                "chart_id": chart_id,
                "executed": False,
                "duration_ms": duration_ms,
                "row_count": None,
                "error": str(e),
            }
    # endregion DatasetHealthChecker.check_chart_data

    # region DatasetHealthChecker.check_dashboard_datasets [TYPE Function]
    # @PURPOSE For every unique dataset in dashboard charts, check health.
    # @PRE chart_list has chart dicts with slice_id and datasource_id.
    # @POST Returns dict with datasets and optional chart_data lists.
    async def check_dashboard_datasets(
        self,
        chart_list: list[dict],
        execute_chart_data: bool = False,
    ) -> dict:
        """
        Check all unique datasets referenced by dashboard charts.

        Args:
            chart_list: list of chart dicts with at least
                {'slice_id', 'datasource_id', 'viz_type', 'params'}
            execute_chart_data: if True, also execute chart queries (level 3-4)

        Returns:
            {datasets: [...], chart_data: [...]}
        """
        # Collect unique datasource_ids
        unique_ds_ids: set[int] = set()
        for chart in chart_list:
            ds_id = chart.get("datasource_id")
            if ds_id is not None:
                unique_ds_ids.add(int(ds_id))

        # Check each dataset
        dataset_results: list[dict] = []
        for ds_id in sorted(unique_ds_ids):
            result = await self.check_dataset_health(ds_id)
            # Map affected charts
            affected_charts = [
                {"chart_id": c.get("slice_id"), "chart_name": c.get("slice_name", f"chart_{c.get('slice_id')}")}
                for c in chart_list
                if c.get("datasource_id") == ds_id
            ]
            result["affected_charts"] = affected_charts
            dataset_results.append(result)

        # Optionally execute chart data
        chart_data_results: list[dict] = []
        if execute_chart_data:
            for chart in chart_list:
                chart_id = chart.get("slice_id")
                params = chart.get("params", {})
                if isinstance(params, str):
                    params = json.loads(params)
                form_data = {
                    "slice_id": chart_id,
                    "viz_type": chart.get("viz_type", "table"),
                    "datasource_id": chart.get("datasource_id"),
                    "datasource_type": chart.get("datasource_type", "table"),
                    "granularity_sqla": params.get("granularity_sqla"),
                    "time_range": params.get("time_range", "Last 30 days"),
                    "metrics": params.get("metrics", []),
                    "groupby": params.get("groupby", []),
                    "adhoc_filters": params.get("adhoc_filters", []),
                }
                result = await self.check_chart_data(chart_id, form_data)
                chart_data_results.append(result)

        return {
            "datasets": dataset_results,
            "chart_data": chart_data_results,
        }
    # endregion DatasetHealthChecker.check_dashboard_datasets
# #endregion DatasetHealthChecker

# #region RedactionService [C:2] [TYPE Module]
# @BRIEF Redacts PII, credentials, and sensitive data from logs and LLM responses.
# @LAYER Service
# @RATIONALE FR-029: sensitive data must be filtered BEFORE external LLM send and BEFORE persistence.
class RedactionService:
    """Redacts PII, credentials, and sensitive data."""

    # Common patterns to redact
    PATTERNS = [
        (r"password[=:]\s*\S+", "password=***"),
        (r"secret[=:]\s*\S+", "secret=***"),
        (r"token[=:]\s*\S+", "token=***"),
        (r"api_key[=:]\s*\S+", "api_key=***"),
        (r"apikey[=:]\s*\S+", "apikey=***"),
        (r"Authorization:\s*\S+", "Authorization: ***"),
        (r"Bearer\s+\S+\.\S+\.\S+", "Bearer ***"),
        (r"[A-Za-z0-9+/=]{40,}", "***"),  # base64 or long tokens
        (r"[\w.+-]+@[\w-]+\.[\w.-]+", "***@***"),  # emails
    ]

    # region RedactionService.redact_logs [TYPE Function] [C:2]
    # @BRIEF Redact PII/credentials from log lines.
    # @PRE logs is a list of strings.
    # @POST Returns redacted list preserving structure.
    @staticmethod
    def redact_logs(logs: list[str]) -> list[str]:
        """Redact PII/credentials from log lines."""
        redacted = []
        for line in logs:
            for pattern, replacement in RedactionService.PATTERNS:
                line = re.sub(pattern, replacement, line, flags=re.IGNORECASE)
            redacted.append(line)
        return redacted
    # endregion RedactionService.redact_logs

    # region RedactionService.redact_raw_response [TYPE Function] [C:2]
    # @BRIEF Redact sensitive data from LLM raw response.
    # @PRE raw is a string.
    # @POST Returns redacted string.
    @staticmethod
    def redact_raw_response(raw: str) -> str:
        """Redact sensitive data from LLM raw response."""
        for pattern, replacement in RedactionService.PATTERNS:
            raw = re.sub(pattern, replacement, raw, flags=re.IGNORECASE)
        return raw
    # endregion RedactionService.redact_raw_response
# #endregion RedactionService

# #endregion LLMAnalysisService
