"""Looker API client wrapper used by the g-gremlin CLI."""

from __future__ import annotations

import os
import uuid
from typing import Any, Dict, List, Optional, Tuple

import click

from g_gremlin.base import AuthenticationError, BaseIntegration, RetryConfig
from g_gremlin.credentials import resolve_credentials
from g_gremlin.credentials.provider import CredentialProvider, SecretNotFoundError
from g_gremlin.looker.errors import LookerAmbiguousMatchError, LookerApiError, LookerSpecError
from g_gremlin.looker.spec_models import (
    LookerDashboardSpecV1,
    LookerFilterSpecV1,
    LookerLookSpecV1,
    LookerTileSpecV1,
    dashboard_to_spec_payload,
)

DEFAULT_RETRY_CONFIG = RetryConfig(max_attempts=4, base_delay=1.0, max_delay=10.0)


def _parse_bool(value: Any, default: bool = True) -> bool:
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    text = str(value).strip().lower()
    if text in {"1", "true", "t", "yes", "y", "on"}:
        return True
    if text in {"0", "false", "f", "no", "n", "off"}:
        return False
    return default


def _ensure_list(value: Any) -> List[Any]:
    if value is None:
        return []
    if isinstance(value, list):
        return value
    return [value]


class LookerClient(BaseIntegration):
    """Minimal Looker API wrapper for dashboard and query automation."""

    SERVICE_NAME = "looker"
    DEFAULT_API_VERSION = "4.0"

    def __init__(
        self,
        *,
        base_url: Optional[str] = None,
        client_id: Optional[str] = None,
        client_secret: Optional[str] = None,
        api_version: Optional[str] = None,
        verify_ssl: Optional[bool] = None,
        credential_provider: CredentialProvider | None = None,
        retry_config: RetryConfig | None = None,
    ) -> None:
        super().__init__(credential_provider=credential_provider, default_retry_config=retry_config or DEFAULT_RETRY_CONFIG)

        creds: Dict[str, str] = {}
        if not all([base_url, client_id, client_secret]) or api_version is None or verify_ssl is None:
            try:
                creds = resolve_credentials(
                    "looker",
                    required_keys=["base_url", "client_id", "client_secret"],
                    optional_keys=["api_version", "verify_ssl"],
                    credential_provider=self.credential_provider,
                )
            except SecretNotFoundError:
                creds = {}

        self.base_url = str(base_url or creds.get("base_url") or "").rstrip("/")
        self.client_id = str(client_id or creds.get("client_id") or "")
        self.client_secret = str(client_secret or creds.get("client_secret") or "")
        self.api_version = str(api_version or creds.get("api_version") or os.environ.get("G_GREMLIN_LOOKER_API_VERSION") or self.DEFAULT_API_VERSION).strip()
        self.verify_ssl = _parse_bool(
            verify_ssl if verify_ssl is not None else creds.get("verify_ssl"),
            default=True,
        )
        self._session.verify = self.verify_ssl

        if not self.base_url or not self.client_id or not self.client_secret:
            raise click.ClickException(
                "Looker credentials missing. Set env vars or run: g-gremlin looker connect"
            )

        self._access_token: Optional[str] = None

    def _api_url(self, path: str) -> str:
        normalized = path if path.startswith("/") else f"/{path}"
        return f"{self.base_url}/api/{self.api_version}{normalized}"

    def _auth_headers(self) -> Dict[str, str]:
        self._ensure_signed_in()
        return {
            "Authorization": f"token {self._access_token or ''}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        }

    def _ensure_signed_in(self) -> None:
        if not self._access_token:
            self.sign_in()

    def _parse_response_json(self, response) -> Any:
        try:
            return response.json()
        except Exception as exc:  # pragma: no cover - defensive
            raise LookerApiError(f"Failed to parse Looker response: {exc}") from exc

    def _request_json(
        self,
        method: str,
        path: str,
        *,
        params: Dict[str, Any] | None = None,
        payload: Dict[str, Any] | None = None,
        allow_statuses: Tuple[int, ...] = (),
        authenticated: bool = True,
    ) -> Any:
        headers = (
            self._auth_headers()
            if authenticated
            else {"Accept": "application/json", "Content-Type": "application/json"}
        )
        try:
            response = self._request(
                method,
                self._api_url(path),
                params=params,
                json=payload,
                headers=headers,
                allow_statuses=allow_statuses,
            )
        except AuthenticationError:
            if not authenticated:
                raise
            # Looker access tokens are short-lived. Re-auth once and retry.
            self._access_token = None
            self.sign_in()
            response = self._request(
                method,
                self._api_url(path),
                params=params,
                json=payload,
                headers=self._auth_headers(),
                allow_statuses=allow_statuses,
            )
        return self._parse_response_json(response)

    def _request_text(
        self,
        method: str,
        path: str,
        *,
        params: Dict[str, Any] | None = None,
        payload: Dict[str, Any] | None = None,
        authenticated: bool = True,
    ) -> str:
        headers = (
            self._auth_headers()
            if authenticated
            else {"Accept": "text/plain", "Content-Type": "application/json"}
        )
        try:
            response = self._request(
                method,
                self._api_url(path),
                params=params,
                json=payload,
                headers=headers,
            )
        except AuthenticationError:
            if not authenticated:
                raise
            self._access_token = None
            self.sign_in()
            response = self._request(
                method,
                self._api_url(path),
                params=params,
                json=payload,
                headers=self._auth_headers(),
            )
        return str(response.text or "")

    def _normalize_collection(self, payload: Any, key: str) -> List[Dict[str, Any]]:
        if isinstance(payload, list):
            return [item for item in payload if isinstance(item, dict)]
        if isinstance(payload, dict):
            value = payload.get(key)
            if isinstance(value, list):
                return [item for item in value if isinstance(item, dict)]
            if isinstance(value, dict):
                return [value]
        return []

    # ------------------------------------------------------------------
    # Auth / health
    # ------------------------------------------------------------------
    def sign_in(self) -> Dict[str, Any]:
        response = self._request(
            "POST",
            self._api_url("/login"),
            data={"client_id": self.client_id, "client_secret": self.client_secret},
            headers={
                "Accept": "application/json",
                "Content-Type": "application/x-www-form-urlencoded",
            },
        )
        payload = self._parse_response_json(response)
        if not isinstance(payload, dict):
            raise LookerApiError("Looker login did not return a JSON object")
        token = payload.get("access_token")
        if not token:
            raise LookerApiError("Looker login failed: missing access_token")
        self._access_token = str(token)
        return {
            "access_token": self._access_token,
            "token_type": payload.get("token_type"),
            "expires_in": payload.get("expires_in"),
        }

    def sign_out(self) -> None:
        if not self._access_token:
            return
        try:
            self._request("DELETE", self._api_url("/logout"), headers=self._auth_headers(), allow_statuses=(200, 204))
        finally:
            self._access_token = None

    def get_versions(self) -> Any:
        # This endpoint is not versioned in some deployments.
        response = self._request(
            "GET",
            f"{self.base_url}/api/versions",
            headers={"Accept": "application/json"},
            allow_statuses=(200, 404),
        )
        if response.status_code == 404:
            return {"status": "unknown", "detail": "versions endpoint unavailable"}
        return self._parse_response_json(response)

    def doctor(self, *, write_check: bool = False, space_id: str | None = None) -> Dict[str, Any]:
        report: Dict[str, Any] = {
            "status": "ready",
            "auth": "ok",
            "api_version": self.api_version,
            "verify_ssl": self.verify_ssl,
            "models": None,
            "spaces": None,
            "version_info": None,
            "write_check": "skipped",
        }

        try:
            auth = self.sign_in()
            report["auth"] = "ok"
            report["auth_expires_in"] = auth.get("expires_in")
        except Exception as exc:
            report["status"] = "error"
            report["auth"] = "error"
            report["error"] = str(exc)
            return report

        try:
            models = self.list_models()
            report["models"] = len(models)
        except Exception as exc:
            report["status"] = "warn"
            report["models"] = "error"
            report["models_error"] = str(exc)

        try:
            spaces = self.list_spaces(limit=50)
            report["spaces"] = len(spaces)
        except Exception as exc:
            report["status"] = "warn"
            report["spaces"] = "error"
            report["spaces_error"] = str(exc)

        try:
            report["version_info"] = self.get_versions()
        except Exception as exc:
            report["status"] = "warn"
            report["version_info"] = {"status": "error", "error": str(exc)}

        if write_check:
            if not space_id:
                report["status"] = "warn"
                report["write_check"] = "skipped"
                report["write_check_error"] = "space_id required for write check"
            else:
                write_error: str | None = None
                cleanup_error: str | None = None
                probe_dashboard_id: str | None = None
                try:
                    temp_title = f"g-gremlin write-check {uuid.uuid4().hex[:10]}"
                    created = self.create_dashboard(title=temp_title, space_id=space_id)
                    probe_dashboard_id = str(created.get("id") or "")
                except Exception as exc:
                    write_error = str(exc)
                finally:
                    if probe_dashboard_id:
                        try:
                            self.delete_dashboard(probe_dashboard_id)
                        except Exception as exc:
                            cleanup_error = str(exc)

                if write_error:
                    report["status"] = "warn"
                    report["write_check"] = "error"
                    report["write_check_error"] = write_error
                elif cleanup_error:
                    report["status"] = "warn"
                    report["write_check"] = "error"
                    report["write_check_error"] = (
                        f"Write probe created dashboard {probe_dashboard_id} but cleanup failed: {cleanup_error}"
                    )
                else:
                    report["write_check"] = "ok"

        return report

    # ------------------------------------------------------------------
    # Metadata / describe
    # ------------------------------------------------------------------
    def list_models(self) -> List[Dict[str, Any]]:
        payload = self._request_json("GET", "/lookml_models", params={"fields": "name,label"})
        return self._normalize_collection(payload, "lookml_models")

    def list_explores(self, model: str) -> List[Dict[str, Any]]:
        payload = self._request_json("GET", f"/lookml_models/{model}/explores")
        return self._normalize_collection(payload, "explores")

    def describe_fields(self, model: str, explore: str) -> Dict[str, Any]:
        payload = self._request_json("GET", f"/lookml_models/{model}/explores/{explore}")
        if not isinstance(payload, dict):
            raise LookerApiError("Looker explore describe returned non-object payload")
        return payload

    def list_spaces(self, *, limit: int = 100) -> List[Dict[str, Any]]:
        items: List[Dict[str, Any]] = []
        offset = 0
        remaining = max(int(limit or 0), 0)
        if remaining <= 0:
            return items

        while remaining > 0:
            page_size = min(100, remaining)
            payload = self._request_json("GET", "/spaces", params={"limit": page_size, "offset": offset})
            batch = self._normalize_collection(payload, "spaces")
            if not batch:
                break
            items.extend(batch)
            got = len(batch)
            remaining -= got
            offset += got
            if got < page_size:
                break
        return items

    # ------------------------------------------------------------------
    # Queries / Looks
    # ------------------------------------------------------------------
    def _query_payload(self, query: Dict[str, Any]) -> Dict[str, Any]:
        model = str(query.get("model") or "").strip()
        explore = str(query.get("explore") or query.get("view") or "").strip()
        fields = _ensure_list(query.get("fields"))
        if not model or not explore:
            raise LookerSpecError("query.model and query.explore are required")
        if not fields:
            raise LookerSpecError("query.fields must include at least one field")

        payload: Dict[str, Any] = {
            "model": model,
            "view": explore,
            "fields": fields,
        }
        if isinstance(query.get("filters"), dict):
            payload["filters"] = query.get("filters")
        if query.get("sorts") is not None:
            payload["sorts"] = _ensure_list(query.get("sorts"))
        if query.get("limit") is not None:
            payload["limit"] = query.get("limit")
        return payload

    def run_inline_query(self, query: Dict[str, Any], *, result_format: str = "json") -> Any:
        payload = self._query_payload(query)
        fmt = str(result_format or "json").strip().lower()
        if fmt in {"json", "json_detail"}:
            return self._request_json("POST", f"/queries/run/{fmt}", payload=payload)
        return self._request_text("POST", f"/queries/run/{fmt}", payload=payload)

    def create_query(self, query: Dict[str, Any]) -> Dict[str, Any]:
        payload = self._query_payload(query)
        data = self._request_json("POST", "/queries", payload=payload)
        if not isinstance(data, dict):
            raise LookerApiError("create_query returned non-object payload")
        return data

    def get_query(self, query_id: str) -> Dict[str, Any]:
        payload = self._request_json("GET", f"/queries/{query_id}")
        if not isinstance(payload, dict):
            raise LookerApiError("get_query returned non-object payload")
        return payload

    def list_looks(self, *, space_id: str | None = None, title: str | None = None, limit: int = 100) -> List[Dict[str, Any]]:
        items: List[Dict[str, Any]] = []
        offset = 0
        remaining = max(int(limit or 0), 0)
        if remaining <= 0:
            return items

        while remaining > 0:
            page_size = min(100, remaining)
            params: Dict[str, Any] = {"limit": page_size, "offset": offset}
            if space_id:
                params["space_id"] = space_id
            if title:
                params["title"] = title
            payload = self._request_json("GET", "/looks", params=params)
            batch = self._normalize_collection(payload, "looks")
            if not batch:
                break
            items.extend(batch)
            got = len(batch)
            remaining -= got
            offset += got
            if got < page_size:
                break
        return items

    def create_look(self, spec: LookerLookSpecV1) -> Dict[str, Any]:
        query = self.create_query(spec.query.model_dump(mode="json"))
        payload = {
            "title": spec.look.title,
            "space_id": spec.look.space_id,
            "description": spec.look.description,
            "query_id": query.get("id"),
        }
        created = self._request_json("POST", "/looks", payload=payload)
        if not isinstance(created, dict):
            raise LookerApiError("create_look returned non-object payload")
        return created

    # ------------------------------------------------------------------
    # Dashboards
    # ------------------------------------------------------------------
    def list_dashboards(
        self,
        *,
        title: str | None = None,
        space_id: str | None = None,
        limit: int = 100,
    ) -> List[Dict[str, Any]]:
        items: List[Dict[str, Any]] = []
        offset = 0
        remaining = max(int(limit or 0), 0)
        if remaining <= 0:
            return items

        while remaining > 0:
            page_size = min(100, remaining)
            params: Dict[str, Any] = {"limit": page_size, "offset": offset}
            if title:
                params["title"] = title
            if space_id:
                params["space_id"] = space_id
            payload = self._request_json("GET", "/search_dashboards", params=params)
            batch = self._normalize_collection(payload, "dashboards")
            if not batch:
                break
            items.extend(batch)
            got = len(batch)
            remaining -= got
            offset += got
            if got < page_size:
                break
        return items

    def _fetch_dashboard_payload(
        self,
        dashboard_id: str,
        *,
        allow_not_found: bool = False,
    ) -> Dict[str, Any] | None:
        fields = (
            "id,title,description,space_id,dashboard_filters(id,name,title,dimension,type,default_value),"
            "dashboard_elements(id,title,type,row,column,width,height,vis_config,query_id,"
            "query(id,model,view,fields,filters,sorts,limit))"
        )
        try:
            response = self._request(
                "GET",
                self._api_url(f"/dashboards/{dashboard_id}"),
                params={"fields": fields},
                headers=self._auth_headers(),
                allow_statuses=(404,) if allow_not_found else (),
            )
        except AuthenticationError:
            self._access_token = None
            self.sign_in()
            response = self._request(
                "GET",
                self._api_url(f"/dashboards/{dashboard_id}"),
                params={"fields": fields},
                headers=self._auth_headers(),
                allow_statuses=(404,) if allow_not_found else (),
            )
        if response.status_code == 404:
            return None
        payload = self._parse_response_json(response)
        if not isinstance(payload, dict):
            raise LookerApiError("get_dashboard returned non-object payload")
        return payload

    def get_dashboard(self, dashboard_id: str, *, hydrate_queries: bool = True) -> Dict[str, Any]:
        payload = self._fetch_dashboard_payload(dashboard_id, allow_not_found=False)
        if payload is None:
            raise LookerApiError(f"Dashboard not found: {dashboard_id}")

        if hydrate_queries:
            elements = _ensure_list(payload.get("dashboard_elements") or payload.get("elements"))
            for element in elements:
                if not isinstance(element, dict):
                    continue
                query = element.get("query")
                query_id = element.get("query_id")
                if isinstance(query, dict) and query.get("fields"):
                    continue
                if query_id:
                    try:
                        element["query"] = self.get_query(str(query_id))
                    except Exception:
                        # Keep describe resilient even if one query fetch fails.
                        continue
            payload["dashboard_elements"] = elements

        return payload

    def create_dashboard(self, *, title: str, space_id: str, description: str | None = None) -> Dict[str, Any]:
        payload = {"title": title, "space_id": space_id}
        if description:
            payload["description"] = description
        created = self._request_json("POST", "/dashboards", payload=payload)
        if not isinstance(created, dict):
            raise LookerApiError("create_dashboard returned non-object payload")
        return created

    def update_dashboard(
        self,
        dashboard_id: str,
        *,
        title: str | None = None,
        space_id: str | None = None,
        description: str | None = None,
    ) -> Dict[str, Any]:
        payload: Dict[str, Any] = {}
        if title is not None:
            payload["title"] = title
        if space_id is not None:
            payload["space_id"] = space_id
        if description is not None:
            payload["description"] = description
        updated = self._request_json("PATCH", f"/dashboards/{dashboard_id}", payload=payload)
        if not isinstance(updated, dict):
            raise LookerApiError("update_dashboard returned non-object payload")
        return updated

    def delete_dashboard(self, dashboard_id: str) -> None:
        self._request("DELETE", self._api_url(f"/dashboards/{dashboard_id}"), headers=self._auth_headers(), allow_statuses=(200, 204))

    def create_dashboard_element(self, dashboard_id: str, tile: LookerTileSpecV1) -> Dict[str, Any]:
        query = self.create_query(tile.query.model_dump(mode="json"))
        payload: Dict[str, Any] = {
            "dashboard_id": dashboard_id,
            "title": tile.title,
            "type": tile.type or "vis",
            "query_id": query.get("id"),
            "vis_config": tile.vis_config or {},
        }
        for key in ("row", "column", "width", "height"):
            if tile.layout.get(key) is not None:
                payload[key] = tile.layout.get(key)
        created = self._request_json("POST", "/dashboard_elements", payload=payload)
        if not isinstance(created, dict):
            raise LookerApiError("create_dashboard_element returned non-object payload")
        return created

    def update_dashboard_element(self, element_id: str, tile: LookerTileSpecV1) -> Dict[str, Any]:
        query = self.create_query(tile.query.model_dump(mode="json"))
        payload: Dict[str, Any] = {
            "title": tile.title,
            "query_id": query.get("id"),
            "type": tile.type or "vis",
            "vis_config": tile.vis_config or {},
        }
        for key in ("row", "column", "width", "height"):
            if tile.layout.get(key) is not None:
                payload[key] = tile.layout.get(key)
        updated = self._request_json("PATCH", f"/dashboard_elements/{element_id}", payload=payload)
        if not isinstance(updated, dict):
            raise LookerApiError("update_dashboard_element returned non-object payload")
        return updated

    def delete_dashboard_element(self, element_id: str) -> None:
        self._request(
            "DELETE",
            self._api_url(f"/dashboard_elements/{element_id}"),
            headers=self._auth_headers(),
            allow_statuses=(200, 204),
        )

    def create_dashboard_filter(self, dashboard_id: str, filt: LookerFilterSpecV1) -> Dict[str, Any]:
        payload: Dict[str, Any] = {
            "dashboard_id": dashboard_id,
            "name": filt.name,
            "title": filt.title,
            "dimension": filt.dimension,
            "type": filt.type or "field_filter",
        }
        if filt.default_value is not None:
            payload["default_value"] = filt.default_value
        created = self._request_json("POST", "/dashboard_filters", payload=payload)
        if not isinstance(created, dict):
            raise LookerApiError("create_dashboard_filter returned non-object payload")
        return created

    def update_dashboard_filter(self, filter_id: str, filt: LookerFilterSpecV1) -> Dict[str, Any]:
        payload: Dict[str, Any] = {
            "name": filt.name,
            "title": filt.title,
            "dimension": filt.dimension,
            "type": filt.type or "field_filter",
        }
        if filt.default_value is not None:
            payload["default_value"] = filt.default_value
        updated = self._request_json("PATCH", f"/dashboard_filters/{filter_id}", payload=payload)
        if not isinstance(updated, dict):
            raise LookerApiError("update_dashboard_filter returned non-object payload")
        return updated

    def delete_dashboard_filter(self, filter_id: str) -> None:
        self._request(
            "DELETE",
            self._api_url(f"/dashboard_filters/{filter_id}"),
            headers=self._auth_headers(),
            allow_statuses=(200, 204),
        )

    def describe_dashboard_spec(self, dashboard_id: str) -> Dict[str, Any]:
        dashboard = self.get_dashboard(dashboard_id, hydrate_queries=True)
        return dashboard_to_spec_payload(dashboard)

    def _resolve_dashboard_target(self, spec: LookerDashboardSpecV1) -> Dict[str, Any] | None:
        if spec.dashboard.dashboard_id:
            return self._fetch_dashboard_payload(str(spec.dashboard.dashboard_id), allow_not_found=True)

        matches = self.list_dashboards(
            title=spec.dashboard.title,
            space_id=spec.dashboard.space_id,
            limit=50,
        )
        if len(matches) > 1:
            ids = [str(item.get("id")) for item in matches if item.get("id")]
            raise LookerAmbiguousMatchError(
                f"Multiple dashboards matched title='{spec.dashboard.title}' in space_id='{spec.dashboard.space_id}': {ids}"
            )
        if len(matches) == 1:
            dashboard_id = str(matches[0].get("id"))
            return self.get_dashboard(dashboard_id, hydrate_queries=True)
        return None

    def validate_dashboard_references(self, spec: LookerDashboardSpecV1) -> None:
        model_map = {str(item.get("name")) for item in self.list_models() if item.get("name")}
        missing_models = sorted({tile.query.model for tile in spec.tiles if tile.query.model not in model_map})
        if missing_models:
            raise LookerSpecError(f"Unknown LookML models in spec: {', '.join(missing_models)}")

        explore_cache: Dict[str, set[str]] = {}
        for tile in spec.tiles:
            model = tile.query.model
            if model not in explore_cache:
                explores = self.list_explores(model)
                explore_cache[model] = {str(item.get("name")) for item in explores if item.get("name")}
            if tile.query.explore not in explore_cache[model]:
                raise LookerSpecError(
                    f"Unknown explore '{tile.query.explore}' for model '{model}'"
                )

    def apply_dashboard_spec(
        self,
        spec: LookerDashboardSpecV1,
        *,
        mode: str = "merge",
        dry_run: bool = False,
        force: bool = False,
        validate_refs: bool = True,
    ) -> Dict[str, Any]:
        effective_mode = str(mode or spec.apply_mode or "merge").strip().lower()
        if effective_mode not in {"merge", "replace"}:
            raise LookerSpecError("mode must be one of: merge, replace")
        if effective_mode == "replace" and not force and not dry_run:
            raise LookerSpecError("replace mode requires --force")
        if (spec.dashboard.space_id or "").strip().upper() == "REPLACE_WITH_SPACE_ID":
            raise LookerSpecError("dashboard.space_id contains placeholder value 'REPLACE_WITH_SPACE_ID'")

        if validate_refs:
            self.validate_dashboard_references(spec)

        target = self._resolve_dashboard_target(spec)
        target_id = str(target.get("id")) if target and target.get("id") is not None else None
        action = "update" if target_id else "create"
        existing_elements = _ensure_list(target.get("dashboard_elements")) if target else []
        existing_filters = _ensure_list(target.get("dashboard_filters")) if target else []

        existing_elements_by_id = {
            str(item.get("id")): item
            for item in existing_elements
            if isinstance(item, dict) and item.get("id") is not None
        }
        existing_filters_by_name = {
            str(item.get("name")): item
            for item in existing_filters
            if isinstance(item, dict) and item.get("name")
        }

        plan = {
            "action": action,
            "mode": effective_mode,
            "dashboard_id": target_id,
            "create_tiles": 0,
            "update_tiles": 0,
            "delete_tiles": 0,
            "create_filters": 0,
            "update_filters": 0,
            "delete_filters": 0,
        }

        if effective_mode == "replace":
            plan["delete_tiles"] = len(existing_elements)
            plan["delete_filters"] = len(existing_filters)
            plan["create_tiles"] = len(spec.tiles)
            plan["create_filters"] = len(spec.filters)
        else:
            for tile in spec.tiles:
                if tile.element_id and str(tile.element_id) in existing_elements_by_id:
                    plan["update_tiles"] += 1
                else:
                    plan["create_tiles"] += 1
            for filt in spec.filters:
                if filt.name in existing_filters_by_name:
                    plan["update_filters"] += 1
                else:
                    plan["create_filters"] += 1

        if dry_run:
            return {"dry_run": True, "plan": plan}

        if not target_id:
            created = self.create_dashboard(
                title=spec.dashboard.title,
                space_id=str(spec.dashboard.space_id or ""),
                description=spec.dashboard.description,
            )
            target_id = str(created.get("id"))
        else:
            self.update_dashboard(
                target_id,
                title=spec.dashboard.title,
                space_id=spec.dashboard.space_id,
                description=spec.dashboard.description,
            )

        if effective_mode == "replace":
            for item in existing_elements:
                if isinstance(item, dict) and item.get("id") is not None:
                    self.delete_dashboard_element(str(item.get("id")))
            for item in existing_filters:
                if isinstance(item, dict) and item.get("id") is not None:
                    self.delete_dashboard_filter(str(item.get("id")))
            existing_filters_by_name = {}

        created_elements: List[str] = []
        updated_elements: List[str] = []
        for tile in spec.tiles:
            if effective_mode == "merge" and tile.element_id and str(tile.element_id) in existing_elements_by_id:
                self.update_dashboard_element(str(tile.element_id), tile)
                updated_elements.append(str(tile.element_id))
            else:
                created = self.create_dashboard_element(str(target_id), tile)
                if created.get("id") is not None:
                    created_elements.append(str(created.get("id")))

        created_filters: List[str] = []
        updated_filters: List[str] = []
        for filt in spec.filters:
            if effective_mode == "merge" and filt.name in existing_filters_by_name:
                existing = existing_filters_by_name[filt.name]
                if existing.get("id") is not None:
                    self.update_dashboard_filter(str(existing.get("id")), filt)
                    updated_filters.append(str(existing.get("id")))
            else:
                created = self.create_dashboard_filter(str(target_id), filt)
                if created.get("id") is not None:
                    created_filters.append(str(created.get("id")))

        return {
            "dry_run": False,
            "dashboard_id": target_id,
            "mode": effective_mode,
            "action": action,
            "plan": plan,
            "created_element_ids": created_elements,
            "updated_element_ids": updated_elements,
            "created_filter_ids": created_filters,
            "updated_filter_ids": updated_filters,
        }

    def add_tile(self, dashboard_id: str, tile: LookerTileSpecV1) -> Dict[str, Any]:
        return self.create_dashboard_element(dashboard_id, tile)

    def add_filter(self, dashboard_id: str, filt: LookerFilterSpecV1) -> Dict[str, Any]:
        return self.create_dashboard_filter(dashboard_id, filt)

    def remove_tile(self, dashboard_id: str, element_id: str) -> Dict[str, Any]:
        # dashboard_id retained for command symmetry and safety metadata.
        _ = dashboard_id
        self.delete_dashboard_element(element_id)
        return {"status": "ok", "element_id": element_id}

    def remove_filter(self, dashboard_id: str, filter_id: str) -> Dict[str, Any]:
        _ = dashboard_id
        self.delete_dashboard_filter(filter_id)
        return {"status": "ok", "filter_id": filter_id}
