# #region LLMAnalysisPlugin [C:5] [TYPE Module] [SEMANTICS llm, analysis, dashboard, validation, documentation]
# @BRIEF Implements DashboardValidationPlugin and DocumentationPlugin.
# @LAYER Plugin
# @RELATION INHERITS -> [PluginBase]
# @RELATION CALLS -> [ScreenshotService]
# @RELATION CALLS -> [LLMClient]
# @RELATION CALLS -> [LLMProviderService]
# @RELATION CALLS -> [DatasetHealthChecker]
# @RELATION CALLS -> [AsyncSupersetClient]
# @RELATION DEPENDS_ON -> [TaskContext]
# @INVARIANT  All LLM interactions must be executed as asynchronous tasks.
# @DATA_CONTRACT  AnalysisRequest -> AnalysisResult

from datetime import UTC, datetime, timedelta
import json
import os
import re
from typing import Any

from ...core.async_superset_client import AsyncSupersetClient  # noqa: F401 — available for async client wiring
from ...core.database import SessionLocal
from ...core.logger import belief_scope, logger
from ...core.plugin_base import PluginBase
from ...core.superset_client import SupersetClient
from ...core.task_manager.context import TaskContext
from ...models.llm import ValidationPolicy, ValidationRecord, ValidationRun
from ...services.llm_prompt_templates import (
    DEFAULT_LLM_PROMPTS,
    normalize_llm_settings,
    render_prompt,
)
from ...services.llm_provider import LLMProviderService
from ...services.notifications.service import NotificationService
from .models import DetectedIssue, LLMProviderType, ValidationResult, ValidationStatus
from ._topology import build_dashboard_topology
from .service import DatasetHealthChecker, LLMClient, ScreenshotService


# #region _is_masked_or_invalid_api_key [TYPE Function]
# @BRIEF Guards against placeholder or malformed API keys in runtime.
# @PRE  value may be None.
# @POST  Returns True when value cannot be used for authenticated provider calls.
def _is_masked_or_invalid_api_key(value: str | None) -> bool:
    key = (value or "").strip()
    if not key:
        return True
    if key in {"********", "EMPTY_OR_NONE"}:
        return True
    # Most provider tokens are significantly longer; short values are almost always placeholders.
    return len(key) < 16
# #endregion _is_masked_or_invalid_api_key


# #region _json_safe_value [TYPE Function]
# @BRIEF Recursively normalize payload values for JSON serialization.
# @PRE  value may be nested dict/list with datetime values.
# @POST  datetime values are converted to ISO strings.
def _json_safe_value(value: Any):
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, dict):
        return {k: _json_safe_value(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_json_safe_value(v) for v in value]
    return value
# #endregion _json_safe_value


# #region _ensure_json_prompt [TYPE Function]
# @BRIEF Appends JSON format instruction to any prompt that lacks it, so LLM output is always parseable.
# @PRE prompt may be None or any string.
# @POST Returns prompt with JSON format instruction appended if not already present.
# @RATIONALE Users can set custom prompts via the UI that don't include JSON format.
#   Without this, the LLM returns free-text that the {status,summary,issues} parser rejects.
# @REJECTED Silently ignoring custom prompts rejected — users need custom instructions for domain context.
#   Wrapping in system message rejected — many LLM providers don't support system messages.
#   Stripping custom prompt content rejected — the custom instructions are valuable.
JSON_FORMAT_INSTRUCTION = (
    '\n\nRespond ONLY with valid JSON in this exact format (no markdown, no backticks, no code fences):\n'
    '{\n'
    '    "status": "PASS" | "WARN" | "FAIL",\n'
    '    "summary": "Short summary of findings",\n'
    '    "issues": [\n'
    '        {\n'
    '            "severity": "WARN" | "FAIL",\n'
    '            "message": "Description of the issue",\n'
    '            "location": "Where the issue is (tab name, chart name, or screen region)",\n'
    '            "tab_index": "0-based index of the tab screenshot where the issue appears (-1 if unknown or not tab-specific)"\n'
    '        }\n'
    '    ]\n'
    '}'
)


def _ensure_json_prompt(prompt: str | None) -> str:
    """Append JSON format instruction if prompt doesn't already request JSON output."""
    if not prompt:
        return "Analyze the dashboard" + JSON_FORMAT_INSTRUCTION
    # Check if prompt already asks for JSON
    lower = prompt.lower()
    if 'json' in lower and ('"status"' in prompt or '"summary"' in prompt):
        return prompt  # already has JSON format
    if '{' in prompt and '"' in prompt:
        return prompt  # likely already has JSON structure
    return prompt + JSON_FORMAT_INSTRUCTION
# #endregion _ensure_json_prompt


# #region _update_run_status [C:3] [TYPE Function]
# @BRIEF Aggregate ValidationRecord statuses into the ValidationRun.
#   Marks run as completed when ALL expected dashboards have a record.
#   Computes pass/warn/fail/unknown counters from individual record statuses.
# @RATIONALE Before the fix, this function used `from ..models.llm import ValidationRun`
#   which resolved to src.plugins.models.llm (doesn't exist) — the ModuleNotFoundError
#   was silently swallowed by `except Exception: pass`, causing the run to stay "running"
#   indefinitely. Fixed by importing ValidationRun at module level.
# @REJECTED Leaving the run in "running" status rejected — blocks future runs for the same
#   policy and shows an incomplete state in the UI. Setting run.status = worst record status
#   rejected — the run lifecycle status must be "completed"/"partial"/"failed", not "FAIL".
# @PRE db session is active, run_id may be None.
# @POST If run_id is set and all expected dashboards are recorded,
#   ValidationRun.status is set to 'completed', counters are computed.
#   duration_ms becomes available via finished_at.
def _update_run_status(db, run_id: str | None) -> None:
    if not run_id:
        return
    with belief_scope("_update_run_status", f"run_id={run_id}"):
        from datetime import datetime, timezone
        try:
            records = db.query(ValidationRecord).filter(ValidationRecord.run_id == run_id).all()
            run = db.query(ValidationRun).filter(ValidationRun.id == run_id).first()
            if not records or not run:
                logger.warning(f"[_update_run_status] Run {run_id} not found or has no records")
                return
            total_expected = run.dashboard_count or 0
            if total_expected > 0 and len(records) < total_expected:
                logger.info(
                    f"[_update_run_status] Run {run_id}: {len(records)}/{total_expected} records, not yet complete"
                )
                return
            pass_count = sum(1 for r in records if r.status == 'PASS')
            warn_count = sum(1 for r in records if r.status == 'WARN')
            fail_count = sum(1 for r in records if r.status == 'FAIL')
            unknown_count = sum(1 for r in records if r.status == 'UNKNOWN')
            run.status = 'completed'
            run.finished_at = datetime.now(timezone.utc)
            run.pass_count = pass_count
            run.warn_count = warn_count
            run.fail_count = fail_count
            run.unknown_count = unknown_count
            db.commit()
            logger.info(
                f"[_update_run_status] Run {run_id} completed: "
                f"PASS={pass_count} WARN={warn_count} FAIL={fail_count} UNKNOWN={unknown_count}"
            )
        except Exception as e:
            logger.error(f"[_update_run_status] Failed to finalize run {run_id}: {e}")
# #endregion _update_run_status


# #region DashboardValidationPlugin [TYPE Class]
# @BRIEF Plugin for automated dashboard health analysis using LLMs.
# @RELATION IMPLEMENTS -> [PluginBase]
class DashboardValidationPlugin(PluginBase):
    @property
    def id(self) -> str:
        return "llm_dashboard_validation"

    @property
    def name(self) -> str:
        return "Dashboard LLM Validation"

    @property
    def description(self) -> str:
        return "Automated dashboard health analysis using LLMs."

    @property
    def version(self) -> str:
        return "1.0.0"

    def get_schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "dashboard_id": {"type": "string", "title": "Dashboard ID"},
                "environment_id": {"type": "string", "title": "Environment ID"},
                "provider_id": {"type": "string", "title": "LLM Provider ID"},
                "screenshot_enabled": {
                    "type": "boolean",
                    "title": "Enable Screenshot Capture",
                    "default": True,
                },
                "prompt_template": {
                    "type": "string",
                    "title": "Custom Prompt Template (optional)",
                },
                "execute_chart_data": {
                    "type": "boolean",
                    "title": "Execute chart data queries for Path B (level 3-4 health check)",
                    "default": False,
                },
            },
            "required": ["dashboard_id", "environment_id", "provider_id"],
        }

    # region DashboardValidationPlugin.execute [TYPE Function]
    # @PURPOSE: Dispatches dashboard validation to Path A (multimodal screenshot + LLM)
    #   or Path B (API-based topology + dataset health + text LLM) based on screenshot_enabled.
    # @PARAM  params (Dict[str, Any]) - Validation parameters including screenshot_enabled flag.
    # @PARAM  context (Optional[TaskContext]) - Task context for logging with source attribution.
    # @PRE  params contains dashboard_id, environment_id, and provider_id.
    # @POST  Returns a dictionary with validation results and persists them to the database.
    #   Guarantees _update_run_status is called even if individual dashboard processing fails.
    # @SIDE_EFFECT  When screenshot_enabled=True: captures a screenshot, calls multimodal LLM API.
    #   When screenshot_enabled=False: fetches chart/dataset metadata, calls text-only LLM API.
    #   Both paths write to the database and dispatch notifications.
    # @REJECTED Early exit from the dashboard loop on exception rejected — previously an error
    #   in one dashboard caused _update_run_status to never be called, leaving the run stuck
    #   in "running" status forever. Now each dashboard is wrapped in try/except and the
    #   loop always proceeds to _update_run_status.
    async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
        with belief_scope("execute", f"plugin_id={self.id}"):
            log = context.logger if context else logger

            log.info(f"Executing {self.name} with params: {params}")

            env_id = params.get("environment_id")
            provider_id = params.get("provider_id")

            db = SessionLocal()
            try:
                # 1. Get Environment
                from ...dependencies import get_config_manager

                config_mgr = get_config_manager()
                env = config_mgr.get_environment(env_id)
                if not env:
                    log.error(f"Environment {env_id} not found")
                    raise ValueError(f"Environment {env_id} not found")

                # 2. Get LLM Provider
                llm_service = LLMProviderService(db)
                db_provider = llm_service.get_provider(provider_id)
                if not db_provider:
                    log.error(f"LLM Provider {provider_id} not found")
                    raise ValueError(f"LLM Provider {provider_id} not found")

                llm_log = log.with_source("llm") if context else log
                llm_log.debug("Retrieved provider config:")
                llm_log.debug(f"  Provider ID: {db_provider.id}")
                llm_log.debug(f"  Provider Name: {db_provider.name}")
                llm_log.debug(f"  Provider Type: {db_provider.provider_type}")
                llm_log.debug(f"  Base URL: {db_provider.base_url}")
                llm_log.debug(f"  Default Model: {db_provider.default_model}")
                llm_log.debug(f"  Is Active: {db_provider.is_active}")
                llm_log.debug(f"  Is Multimodal: {db_provider.is_multimodal}")

                api_key = llm_service.get_decrypted_api_key(provider_id)
                llm_log.debug(
                    f"API Key decrypted (first 8 chars): "
                    f"{api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}..."
                )

                # Check if API key was successfully decrypted
                if _is_masked_or_invalid_api_key(api_key):
                    raise ValueError(
                        f"Invalid API key for provider {provider_id}. "
                        "Please open LLM provider settings and save a real API key (not masked placeholder)."
                    )

                # Resolve dashboard_ids from v2 list or v1 single string
                all_dashboard_ids: list[str] = []
                raw_ids = params.get("dashboard_ids")
                if raw_ids and isinstance(raw_ids, list) and len(raw_ids) > 0:
                    all_dashboard_ids = [str(did) for did in raw_ids]
                single_id = params.get("dashboard_id")
                if single_id and single_id not in all_dashboard_ids:
                    all_dashboard_ids.append(str(single_id))
                if not all_dashboard_ids:
                    raise ValueError("No dashboard_id provided in params (dashboard_ids list is empty or missing)")

                screenshot_enabled = params.get("screenshot_enabled", True)
                if screenshot_enabled and not bool(db_provider.is_multimodal):
                    raise ValueError(
                        "Dashboard validation with screenshot_enabled=True requires "
                        "a multimodal model (image input support)."
                    )

                # Process each dashboard sequentially, collecting results
                results: list[dict[str, Any]] = []
                last_error: BaseException | None = None
                for idx, dashboard_id in enumerate(all_dashboard_ids):
                    log.info(f"Processing dashboard {idx + 1}/{len(all_dashboard_ids)}: {dashboard_id}")
                    params["dashboard_id"] = dashboard_id

                    # Re-parse URL sources per FR-057
                    dashboard_url = params.get("dashboard_url")
                    if dashboard_url:
                        try:
                            from ...core.utils.superset_context_extractor import SupersetContextExtractor

                            client = SupersetClient(env)
                            extractor = SupersetContextExtractor(environment=env, client=client)
                            parsed = extractor.parse_superset_link(dashboard_url)

                            if parsed.dashboard_id:
                                params["dashboard_id"] = str(parsed.dashboard_id)

                            # Store parsed context for tab/filter navigation
                            params["parsed_context"] = {
                                "dashboard_id": parsed.dashboard_id,
                                "native_filters": parsed.imported_filters if hasattr(parsed, "imported_filters") else None,
                                "active_tabs": [],
                                "anchor": None,
                            }

                            log.info(f"[URL re-parse] Resolved dashboard_id={parsed.dashboard_id} from URL")

                        except Exception as e:
                            log.warning(f"[URL re-parse] Failed to parse URL: {e}")
                            # Mark source as failed — task will skip this dashboard
                            params["source_invalid"] = True
                            params["source_error"] = str(e)

                    try:
                        if screenshot_enabled:
                            result = await self._execute_path_a(
                                params, context, db, db_provider, api_key, env, config_mgr, log
                            )
                        else:
                            result = await self._execute_path_b(
                                params, context, db, db_provider, api_key, env, config_mgr, log
                            )
                        results.append(result)
                    except Exception as e:
                        log.error(f"Dashboard {dashboard_id} processing failed: {e}")
                        last_error = e
                        # Continue to next dashboard — records for already-processed ones are saved

                # After ALL dashboards processed (even if some failed), update run status
                _update_run_status(db, params.get("run_id"))

                if last_error:
                    raise last_error  # Re-raise so TaskManager marks the task as FAILED
                return {"dashboards": results, "total": len(results)}

            finally:
                db.close()

    # endregion DashboardValidationPlugin.execute

    # region DashboardValidationPlugin._execute_path_a [TYPE Function]
    # @PURPOSE: Path A — capture Playwright screenshot, split into chunks for large dashboards,
    #   send as multimodal LLM call, persist result.
    # @PARAM  params (Dict) - Validation parameters including dashboard_id, environment_id.
    # @PARAM  context (Optional[TaskContext]) - Task context for logging.
    # @PARAM  db (Session) - Database session.
    # @PARAM  db_provider (Provider model) - LLM provider config.
    # @PARAM  api_key (str) - Decrypted API key.
    # @PARAM  env (Environment) - Superset environment config.
    # @PARAM  config_mgr (ConfigManager) - Application config manager.
    # @PARAM  log (Logger) - Logger instance.
    # @PRE  provider is multimodal (supports image inputs).
    # @POST  Returns validation result dict with screenshot_paths, logs, and analysis.
    # @SIDE_EFFECT  Captures screenshot to disk, calls multimodal LLM API, writes DB record.
    # @RELATION CALLS -> [ScreenshotService]
    # @RELATION CALLS -> [LLMClient.analyze_dashboard]
    async def _execute_path_a(
        self,
        params: dict[str, Any],
        context: TaskContext | None,
        db,
        db_provider,
        api_key: str,
        env,
        config_mgr,
        log,
    ) -> dict[str, Any]:
        with belief_scope("_execute_path_a"):
            validation_started_at = datetime.now(UTC)
            llm_log = log.with_source("llm") if context else log
            screenshot_log = log.with_source("screenshot") if context else log

            dashboard_id = params.get("dashboard_id")
            env_id = params.get("environment_id")

            # 3. Capture Screenshot (v2 pipeline — returns JPEGs for LLM, archives to WebP)
            screenshot_service = ScreenshotService(env)

            storage_root = config_mgr.get_config().settings.storage.root_path
            screenshots_dir = os.path.join(storage_root, "screenshots")
            os.makedirs(screenshots_dir, exist_ok=True)

            filename = f"{dashboard_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
            screenshot_path = os.path.join(screenshots_dir, filename)

            screenshot_started_at = datetime.now(UTC)
            screenshot_log.info(f"Capturing screenshot for dashboard {dashboard_id}")
            jpeg_paths, archive_results = await screenshot_service.capture_dashboard(dashboard_id, screenshot_path)
            screenshot_finished_at = datetime.now(UTC)

            if not jpeg_paths:
                screenshot_log.warning(f"No screenshots captured for dashboard {dashboard_id}, falling back to text-only")
                # Fall through with empty jpeg_paths — LLM will get logs only
            else:
                screenshot_log.debug(f"Captured {len(jpeg_paths)} screenshot(s), archived {len(archive_results)} to WebP")

            # 4. Fetch Logs (from Environment /api/v1/log/)
            logs, logs_timings = await self._fetch_dashboard_logs(env, dashboard_id, log)

            # 5. Analyze with LLM (multimodal — use JPEG paths)
            llm_client = LLMClient(
                provider_type=LLMProviderType(db_provider.provider_type),
                api_key=api_key,
                base_url=db_provider.base_url,
                default_model=db_provider.default_model,
            )

            llm_log.info(f"Analyzing dashboard {dashboard_id} with multimodal LLM")
            # Use user's custom prompt if provided, else v2 multimodal default; always ensure JSON format
            raw_prompt = params.get("prompt_template") or DEFAULT_LLM_PROMPTS["dashboard_validation_prompt_multimodal"]
            dashboard_prompt = _ensure_json_prompt(raw_prompt)
            llm_call_started_at = datetime.now(UTC)
            if jpeg_paths:
                # Extract tab labels from JPEG filenames for prompt context
                # Filename format: {dashboard_id}_{tab_label}_{timestamp}_d{depth}_llm.jpg
                tab_labels = []
                for jp in jpeg_paths:
                    base = os.path.splitext(os.path.basename(jp))[0]
                    # Remove _llm suffix and parse
                    clean = base.rsplit("_llm", 1)[0] if base.endswith("_llm") else base
                    m = re.match(r"\d+_(.+)_\d+_d\d+$", clean)
                    tab_labels.append(m.group(1).replace("_", " ") if m else f"tab {len(tab_labels)}")
                analysis = await llm_client.analyze_dashboard_multimodal(
                    screenshot_paths=jpeg_paths,
                    logs=logs,
                    prompt_template=dashboard_prompt,
                    max_images=db_provider.max_images,
                    tab_labels=tab_labels,
                )
            else:
                # Fallback: text-only analysis if no screenshots
                analysis = await llm_client.analyze_dashboard_text_batch(
                    payloads=[{
                        "dashboard_id": dashboard_id,
                        "topology": {},
                        "dataset_health": {},
                        "log_text": "\n".join(logs),
                    }],
                    prompt_template=dashboard_prompt,
                )
                # Extract single result from batch response
                analysis = analysis.get("dashboards", [{}])[0] if analysis.get("dashboards") else {
                    "status": "UNKNOWN",
                    "summary": "No analysis available (no screenshots, text fallback incomplete)",
                    "issues": [],
                }
            llm_call_finished_at = datetime.now(UTC)

            # Clean up JPEG temp files after LLM analysis
            ScreenshotService._cleanup_temp_files(jpeg_paths)

            # Log analysis summary to task logs for better visibility
            llm_log.info(f"[ANALYSIS_SUMMARY] Status: {analysis['status']}")
            llm_log.info(f"[ANALYSIS_SUMMARY] Summary: {analysis['summary']}")
            if analysis.get("issues"):
                for i, issue in enumerate(analysis["issues"]):
                    llm_log.info(
                        f"[ANALYSIS_ISSUE][{i + 1}] {issue.get('severity')}: "
                        f"{issue.get('message')} (Location: {issue.get('location', 'N/A')})"
                    )

            # 6. Persist Result
            webp_paths = [r.get("webp_path", "") for r in archive_results if r.get("webp_path")]
            validation_result = ValidationResult(
                dashboard_id=dashboard_id,
                status=ValidationStatus(analysis["status"]),
                summary=analysis["summary"],
                issues=[DetectedIssue(**issue) for issue in analysis["issues"]],
                screenshot_path=webp_paths[0] if webp_paths else None,
                raw_response=str(analysis),
            )
            validation_finished_at = datetime.now(UTC)

            result_payload = _json_safe_value(validation_result.model_dump())
            result_payload["execution_path"] = "multimodal"
            result_payload["chunk_count"] = analysis.get("chunk_count", 1)
            result_payload["screenshot_paths"] = webp_paths or jpeg_paths
            result_payload["logs_sent_to_llm"] = logs
            result_payload["logs_sent_count"] = len(logs)
            result_payload["prompt_template"] = dashboard_prompt
            result_payload["provider"] = {
                "id": db_provider.id,
                "name": db_provider.name,
                "type": db_provider.provider_type,
                "base_url": db_provider.base_url,
                "model": db_provider.default_model,
            }
            result_payload["environment_id"] = env_id
            result_payload["timings"] = {
                "validation_started_at": validation_started_at.isoformat(),
                "validation_finished_at": validation_finished_at.isoformat(),
                "validation_duration_ms": int(
                    (validation_finished_at - validation_started_at).total_seconds() * 1000
                ),
                "screenshot_started_at": screenshot_started_at.isoformat(),
                "screenshot_finished_at": screenshot_finished_at.isoformat(),
                "screenshot_duration_ms": int(
                    (screenshot_finished_at - screenshot_started_at).total_seconds() * 1000
                ),
                **logs_timings,
                "llm_call_started_at": llm_call_started_at.isoformat(),
                "llm_call_finished_at": llm_call_finished_at.isoformat(),
                "llm_call_duration_ms": int(
                    (llm_call_finished_at - llm_call_started_at).total_seconds() * 1000
                ),
            }

            db_record = ValidationRecord(
                task_id=context.task_id if context else None,
                policy_id=params.get("policy_id"),
                run_id=params.get("run_id"),
                dashboard_id=validation_result.dashboard_id,
                environment_id=env_id,
                status=validation_result.status.value,
                summary=validation_result.summary,
                issues=[issue.model_dump() for issue in validation_result.issues],
                screenshot_path=validation_result.screenshot_path,
                screenshot_paths=webp_paths or jpeg_paths or [],
                logs_sent_to_llm=logs,
                execution_path="screenshot",
                raw_response=json.dumps(result_payload, ensure_ascii=False),
            )
            db.add(db_record)
            db.commit()

            # 7. Notification on failure (US1 / FR-015)
            try:
                policy_id = params.get("policy_id")
                policy = None
                if policy_id:
                    policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == policy_id).first()

                notification_service = NotificationService(db, config_mgr)
                await notification_service.dispatch_report(
                    record=db_record,
                    policy=policy,
                    background_tasks=getattr(context, "background_tasks", None) if context else None,
                )
            except Exception as e:
                log.error(f"Failed to dispatch notifications: {e}")

            # Final log to ensure all analysis is visible in task logs
            log.info(
                f"Validation completed for dashboard {dashboard_id}. "
                f"Status: {validation_result.status.value}"
            )

            return result_payload

    # endregion DashboardValidationPlugin._execute_path_a

    # region DashboardValidationPlugin._execute_path_b [TYPE Function]
    # @PURPOSE: Path B — fetch dashboard structure via API, check dataset health,
    #   build topology text, send text-only LLM call, persist result.
    # @PARAM  params (Dict) - Validation parameters including dashboard_id, environment_id,
    #   execute_chart_data, and optional prompt_template.
    # @PARAM  context (Optional[TaskContext]) - Task context for logging.
    # @PARAM  db (Session) - Database session.
    # @PARAM  db_provider (Provider model) - LLM provider config.
    # @PARAM  api_key (str) - Decrypted API key.
    # @PARAM  env (Environment) - Superset environment config.
    # @PARAM  config_mgr (ConfigManager) - Application config manager.
    # @PARAM  log (Logger) - Logger instance.
    # @PRE  provider has a valid API key.
    # @POST  Returns validation result dict with topology, dataset_health, logs, and analysis.
    # @SIDE_EFFECT  Calls Superset REST API for dashboard/chart/dataset metadata,
    #   calls text-only LLM API, writes DB record.
    # @RELATION CALLS -> [SupersetClient]
    # @RELATION CALLS -> [DatasetHealthChecker]
    # @RELATION CALLS -> [LLMClient.get_json_completion]
    async def _execute_path_b(
        self,
        params: dict[str, Any],
        context: TaskContext | None,
        db,
        db_provider,
        api_key: str,
        env,
        config_mgr,
        log,
    ) -> dict[str, Any]:
        with belief_scope("_execute_path_b"):
            validation_started_at = datetime.now(UTC)
            llm_log = log.with_source("llm") if context else log
            superset_log = log.with_source("superset_api") if context else log

            dashboard_id = params.get("dashboard_id")
            env_id = params.get("environment_id")
            execute_chart_data = params.get("execute_chart_data", False)

            # 1. Fetch dashboard metadata from Superset
            superset_log.info(f"Fetching dashboard {dashboard_id} metadata for Path B")
            client = SupersetClient(env)
            dashboard_raw = client.get_dashboard(dashboard_id)
            dashboard = dashboard_raw.get("result", dashboard_raw) if isinstance(dashboard_raw, dict) else dashboard_raw
            slices = dashboard.get("slices", [])

            # 2. Fetch each chart's params for full detail
            chart_data: list[dict] = []
            chart_entries: list[dict] = []
            for slice_ref in slices:
                slice_id = slice_ref.get("id") or slice_ref.get("slice_id")
                if slice_id:
                    try:
                        chart_raw = client.get_chart(int(slice_id))
                        chart = chart_raw.get("result", chart_raw) if isinstance(chart_raw, dict) else chart_raw
                        chart_data.append(chart)
                        chart_entries.append({
                            "slice_id": chart.get("id"),
                            "slice_name": chart.get("slice_name", f"chart_{chart.get('id')}"),
                            "datasource_id": chart.get("datasource_id"),
                            "datasource_type": chart.get("datasource_type", "table"),
                            "viz_type": chart.get("viz_type"),
                            "params": chart.get("params", "{}"),
                        })
                    except Exception as e:
                        superset_log.warning(f"Failed to fetch chart {slice_id}: {e}")

            superset_log.info(f"Fetched {len(chart_data)} charts for dashboard {dashboard_id}")

            # 3. Fetch logs
            logs, logs_timings = await self._fetch_dashboard_logs(env, dashboard_id, log)

            # 4. Check dataset health
            health_checker = DatasetHealthChecker(client)
            health_result = await health_checker.check_dashboard_datasets(
                chart_entries, execute_chart_data=execute_chart_data
            )

            superset_log.info(
                f"Checked {len(health_result.get('datasets', []))} datasets, "
                f"{len(health_result.get('chart_data', []))} chart data executions"
            )

            # 5. Build topology text
            topology = build_dashboard_topology(dashboard, chart_data)

            # 6. Build text prompt and call LLM (text-only)
            llm_client = LLMClient(
                provider_type=LLMProviderType(db_provider.provider_type),
                api_key=api_key,
                base_url=db_provider.base_url,
                default_model=db_provider.default_model,
            )

            llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
            raw_prompt = params.get("prompt_template") or llm_settings["prompts"].get(
                "dashboard_validation_prompt_text",
                DEFAULT_LLM_PROMPTS["dashboard_validation_prompt_text"],
            )
            prompt_template = _ensure_json_prompt(raw_prompt)

            rendered_prompt = render_prompt(
                prompt_template,
                {
                    "topology": topology,
                    "dataset_health": json.dumps(
                        health_result.get("datasets", []), ensure_ascii=False, indent=2
                    ),
                    "logs": "\n".join(logs),
                },
            )

            llm_log.info(f"Analyzing dashboard {dashboard_id} with text-only LLM")
            llm_call_started_at = datetime.now(UTC)
            messages = [{"role": "user", "content": rendered_prompt}]
            analysis = await llm_client.get_json_completion(messages)
            llm_call_finished_at = datetime.now(UTC)

            # Log analysis summary
            llm_log.info(f"[ANALYSIS_SUMMARY] Status: {analysis.get('status', 'UNKNOWN')}")
            llm_log.info(f"[ANALYSIS_SUMMARY] Summary: {analysis.get('summary', '')}")
            if analysis.get("issues"):
                for i, issue in enumerate(analysis["issues"]):
                    llm_log.info(
                        f"[ANALYSIS_ISSUE][{i + 1}] {issue.get('severity')}: "
                        f"{issue.get('message')} (Location: {issue.get('location', 'N/A')})"
                    )

            # 7. Persist Result (no screenshot)
            validation_result = ValidationResult(
                dashboard_id=dashboard_id,
                status=ValidationStatus(analysis.get("status", "UNKNOWN")),
                summary=analysis.get("summary", ""),
                issues=[DetectedIssue(**issue) for issue in analysis.get("issues", [])],
                screenshot_path=None,
                raw_response=str(analysis),
            )
            validation_finished_at = datetime.now(UTC)

            result_payload = _json_safe_value(validation_result.model_dump())
            result_payload["execution_path"] = "text_only"
            result_payload["screenshot_paths"] = []
            result_payload["logs_sent_to_llm"] = logs
            result_payload["logs_sent_count"] = len(logs)
            result_payload["prompt_template"] = prompt_template
            result_payload["topology"] = topology
            result_payload["dataset_health"] = _json_safe_value(health_result)
            result_payload["provider"] = {
                "id": db_provider.id,
                "name": db_provider.name,
                "type": db_provider.provider_type,
                "base_url": db_provider.base_url,
                "model": db_provider.default_model,
            }
            result_payload["environment_id"] = env_id
            result_payload["timings"] = {
                "validation_started_at": validation_started_at.isoformat(),
                "validation_finished_at": validation_finished_at.isoformat(),
                "validation_duration_ms": int(
                    (validation_finished_at - validation_started_at).total_seconds() * 1000
                ),
                **logs_timings,
                "llm_call_started_at": llm_call_started_at.isoformat(),
                "llm_call_finished_at": llm_call_finished_at.isoformat(),
                "llm_call_duration_ms": int(
                    (llm_call_finished_at - llm_call_started_at).total_seconds() * 1000
                ),
            }

            db_record = ValidationRecord(
                task_id=context.task_id if context else None,
                policy_id=params.get("policy_id"),
                run_id=params.get("run_id"),
                dashboard_id=validation_result.dashboard_id,
                environment_id=env_id,
                status=validation_result.status.value,
                summary=validation_result.summary,
                issues=[issue.model_dump() for issue in validation_result.issues],
                screenshot_path=None,
                screenshot_paths=[],
                logs_sent_to_llm=logs,
                execution_path="text_only",
                raw_response=json.dumps(result_payload, ensure_ascii=False),
            )
            db.add(db_record)
            db.commit()

            # 8. Notification on failure (US1 / FR-015)
            try:
                policy_id = params.get("policy_id")
                policy = None
                if policy_id:
                    policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == policy_id).first()

                notification_service = NotificationService(db, config_mgr)
                await notification_service.dispatch_report(
                    record=db_record,
                    policy=policy,
                    background_tasks=getattr(context, "background_tasks", None) if context else None,
                )
            except Exception as e:
                log.error(f"Failed to dispatch notifications: {e}")

            log.info(
                f"Validation completed for dashboard {dashboard_id} (Path B). "
                f"Status: {validation_result.status.value}"
            )

            return result_payload

    # endregion DashboardValidationPlugin._execute_path_b

    # region DashboardValidationPlugin._fetch_dashboard_logs [TYPE Function]
    # @PURPOSE: Fetch execution logs for a dashboard from the Superset /api/v1/log/ endpoint.
    # @PARAM  env (Environment) - Superset environment config for API authentication.
    # @PARAM  dashboard_id (str) - Dashboard ID to filter logs.
    # @PARAM  log (Logger) - Logger instance for sub-logger creation.
    # @PRE  env is a valid Environment with working credentials.
    # @POST  Returns a tuple of (logs_list, timings_dict).
    # @SIDE_EFFECT  Calls Superset REST API /log/ endpoint.
    # @RELATION CALLS -> [SupersetClient.network.request]
    async def _fetch_dashboard_logs(
        self, env, dashboard_id: str, log
    ) -> tuple[list[str], dict]:
        """Fetch execution logs from the environment API returning (log_lines, timings)."""
        logs: list[str] = []
        logs_fetch_started_at = datetime.now(UTC)
        superset_log = log.with_source("superset_api") if hasattr(log, "with_source") else log

        try:
            client = SupersetClient(env)

            # Calculate time window (last 24 hours)
            start_time = (datetime.now() - timedelta(hours=24)).isoformat()

            # Construct filter for logs
            query_params = {
                "filters": [
                    {"col": "dashboard_id", "opr": "eq", "value": dashboard_id},
                    {"col": "dttm", "opr": "gt", "value": start_time},
                ],
                "order_column": "dttm",
                "order_direction": "desc",
                "page": 0,
                "page_size": 100,
            }

            superset_log.debug(f"Fetching logs for dashboard {dashboard_id}")
            response = client.network.request(
                method="GET",
                endpoint="/log/",
                params={"q": json.dumps(query_params)},
            )

            if isinstance(response, dict) and "result" in response:
                for item in response["result"]:
                    action = item.get("action", "unknown")
                    dttm = item.get("dttm", "")
                    details = item.get("json", "")
                    logs.append(f"[{dttm}] {action}: {details}")

            if not logs:
                logs = ["No recent logs found for this dashboard."]
                superset_log.debug("No recent logs found for this dashboard")

        except Exception as e:
            superset_log.warning(f"Failed to fetch logs from environment: {e}")
            logs = [f"Error fetching remote logs: {e!s}"]

        logs_fetch_finished_at = datetime.now(UTC)
        timings = {
            "logs_fetch_started_at": logs_fetch_started_at.isoformat(),
            "logs_fetch_finished_at": logs_fetch_finished_at.isoformat(),
            "logs_fetch_duration_ms": int(
                (logs_fetch_finished_at - logs_fetch_started_at).total_seconds() * 1000
            ),
        }

        return logs, timings

    # endregion DashboardValidationPlugin._fetch_dashboard_logs

    # endregion DashboardValidationPlugin


# #region DocumentationPlugin [TYPE Class]
# @BRIEF Plugin for automated dataset documentation using LLMs.
# @RELATION IMPLEMENTS -> [PluginBase]
class DocumentationPlugin(PluginBase):
    @property
    def id(self) -> str:
        return "llm_documentation"

    @property
    def name(self) -> str:
        return "Dataset LLM Documentation"

    @property
    def description(self) -> str:
        return "Automated dataset and column documentation using LLMs."

    @property
    def version(self) -> str:
        return "1.0.0"

    def get_schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "dataset_id": {"type": "string", "title": "Dataset ID"},
                "environment_id": {"type": "string", "title": "Environment ID"},
                "provider_id": {"type": "string", "title": "LLM Provider ID"}
            },
            "required": ["dataset_id", "environment_id", "provider_id"]
        }

    # region DocumentationPlugin.execute [TYPE Function]
    # @PURPOSE: Executes the dataset documentation task with TaskContext support.
    # @PARAM  params (Dict[str, Any]) - Documentation parameters.
    # @PARAM  context (Optional[TaskContext]) - Task context for logging with source attribution.
    # @PRE  params contains dataset_id, environment_id, and provider_id.
    # @POST  Returns generated documentation and updates the dataset in Superset.
    # @SIDE_EFFECT  Calls LLM API and updates dataset metadata in Superset.
    async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
        with belief_scope("execute", f"plugin_id={self.id}"):
            # Use TaskContext logger if available, otherwise fall back to app logger
            log = context.logger if context else logger

            # Create sub-loggers for different components
            llm_log = log.with_source("llm") if context else log
            superset_log = log.with_source("superset_api") if context else log

            log.info(f"Executing {self.name} with params: {params}")

            dataset_id = params.get("dataset_id")
            env_id = params.get("environment_id")
            provider_id = params.get("provider_id")

            db = SessionLocal()
            try:
                # 1. Get Environment
                from ...dependencies import get_config_manager
                config_mgr = get_config_manager()
                env = config_mgr.get_environment(env_id)
                if not env:
                    log.error(f"Environment {env_id} not found")
                    raise ValueError(f"Environment {env_id} not found")

                # 2. Get LLM Provider
                llm_service = LLMProviderService(db)
                db_provider = llm_service.get_provider(provider_id)
                if not db_provider:
                    log.error(f"LLM Provider {provider_id} not found")
                    raise ValueError(f"LLM Provider {provider_id} not found")

                llm_log.debug("Retrieved provider config:")
                llm_log.debug(f"  Provider ID: {db_provider.id}")
                llm_log.debug(f"  Provider Name: {db_provider.name}")
                llm_log.debug(f"  Provider Type: {db_provider.provider_type}")
                llm_log.debug(f"  Base URL: {db_provider.base_url}")
                llm_log.debug(f"  Default Model: {db_provider.default_model}")

                api_key = llm_service.get_decrypted_api_key(provider_id)
                llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")

                # Check if API key was successfully decrypted
                if _is_masked_or_invalid_api_key(api_key):
                    raise ValueError(
                        f"Invalid API key for provider {provider_id}. "
                        "Please open LLM provider settings and save a real API key (not masked placeholder)."
                    )

                # 3. Fetch Metadata (US2 / T024)
                from ...core.superset_client import SupersetClient
                client = SupersetClient(env)

                superset_log.debug(f"Fetching dataset {dataset_id}")
                dataset = client.get_dataset(int(dataset_id))

                # Extract columns and existing descriptions
                columns_data = []
                for col in dataset.get("columns", []):
                    columns_data.append({
                        "name": col.get("column_name"),
                        "type": col.get("type"),
                        "description": col.get("description")
                    })
                superset_log.debug(f"Extracted {len(columns_data)} columns from dataset")

                # 4. Construct Prompt & Analyze (US2 / T025)
                llm_client = LLMClient(
                    provider_type=LLMProviderType(db_provider.provider_type),
                    api_key=api_key,
                    base_url=db_provider.base_url,
                    default_model=db_provider.default_model
                )

                llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
                documentation_prompt = llm_settings["prompts"].get(
                    "documentation_prompt",
                    DEFAULT_LLM_PROMPTS["documentation_prompt"],
                )
                prompt = render_prompt(
                    documentation_prompt,
                    {
                        "dataset_name": dataset.get("table_name") or "",
                        "columns_json": json.dumps(columns_data, ensure_ascii=False),
                    },
                )

                # Using a generic chat completion for text-only US2
                llm_log.info(f"Generating documentation for dataset {dataset_id}")
                doc_result = await llm_client.get_json_completion([{"role": "user", "content": prompt}])

                # 5. Update Metadata (US2 / T026)
                update_payload = {
                    "description": doc_result["dataset_description"],
                    "columns": []
                }

                # Map generated descriptions back to column IDs
                for col_doc in doc_result["column_descriptions"]:
                    for col in dataset.get("columns", []):
                        if col.get("column_name") == col_doc["name"]:
                            update_payload["columns"].append({
                                "id": col.get("id"),
                                "description": col_doc["description"]
                            })

                superset_log.info(f"Updating dataset {dataset_id} with generated documentation")
                client.update_dataset(int(dataset_id), update_payload)

                log.info(f"Documentation completed for dataset {dataset_id}")

                return doc_result

            finally:
                db.close()
    # endregion DocumentationPlugin.execute
# #endregion DocumentationPlugin

# #endregion LLMAnalysisPlugin
