"""Tableau Cloud client with REST API, Metadata API, and Hyper support."""

from __future__ import annotations

import json
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional

import click

from g_gremlin.base import BaseIntegration, IntegrationError, RetryConfig
from g_gremlin.credentials import resolve_credentials
from g_gremlin.credentials.provider import CredentialProvider, SecretNotFoundError
from g_gremlin.tableau.metadata_query import DEFAULT_FIELDS_QUERY, DEFAULT_LINEAGE_QUERY
from g_gremlin.tableau.models import DatasourceInfo, FieldInfo, ViewInfo, WorkbookInfo

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


class TableauClient(BaseIntegration):
    """Tableau Cloud client with REST API, Metadata API, and Hyper support."""

    SERVICE_NAME = "tableau"
    DEFAULT_API_VERSION = "3.27"

    def __init__(
        self,
        site_url: Optional[str] = None,
        site_name: Optional[str] = None,
        access_token: Optional[str] = None,
        token_name: Optional[str] = None,
        api_version: Optional[str] = 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([site_url, access_token, token_name]) or api_version is None or site_name is None:
            try:
                creds = resolve_credentials(
                    "tableau",
                    required_keys=["site_url", "access_token", "token_name"],
                    optional_keys=["site_name", "api_version"],
                    credential_provider=self.credential_provider,
                )
            except SecretNotFoundError:
                creds = {}

        self.site_url = (site_url or creds.get("site_url") or "").rstrip("/")
        self.site_name = site_name if site_name is not None else creds.get("site_name", "")
        self.access_token = access_token or creds.get("access_token") or ""
        self.token_name = token_name or creds.get("token_name") or ""

        self.api_version = api_version or creds.get("api_version") or self._load_api_version() or self.DEFAULT_API_VERSION

        if not self.site_url or not self.access_token or not self.token_name:
            raise click.ClickException(
                "Tableau credentials missing. Set env vars or run: g-gremlin tableau auth connect"
            )

        self._auth_token: Optional[str] = None
        self._site_id: Optional[str] = None
        self._user_id: Optional[str] = None

    @property
    def site_id(self) -> Optional[str]:
        return self._site_id

    @property
    def user_id(self) -> Optional[str]:
        return self._user_id

    def _load_api_version(self) -> Optional[str]:
        return os.environ.get("G_GREMLIN_TABLEAU_API_VERSION")

    def _api_url(self, path: str) -> str:
        return f"{self.site_url}/api/{self.api_version}{path}"

    def _rest_headers(self, include_json: bool = True) -> Dict[str, str]:
        self._ensure_signed_in()
        headers = {"X-Tableau-Auth": self._auth_token or ""}
        if include_json:
            headers["Accept"] = "application/json"
            headers["Content-Type"] = "application/json"
        return headers

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

    def _require_site_id(self) -> str:
        self._ensure_signed_in()
        if not self._site_id:
            raise IntegrationError("Tableau site id unavailable after sign-in")
        return self._site_id

    def _parse_json(self, response) -> Dict[str, Any]:
        try:
            return response.json()
        except Exception as exc:
            raise IntegrationError(f"Failed to parse Tableau response: {exc}") from exc

    @staticmethod
    def _as_list(value: Any) -> List[Dict[str, Any]]:
        if value is None:
            return []
        if isinstance(value, list):
            return [item for item in value if isinstance(item, dict)]
        if isinstance(value, dict):
            return [value]
        return []

    def _extract_refresh_tasks_from_payload(self, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
        tasks = (payload.get("tasks") or {}).get("task")
        if tasks is not None:
            return self._as_list(tasks)
        extracts = (payload.get("extracts") or {}).get("extract")
        if extracts is not None:
            return self._as_list(extracts)
        if payload.get("task") is not None:
            return self._as_list(payload.get("task"))
        if payload.get("extractRefresh") is not None:
            return [payload]
        return []

    def _normalize_extract_refresh_task(self, payload: Dict[str, Any]) -> Dict[str, Any]:
        task_payload = payload
        if isinstance(payload.get("task"), dict):
            task_payload = payload.get("task") or {}

        extract_refresh = task_payload.get("extractRefresh")
        schedule = task_payload.get("schedule")

        if not isinstance(extract_refresh, dict):
            if isinstance(task_payload, dict) and task_payload.get("id"):
                extract_refresh = dict(task_payload)
            else:
                return task_payload

        normalized: Dict[str, Any] = dict(extract_refresh)
        if isinstance(schedule, dict):
            normalized["schedule"] = schedule
        elif isinstance(normalized.get("schedule"), dict):
            # Preserve schedule if nested under extractRefresh in some payload variants.
            pass

        datasource = normalized.get("datasource")
        workbook = normalized.get("workbook")
        if isinstance(datasource, dict) and datasource.get("id"):
            normalized["target_type"] = "datasource"
            normalized["target_id"] = datasource.get("id")
        elif isinstance(workbook, dict) and workbook.get("id"):
            normalized["target_type"] = "workbook"
            normalized["target_id"] = workbook.get("id")

        return normalized

    def _build_extract_refresh_payload(
        self,
        *,
        target_type: Optional[str] = None,
        target_id: Optional[str] = None,
        refresh_type: Optional[str] = None,
        frequency: Optional[str] = None,
        start: Optional[str] = None,
        end: Optional[str] = None,
        intervals: Optional[List[Dict[str, str]]] = None,
    ) -> Dict[str, Any]:
        payload: Dict[str, Any] = {}

        if target_type and target_type not in {"datasource", "workbook"}:
            raise IntegrationError("target_type must be 'datasource' or 'workbook'")
        if (target_type and not target_id) or (target_id and not target_type):
            raise IntegrationError("target_type and target_id must be provided together")

        extract_refresh: Dict[str, Any] = {}
        if refresh_type:
            extract_refresh["type"] = refresh_type
        if target_type and target_id:
            extract_refresh[target_type] = {"id": target_id}
        if extract_refresh:
            payload["extractRefresh"] = extract_refresh

        schedule: Dict[str, Any] = {}
        if frequency:
            schedule["frequency"] = frequency

        frequency_details: Dict[str, Any] = {}
        if start:
            frequency_details["start"] = start
        if end:
            frequency_details["end"] = end
        if intervals is not None:
            frequency_details["intervals"] = {"interval": intervals}
        if frequency_details:
            schedule["frequencyDetails"] = frequency_details
        if schedule:
            payload["schedule"] = schedule

        return payload

    # ------------------------------------------------------------------
    # Auth
    # ------------------------------------------------------------------
    def sign_in(self) -> Dict[str, Any]:
        payload = {
            "credentials": {
                "personalAccessTokenName": self.token_name,
                "personalAccessTokenSecret": self.access_token,
                "site": {"contentUrl": self.site_name or ""},
            }
        }
        response = self._post(
            self._api_url("/auth/signin"),
            json=payload,
            headers={"Accept": "application/json", "Content-Type": "application/json"},
        )
        data = self._parse_json(response)
        creds = data.get("credentials") or {}
        token = creds.get("token")
        site_id = (creds.get("site") or {}).get("id")
        user_id = (creds.get("user") or {}).get("id")
        if not token or not site_id:
            raise IntegrationError("Tableau sign-in failed: missing token or site id")
        self._auth_token = token
        self._site_id = site_id
        self._user_id = user_id
        return {"auth_token": token, "site_id": site_id, "user_id": user_id}

    def sign_out(self) -> None:
        if not self._auth_token:
            return
        try:
            self._post(
                self._api_url("/auth/signout"),
                headers=self._rest_headers(),
                allow_statuses=(204,),
            )
        finally:
            self._auth_token = None
            self._site_id = None
            self._user_id = None

    # ------------------------------------------------------------------
    # Core REST helpers
    # ------------------------------------------------------------------
    def _get_paginated(self, path: str, container_key: str, item_key: str) -> List[Dict[str, Any]]:
        items: List[Dict[str, Any]] = []
        page_number = 1
        page_size = 100
        total_available = None

        while True:
            response = self._get(
                self._api_url(path),
                headers=self._rest_headers(),
                params={"pageSize": page_size, "pageNumber": page_number},
            )
            payload = self._parse_json(response)
            container = payload.get(container_key) or {}
            batch = container.get(item_key) or []
            if isinstance(batch, dict):
                batch = [batch]
            items.extend(batch)

            pagination = payload.get("pagination") or {}
            try:
                total_available = int(pagination.get("totalAvailable", len(items)))
                page_size = int(pagination.get("pageSize", page_size))
                page_number = int(pagination.get("pageNumber", page_number))
            except Exception:
                total_available = total_available or len(items)

            if len(items) >= (total_available or 0):
                break
            page_number += 1

        return items

    # ------------------------------------------------------------------
    # Workbooks
    # ------------------------------------------------------------------
    def list_workbooks(self) -> List[Dict[str, Any]]:
        site_id = self._require_site_id()
        raw_items = self._get_paginated(f"/sites/{site_id}/workbooks", "workbooks", "workbook")
        return [WorkbookInfo.from_api(item).model_dump() for item in raw_items]

    def get_workbook(self, workbook_id: str) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/workbooks/{workbook_id}"),
            headers=self._rest_headers(),
        )
        payload = self._parse_json(response)
        workbook = (payload.get("workbook") or {})
        return WorkbookInfo.from_api(workbook).model_dump()

    def download_workbook(self, workbook_id: str, output_path: Path) -> Path:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/workbooks/{workbook_id}/content"),
            headers=self._rest_headers(include_json=False),
        )
        output_path.write_bytes(response.content)
        return output_path

    def publish_workbook(
        self,
        file_path: Path,
        project_id: str,
        name: Optional[str] = None,
        publish_mode: str = "create",
    ) -> Dict[str, Any]:
        server, TSC = self._get_tsc_server()
        mode = TSC.Server.PublishMode.Overwrite if publish_mode == "overwrite" else TSC.Server.PublishMode.CreateNew
        item = TSC.WorkbookItem(project_id=project_id, name=name or file_path.stem)
        try:
            workbook = server.workbooks.publish(item, str(file_path), mode)
        finally:
            server.auth.sign_out()
        return {
            "workbook_id": workbook.id,
            "name": workbook.name,
            "project_id": workbook.project_id,
            "publish_mode": publish_mode,
            "destructive": publish_mode == "overwrite",
        }

    def refresh_workbook(self, workbook_id: str, wait: bool = False, timeout: int = 600) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._post(
            self._api_url(f"/sites/{site_id}/workbooks/{workbook_id}/refresh"),
            headers=self._rest_headers(),
        )
        payload = self._parse_json(response)
        job_id = (payload.get("job") or {}).get("id")
        result = {"job_id": job_id}
        if wait and job_id:
            result.update(self.wait_for_job(job_id, timeout=timeout))
        return result

    # ------------------------------------------------------------------
    # Datasources
    # ------------------------------------------------------------------
    def list_datasources(self) -> List[Dict[str, Any]]:
        site_id = self._require_site_id()
        raw_items = self._get_paginated(f"/sites/{site_id}/datasources", "datasources", "datasource")
        return [DatasourceInfo.from_api(item).model_dump() for item in raw_items]

    def get_datasource(self, datasource_id: str) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/datasources/{datasource_id}"),
            headers=self._rest_headers(),
        )
        payload = self._parse_json(response)
        datasource = (payload.get("datasource") or {})
        return DatasourceInfo.from_api(datasource).model_dump()

    def download_datasource(self, datasource_id: str, output_path: Path) -> Path:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/datasources/{datasource_id}/content"),
            headers=self._rest_headers(include_json=False),
        )
        output_path.write_bytes(response.content)
        return output_path

    def publish_datasource(
        self,
        file_path: Path,
        project_id: str,
        name: Optional[str] = None,
        publish_mode: str = "create",
    ) -> Dict[str, Any]:
        server, TSC = self._get_tsc_server()
        mode = TSC.Server.PublishMode.Overwrite if publish_mode == "overwrite" else TSC.Server.PublishMode.CreateNew
        item = TSC.DatasourceItem(project_id=project_id, name=name or file_path.stem)
        try:
            datasource = server.datasources.publish(item, str(file_path), mode)
        finally:
            server.auth.sign_out()
        return {
            "datasource_id": datasource.id,
            "name": datasource.name,
            "project_id": datasource.project_id,
            "publish_mode": publish_mode,
            "destructive": publish_mode == "overwrite",
        }

    def refresh_datasource(self, datasource_id: str, wait: bool = False, timeout: int = 600) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._post(
            self._api_url(f"/sites/{site_id}/datasources/{datasource_id}/refresh"),
            headers=self._rest_headers(),
        )
        payload = self._parse_json(response)
        job_id = (payload.get("job") or {}).get("id")
        result = {"job_id": job_id}
        if wait and job_id:
            result.update(self.wait_for_job(job_id, timeout=timeout))
        return result

    # ------------------------------------------------------------------
    # Extract Refresh Tasks (Schedules)
    # ------------------------------------------------------------------
    def list_extract_refresh_tasks(self) -> List[Dict[str, Any]]:
        site_id = self._require_site_id()
        items: List[Dict[str, Any]] = []
        page_number = 1
        page_size = 100

        while True:
            response = self._get(
                self._api_url(f"/sites/{site_id}/tasks/extractRefreshes"),
                headers=self._rest_headers(),
                params={"pageSize": page_size, "pageNumber": page_number},
            )
            payload = self._parse_json(response)
            batch = self._extract_refresh_tasks_from_payload(payload)
            items.extend(batch)

            pagination = payload.get("pagination") or {}
            try:
                total_available = int(pagination.get("totalAvailable", len(items)))
                page_number = int(pagination.get("pageNumber", page_number))
                page_size = int(pagination.get("pageSize", page_size))
            except Exception:
                total_available = len(items)

            if not batch or len(items) >= total_available:
                break
            page_number += 1

        return [self._normalize_extract_refresh_task(item) for item in items]

    def get_extract_refresh_task(self, task_id: str) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/tasks/extractRefreshes/{task_id}"),
            headers=self._rest_headers(),
        )
        payload = self._parse_json(response)
        return self._normalize_extract_refresh_task(payload)

    def create_extract_refresh_task(
        self,
        *,
        target_type: str,
        target_id: str,
        refresh_type: str,
        frequency: str,
        start: str,
        end: Optional[str] = None,
        intervals: Optional[List[Dict[str, str]]] = None,
    ) -> Dict[str, Any]:
        site_id = self._require_site_id()
        payload = self._build_extract_refresh_payload(
            target_type=target_type,
            target_id=target_id,
            refresh_type=refresh_type,
            frequency=frequency,
            start=start,
            end=end,
            intervals=intervals,
        )
        response = self._post(
            self._api_url(f"/sites/{site_id}/tasks/extractRefreshes"),
            headers=self._rest_headers(),
            json=payload,
        )
        data = self._parse_json(response)
        return self._normalize_extract_refresh_task(data)

    def update_extract_refresh_task(
        self,
        task_id: str,
        *,
        target_type: Optional[str] = None,
        target_id: Optional[str] = None,
        refresh_type: Optional[str] = None,
        frequency: Optional[str] = None,
        start: Optional[str] = None,
        end: Optional[str] = None,
        intervals: Optional[List[Dict[str, str]]] = None,
    ) -> Dict[str, Any]:
        site_id = self._require_site_id()
        payload = self._build_extract_refresh_payload(
            target_type=target_type,
            target_id=target_id,
            refresh_type=refresh_type,
            frequency=frequency,
            start=start,
            end=end,
            intervals=intervals,
        )
        if not payload:
            raise IntegrationError("No update fields provided")
        response = self._post(
            self._api_url(f"/sites/{site_id}/tasks/extractRefreshes/{task_id}"),
            headers=self._rest_headers(),
            json=payload,
        )
        data = self._parse_json(response)
        return self._normalize_extract_refresh_task(data)

    def delete_extract_refresh_task(self, task_id: str) -> Dict[str, Any]:
        site_id = self._require_site_id()
        self._delete(
            self._api_url(f"/sites/{site_id}/tasks/extractRefreshes/{task_id}"),
            headers=self._rest_headers(),
            allow_statuses=(204,),
        )
        return {"task_id": task_id, "deleted": True}

    def run_extract_refresh_task(self, task_id: str, wait: bool = False, timeout: int = 600) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._post(
            self._api_url(f"/sites/{site_id}/tasks/extractRefreshes/{task_id}/runNow"),
            headers=self._rest_headers(),
            json={},
        )
        payload = self._parse_json(response)
        job_id = (payload.get("job") or {}).get("id")
        result = {"task_id": task_id, "job_id": job_id}
        if wait and job_id:
            result.update(self.wait_for_job(job_id, timeout=timeout))
        return result

    # ------------------------------------------------------------------
    # Views
    # ------------------------------------------------------------------
    def list_views(self, workbook_id: str) -> List[Dict[str, Any]]:
        site_id = self._require_site_id()
        raw_items = self._get_paginated(
            f"/sites/{site_id}/workbooks/{workbook_id}/views", "views", "view"
        )
        return [ViewInfo.from_api(item).model_dump() for item in raw_items]

    def download_view_image(
        self,
        view_id: str,
        output_path: Path,
        width: Optional[int] = None,
        height: Optional[int] = None,
        resolution: Optional[int] = None,
    ) -> Path:
        site_id = self._require_site_id()
        params: Dict[str, Any] = {}
        if width:
            params["width"] = width
        if height:
            params["height"] = height
        if resolution:
            params["resolution"] = resolution
        response = self._get(
            self._api_url(f"/sites/{site_id}/views/{view_id}/image"),
            headers=self._rest_headers(include_json=False),
            params=params or None,
        )
        output_path.write_bytes(response.content)
        return output_path

    def download_view_data(self, view_id: str, output_path: Path, params: Optional[Dict[str, str]] = None) -> Path:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/views/{view_id}/data"),
            headers=self._rest_headers(include_json=False),
            params=params or None,
        )
        output_path.write_bytes(response.content)
        return output_path

    # ------------------------------------------------------------------
    # Projects
    # ------------------------------------------------------------------
    def list_projects(self) -> List[Dict[str, Any]]:
        site_id = self._require_site_id()
        raw_items = self._get_paginated(f"/sites/{site_id}/projects", "projects", "project")
        return raw_items

    def get_project(self, project_id: str) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/projects/{project_id}"),
            headers=self._rest_headers(),
        )
        payload = self._parse_json(response)
        return payload.get("project") or {}

    def create_project(self, name: str, description: Optional[str] = None) -> Dict[str, Any]:
        site_id = self._require_site_id()
        payload = {"project": {"name": name}}
        if description:
            payload["project"]["description"] = description
        response = self._post(
            self._api_url(f"/sites/{site_id}/projects"),
            headers=self._rest_headers(),
            json=payload,
        )
        data = self._parse_json(response)
        return data.get("project") or {}

    # ------------------------------------------------------------------
    # Metadata API
    # ------------------------------------------------------------------
    def query_metadata(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
        """
        Execute GraphQL query against the Metadata API.

        Endpoint: /api/metadata/graphql (uses the same auth token as REST API).
        No separate sign-in is required.
        """
        payload = {"query": query, "variables": variables or {}}
        response = self._post(
            f"{self.site_url}/api/metadata/graphql",
            headers=self._rest_headers(),
            json=payload,
        )
        data = self._parse_json(response)
        if data.get("errors"):
            raise IntegrationError(json.dumps(data.get("errors"), indent=2))
        return data.get("data") or {}

    def get_datasource_fields(self, datasource_id: str) -> List[Dict[str, Any]]:
        data = self.query_metadata(DEFAULT_FIELDS_QUERY, {"datasourceId": datasource_id})
        fields = ((data.get("datasource") or {}).get("fields") or [])
        return [FieldInfo.from_api(field).model_dump() for field in fields]

    def get_lineage(self, datasource_id: str) -> Dict[str, Any]:
        return self.query_metadata(DEFAULT_LINEAGE_QUERY, {"datasourceId": datasource_id})

    # ------------------------------------------------------------------
    # Jobs
    # ------------------------------------------------------------------
    def get_job(self, job_id: str) -> Dict[str, Any]:
        site_id = self._require_site_id()
        response = self._get(
            self._api_url(f"/sites/{site_id}/jobs/{job_id}"),
            headers=self._rest_headers(),
        )
        payload = self._parse_json(response)
        return payload.get("job") or {}

    def wait_for_job(self, job_id: str, timeout: int = 600, poll_interval: float = 2.0) -> Dict[str, Any]:
        deadline = time.time() + timeout
        last_job = {}
        while time.time() < deadline:
            last_job = self.get_job(job_id)
            state = (last_job.get("status") or last_job.get("state") or "").lower()
            finish_code = last_job.get("finishCode")
            if finish_code in (0, "0"):
                state = "success"
            if state in {"success", "succeeded", "completed"}:
                return {"job_id": job_id, "status": "success", "job": last_job}
            if state in {"failed", "error", "cancelled", "canceled"}:
                return {"job_id": job_id, "status": state or "failed", "job": last_job}
            self._sleep(poll_interval)
        return {"job_id": job_id, "status": "timeout", "job": last_job}

    # ------------------------------------------------------------------
    # Optional: Tableau Server Client
    # ------------------------------------------------------------------
    def _get_tsc_server(self):
        try:
            import tableauserverclient as TSC
        except ImportError as exc:  # pragma: no cover - optional dependency
            raise IntegrationError(
                "tableauserverclient is required for publish operations. "
                "Install with: pip install g-gremlin[tableau]"
            ) from exc

        server = TSC.Server(self.site_url, use_server_version=False)
        server.version = self.api_version
        auth = TSC.PersonalAccessTokenAuth(self.token_name, self.access_token, self.site_name or "")
        server.auth.sign_in(auth)
        return server, TSC


__all__ = ["TableauClient"]
