# [DEF:backend/src/plugins/llm_analysis/service.py:Module]
# @TIER: STANDARD
# @SEMANTICS: service, llm, screenshot, playwright, openai
# @PURPOSE: Services for LLM interaction and dashboard screenshots.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> playwright
# @RELATION: DEPENDS_ON -> openai
# @RELATION: DEPENDS_ON -> tenacity
# @INVARIANT: Screenshots must be 1920px width and capture full page height.

import asyncio
import base64
import json
import io
from typing import List, Dict, Any
import httpx
from PIL import Image
from playwright.async_api import async_playwright
from openai import AsyncOpenAI, RateLimitError, AuthenticationError as OpenAIAuthenticationError
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
from .models import LLMProviderType
from ...core.logger import belief_scope, logger
from ...core.config_models import Environment
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt

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

    # [DEF:ScreenshotService.capture_dashboard:Function]
    # @PURPOSE: Captures a full-page screenshot of a dashboard using Playwright and CDP.
    # @PRE: dashboard_id is a valid string, output_path is a writable path.
    # @POST: Returns True if screenshot is saved successfully.
    # @SIDE_EFFECT: Launches a browser, performs UI login, switches tabs, and writes a PNG file.
    # @UX_STATE: [Navigating] -> Loading dashboard UI
    # @UX_STATE: [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
    # @UX_STATE: [CalculatingHeight] -> Determining dashboard dimensions
    # @UX_STATE: [Capturing] -> Executing CDP screenshot
    async def capture_dashboard(self, dashboard_id: str, output_path: str) -> bool:
        with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
            logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
            async with async_playwright() as p:
                browser = await p.chromium.launch(
                    headless=True,
                    args=[
                        "--disable-blink-features=AutomationControlled",
                        "--disable-infobars",
                        "--no-sandbox"
                    ]
                )
                # Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
                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"
                # Construct base UI URL from environment (strip /api/v1 suffix)
                base_ui_url = self.env.url.rstrip("/")
                if base_ui_url.endswith("/api/v1"):
                    base_ui_url = base_ui_url[:-len("/api/v1")]
                
                # Create browser context with realistic headers
                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"
                    }
                )
                logger.info("Browser context created successfully")

                page = await context.new_page()
                # Bypass navigator.webdriver detection
                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/"
                logger.info(f"[DEBUG] Navigating to login page: {login_url}")
                
                response = await page.goto(login_url, wait_until="networkidle", timeout=60000)
                if response:
                    logger.info(f"[DEBUG] Login page response status: {response.status}")
                
                # Wait for login form to be ready
                await page.wait_for_load_state("domcontentloaded")
                
                # More exhaustive list of selectors for various Superset versions/themes
                selectors = {
                    "username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
                    "password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
                    "submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
                }
                logger.info("[DEBUG] Attempting to find login form elements...")
                
                try:
                    # Find and fill username
                    u_selector = None
                    for s in selectors["username"]:
                        count = await page.locator(s).count()
                        logger.info(f"[DEBUG] Selector '{s}': {count} elements found")
                        if count > 0:
                            u_selector = s
                            break
                    
                    if not u_selector:
                        # Log all input fields on the page for debugging
                        all_inputs = await page.locator('input').all()
                        logger.info(f"[DEBUG] Found {len(all_inputs)} input fields on page")
                        for i, inp in enumerate(all_inputs[:5]):  # Log first 5
                            inp_type = await inp.get_attribute('type')
                            inp_name = await inp.get_attribute('name')
                            inp_id = await inp.get_attribute('id')
                            logger.info(f"[DEBUG] Input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
                        raise RuntimeError("Could not find username input field on login page")
                    
                    logger.info(f"[DEBUG] Filling username field with selector: {u_selector}")
                    await page.fill(u_selector, self.env.username)
                    
                    # Find and fill password
                    p_selector = None
                    for s in selectors["password"]:
                        if await page.locator(s).count() > 0:
                            p_selector = s
                            break
                    
                    if not p_selector:
                        raise RuntimeError("Could not find password input field on login page")
                    
                    logger.info(f"[DEBUG] Filling password field with selector: {p_selector}")
                    await page.fill(p_selector, self.env.password)
                    
                    # Click submit
                    s_selector = selectors["submit"][0]
                    for s in selectors["submit"]:
                        if await page.locator(s).count() > 0:
                            s_selector = s
                            break
                    
                    logger.info(f"[DEBUG] Clicking submit button with selector: {s_selector}")
                    await page.click(s_selector)
                    
                    # Wait for navigation after login
                    await page.wait_for_load_state("networkidle", timeout=30000)
                    
                    # Check if login was successful
                    if "/login" in page.url:
                        # Check for error messages on page
                        error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
                        logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
                        debug_path = output_path.replace(".png", "_debug_failed_login.png")
                        await page.screenshot(path=debug_path)
                        raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
                    
                    logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
                    
                    # Check cookies after successful login
                    page_cookies = await context.cookies()
                    logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
                    for c in page_cookies:
                        logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
                    
                except Exception as e:
                    page_title = await page.title()
                    logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {str(e)}")
                    debug_path = output_path.replace(".png", "_debug_failed_login.png")
                    await page.screenshot(path=debug_path)
                    raise RuntimeError(f"Login failed: {str(e)}. Debug screenshot saved to {debug_path}")

                # 2. Navigate to dashboard
                # @UX_STATE: [Navigating] -> Loading dashboard UI
                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://")

                logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
                
                # Use networkidle to ensure all initial assets are loaded
                response = await page.goto(dashboard_url, wait_until="networkidle", timeout=60000)
                
                if response:
                    logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
                
                try:
                    # Wait for the dashboard grid to be present
                    await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=30000)
                    logger.info("[DEBUG] Dashboard container loaded")
                    
                    # Wait for charts to finish loading (Superset uses loading spinners/skeletons)
                    # We wait until loading indicators disappear or a timeout occurs
                    try:
                        # Wait for loading indicators to disappear
                        await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=60000)
                        logger.info("[DEBUG] Loading indicators hidden")
                    except Exception:
                        logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")

                    # Wait for charts to actually render their content (e.g., ECharts, NVD3)
                    # We look for common chart containers that should have content
                    try:
                        await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=60000)
                        logger.info("[DEBUG] Chart content detected")
                    except Exception:
                        logger.warning("[DEBUG] Timeout waiting for chart content")

                    # Additional check: wait for all chart containers to have non-empty content
                    logger.info("[DEBUG] Waiting for all charts to have rendered content...")
                    await page.wait_for_function("""() => {
                        const charts = document.querySelectorAll('.chart-container, .slice_container');
                        if (charts.length === 0) return true; // No charts to wait for
                        
                        // Check if all charts have rendered content (canvas, svg, or non-empty div)
                        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=60000)
                    logger.info("[DEBUG] All charts have rendered content")

                    # Scroll to bottom and back to top to trigger lazy loading of all charts
                    logger.info("[DEBUG] Scrolling to trigger lazy loading...")
                    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 as e:
                    logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
                
                # Final stabilization delay - increased for complex dashboards
                logger.info("[DEBUG] Final stabilization delay...")
                await asyncio.sleep(15)

                # Logic to handle tabs and full-page capture
                try:
                    # 1. Handle Tabs (Recursive switching)
                    # @UX_STATE: [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
                    processed_tabs = set()

                    async def switch_tabs(depth=0):
                        if depth > 3:
                            return # Limit recursion depth
                        
                        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 found_tabs:
                            logger.info(f"[DEBUG][TabSwitching] 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 await tab.is_visible():
                                        logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
                                        processed_tabs.add(tab_id)
                                        
                                        is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
                                        if not is_active:
                                            await tab.click()
                                            await asyncio.sleep(2) # Wait for content to render
                                        
                                        await switch_tabs(depth + 1)
                                except Exception as tab_e:
                                    logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
                            
                            try:
                                first_tab = found_tabs[0]
                                if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
                                    await first_tab.click()
                                    await asyncio.sleep(1)
                            except Exception:
                                pass

                    await switch_tabs()

                    # 2. Calculate full height for screenshot
                    # @UX_STATE: [CalculatingHeight] -> Determining dashboard dimensions
                    full_height = await page.evaluate("""() => {
                        const body = document.body;
                        const html = document.documentElement;
                        const dashboardContent = document.querySelector('.dashboard-content');
                        
                        return Math.max(
                            body.scrollHeight, body.offsetHeight,
                            html.clientHeight, html.scrollHeight, html.offsetHeight,
                            dashboardContent ? dashboardContent.scrollHeight + 100 : 0
                        );
                    }""")
                    logger.info(f"[DEBUG] Calculated full height: {full_height}")
                    
                    # DIAGNOSTIC: Count chart elements before resize
                    chart_count_before = await page.evaluate("""() => {
                        return {
                            chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
                            canvasElements: document.querySelectorAll('canvas').length,
                            svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
                            visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
                        };
                    }""")
                    logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
                    
                    # DIAGNOSTIC: Capture pre-resize screenshot for comparison
                    pre_resize_path = output_path.replace(".png", "_preresize.png")
                    try:
                        await page.screenshot(path=pre_resize_path, full_page=False, timeout=10000)
                        import os
                        pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
                        logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
                    except Exception as pre_e:
                        logger.warning(f"[DIAGNOSTIC] Failed to capture pre-resize screenshot: {pre_e}")
                    
                    logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
                    await page.set_viewport_size({"width": 1920, "height": int(full_height)})
                    
                    # DIAGNOSTIC: Increased wait time and log timing
                    logger.info("[DIAGNOSTIC] Waiting 10 seconds after viewport resize for re-render...")
                    await asyncio.sleep(10)
                    logger.info("[DIAGNOSTIC] Wait completed")
                    
                    # DIAGNOSTIC: Count chart elements after resize and wait
                    chart_count_after = await page.evaluate("""() => {
                        return {
                            chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
                            canvasElements: document.querySelectorAll('canvas').length,
                            svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
                            visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
                        };
                    }""")
                    logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
                    
                    # DIAGNOSTIC: Check if any charts have error states
                    chart_errors = await page.evaluate("""() => {
                        const errors = [];
                        document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
                            const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
                            if (errorEl) {
                                errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
                            }
                        });
                        return errors;
                    }""")
                    if chart_errors:
                        logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
                    else:
                        logger.info("[DIAGNOSTIC] No chart error states detected")

                    # 3. Take screenshot using CDP to bypass Playwright's font loading wait
                    # @UX_STATE: [Capturing] -> Executing CDP screenshot
                    logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
                    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(output_path, 'wb') as f:
                        f.write(image_data)
                    
                    # DIAGNOSTIC: Verify screenshot file
                    import os
                    final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
                    logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
                    logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
                    
                    # DIAGNOSTIC: Get image dimensions
                    try:
                        with Image.open(output_path) as final_img:
                            logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
                    except Exception as img_err:
                        logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
                        
                    logger.info(f"Full-page screenshot saved to {output_path} (via CDP)")
                except Exception as e:
                    logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
                    try:
                        await page.screenshot(path=output_path, full_page=True, timeout=10000)
                    except Exception as e2:
                        logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
                        await page.screenshot(path=output_path, timeout=5000)
                
                await browser.close()
                return True
    # [/DEF:ScreenshotService.capture_dashboard:Function]
# [/DEF:ScreenshotService:Class]

# [DEF:LLMClient:Class]
# @PURPOSE: Wrapper for LLM provider APIs.
class LLMClient:
    # [DEF:LLMClient.__init__: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.KILO:
            default_headers["Authentication"] = f"Bearer {self.api_key}"
            default_headers["X-API-Key"] = self.api_key

        http_client = httpx.AsyncClient(headers=default_headers, timeout=120.0)
        self.client = AsyncOpenAI(
            api_key=self.api_key,
            base_url=base_url,
            default_headers=default_headers,
            http_client=http_client,
        )
    # [/DEF:LLMClient.__init__:Function]

    # [DEF:LLMClient._supports_json_response_format: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:
        base = (self.base_url or "").lower()
        model = (self.default_model or "").lower()

        # OpenRouter routes to many upstream providers; some models reject json_object mode.
        if "openrouter.ai" in base:
            incompatible_tokens = (
                "stepfun/",
                "step-",
                ":free",
            )
            if any(token in model for token in incompatible_tokens):
                return False
        return True
    # [/DEF:LLMClient._supports_json_response_format:Function]

    # [DEF:LLMClient.get_json_completion: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 authentication errors."""
        # Don't retry on authentication errors
        if isinstance(exception, OpenAIAuthenticationError):
            return False
        # Retry on rate limit errors and other exceptions
        return isinstance(exception, (RateLimitError, Exception))
    
    @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: {str(e)}. 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: {str(e)}")
                # 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: {str(e)}")
                
                # 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: {str(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}")
            
            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
    # [/DEF:LLMClient.get_json_completion:Function]

    # [DEF:LLMClient.analyze_dashboard: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.
    async def analyze_dashboard(
        self,
        screenshot_path: str,
        logs: List[str],
        prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
    ) -> Dict[str, Any]:
        with belief_scope("analyze_dashboard"):
            # Optimize image to reduce token count (US1 / T023)
            # Gemini/Gemma models have limits on input tokens, and large images contribute significantly.
            try:
                with Image.open(screenshot_path) as img:
                    # Convert to RGB if necessary
                    if img.mode in ("RGBA", "P"):
                        img = img.convert("RGB")
                    
                    # Resize if too large (max 1024px width while maintaining aspect ratio)
                    # We reduce width further to 1024px to stay within token limits for long dashboards
                    max_width = 1024
                    if img.width > max_width or img.height > 2048:
                        # Calculate scaling factor to fit within 1024x2048
                        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)
                            logger.info(f"[analyze_dashboard] Resized image from {img.width}x{img.height} to {new_width}x{new_height}")
                    
                    # Compress and convert to base64
                    buffer = io.BytesIO()
                    # Lower quality to 60% to further reduce payload size
                    img.save(buffer, format="JPEG", quality=60, optimize=True)
                    base_64_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
                    logger.info(f"[analyze_dashboard] Optimized image size: {len(buffer.getvalue()) / 1024:.2f} KB")
            except Exception as img_e:
                logger.warning(f"[analyze_dashboard] Image optimization failed: {img_e}. Using raw image.")
                with open(screenshot_path, "rb") as image_file:
                    base_64_image = base64.b64encode(image_file.read()).decode('utf-8')

            log_text = "\n".join(logs)
            prompt = render_prompt(prompt_template, {"logs": log_text})
            
            messages = [
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": prompt},
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:image/jpeg;base64,{base_64_image}"
                            }
                        }
                    ]
                }
            ]
            
            try:
                return await self.get_json_completion(messages)
            except Exception as e:
                logger.error(f"[analyze_dashboard] Failed to get analysis: {str(e)}")
                return {
                    "status": "FAIL",
                    "summary": f"Failed to get response from LLM: {str(e)}",
                    "issues": [{"severity": "FAIL", "message": "LLM provider returned empty or invalid response"}]
                }
    # [/DEF:LLMClient.analyze_dashboard:Function]
# [/DEF:LLMClient:Class]

# [/DEF:backend/src/plugins/llm_analysis/service.py:Module]
