import datetime
import logging
import time
from importlib.metadata import PackageNotFoundError, version
from typing import Any, Dict, List, Optional, cast
from urllib.parse import urljoin

import requests
import dagster as dg
from pydantic import Field

from dagster._annotations import deprecated
from dagster._utils.cached_method import cached_method


from dagster_hex.types import (
    HexOutput,
    NotificationDetails,
    RunResponse,
    StatusResponse,
)

from dagster_hex.consts import (
    COMPLETE,
    DEFAULT_POLL_INTERVAL,
    DEFAULT_POLL_TIMEOUT,
    HEX_API_BASE,
    TERMINAL_STATUSES,
    VALID_STATUSES,
)


class HexResource(dg.ConfigurableResource):
    api_key: str = Field(
        ...,
        description="Hex API Key. You can find this on the Hex settings page",
    )

    base_url: str = Field(
        default=HEX_API_BASE,
        description="Hex Base URL for API requests.",
    )

    request_max_retries: int = Field(
        default=3,
        description="The maximum times requests to the Hex API should be retried before failing.",
    )

    request_retry_delay: float = Field(
        default=0.25,
        description="Time (in seconds) to wait between each request retry.",
    )
    log: dg.ResourceDependency[Optional[logging.Logger]] = Field(
        default=None,
        description="Optional logger to be used from within the HexResource (default: `get_dagster_logger()`",
    )

    @property
    @cached_method
    def _log(self) -> logging.Logger:
        return self.log if self.log else dg.get_dagster_logger()

    def make_request(
        self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Any]:
        """Creates and sends a request to the desired Hex API endpoint

        Args:
            method (str): The http method use for this request (e.g. "GET", "POST").
            endpoint (str): The Hex API endpoint to send this request to.
            data (Optional(dict): Query parameters to pass to the API endpoint

        Returns:
            Dict[str, Any]: Parsed json data from the response to this request
        """

        try:
            __version__ = version("dagster_hex")
        except PackageNotFoundError:
            __version__ = "UnknownVersion"

        user_agent = "HexDagsterOp/" + __version__
        headers = {"Authorization": f"Bearer {self.api_key}", "User-Agent": user_agent}
        url = urljoin(self.base_url, endpoint)

        num_retries = 0
        while True:
            try:
                if method == "GET":
                    response = requests.request(
                        method=method,
                        url=url,
                        headers=headers,
                        params=data,
                    )
                elif method == "POST":
                    response = requests.request(
                        method=method,
                        url=url,
                        headers=headers,
                        json=data,
                    )
                else:
                    response = requests.request(
                        method=method,
                        url=url,
                        headers=headers,
                        data=data,
                    )
                if response.status_code == 422:
                    # 422 statuses from Hex may have additional error information as a
                    # JSON payload
                    try:
                        msg = response.json()
                    except requests.exceptions.JSONDecodeError:
                        msg = response.text
                    raise dg.Failure(f"Received 422 status from Hex: {msg}")

                response.raise_for_status()
                if response.headers.get("Content-Type", "").startswith(
                    "application/json"
                ):
                    try:
                        response_json = response.json()
                    except requests.exceptions.JSONDecodeError:
                        self._log.error("Failed to decode response from API.")
                        self._log.error("API returned: %s", response.text)
                        raise dg.Failure(
                            "Unexpected response from Hex API.Failed to decode to JSON."
                        )
                    return response_json
            except requests.RequestException as e:
                self._log.error("Request to Hex API failed: %s", e)
                if num_retries == self.request_max_retries:
                    break
                num_retries += 1
                time.sleep(self.request_retry_delay)

        raise dg.Failure("Exceeded max number of retries.")

    def run_project(
        self,
        project_id: str,
        inputs: Optional[Dict[str, Any]] = None,
        update_cache: bool = False,
        notifications: List[NotificationDetails] = [],
    ) -> RunResponse:
        """Trigger a sync and initiate a sync run

        Args:
            project_id (str): The Hex Project ID
            inputs (dict): additional input parameters, a json-serializable dictionary
                of variable_name: value pairs.
            update_cache (bool): When true, this run will update the cached state of the
                published app with the latest run results.
                Additionally, any SQL cells that have caching enabled will be
                re-executed as part of this run. Note that this cannot be set to true
                if custom input parameters are provided.

        Returns:
            RunResponse
        """
        method = "POST"
        endpoint = f"/api/v1/project/{project_id}/run"
        data: Dict[str, Any] = {"updateCache": update_cache}
        if inputs:
            data["inputParams"] = inputs

        if notifications:
            data["notifications"] = notifications

        response = cast(
            RunResponse,
            self.make_request(
                method=method,
                endpoint=endpoint,
                data=data,
            ),
        )
        return response

    def run_status(self, project_id, run_id) -> StatusResponse:
        """
        Fetches the status of a run for a given project

        Args:
            project_id (str): The Project ID to fetch results for
            run_id (str): The Run ID of the project to fetch results for

        Returns:
            StatusResponse
        """

        endpoint = f"api/v1/project/{project_id}/run/{run_id}"
        method = "GET"

        response = cast(
            StatusResponse,
            self.make_request(method=method, endpoint=endpoint, data=None),
        )
        return response

    def cancel_run(self, project_id, run_id) -> str:
        """
        Cancels a run in progress.
        Args:
            project_id: The Project ID for the run
            run_id: The Run ID to cancel

        Returns:
            The Run ID that was cancelled

        """
        endpoint = f"api/v1/project/{project_id}/run/{run_id}"
        method = "DELETE"

        self.make_request(method=method, endpoint=endpoint)
        return run_id

    def run_and_poll(
        self,
        project_id: str,
        inputs: Optional[dict],
        notifications: List[NotificationDetails] = [],
        update_cache: bool = False,
        kill_on_timeout: bool = True,
        poll_interval: float = DEFAULT_POLL_INTERVAL,
        poll_timeout: Optional[float] = DEFAULT_POLL_TIMEOUT,
    ) -> HexOutput:
        """Trigger a project and poll until complete

        Args:
            project_id (str): The Hex project id
            inputs (dict) additional input parameters, a json-serializable dictionary
                of variable_name: value pairs.
            update_cache (bool): When true, this run will update the cached state of the
                published app with the latest run results.
                Additionally, any SQL cells that have caching enabled will be
                re-executed as part of this run. Note that this cannot be set to true
                if custom input parameters are provided.
            kill_on_timeout (bool): attempt to stop the project if the timeout is
                reached. If false, the project will continue running indefinitely in the
                background until completion.
            poll_interval (int): time to wait in between polls to the API
            poll_timeout (int): maximum time to wait for the project to complete

        Returns:
            Dict[str, Any]: Parsed json output from the API
        """
        run_response = self.run_project(
            project_id,
            inputs=inputs,
            notifications=notifications,
            update_cache=update_cache,
        )
        run_id = run_response["runId"]
        poll_start = datetime.datetime.now()
        while True:
            run_status = self.run_status(project_id, run_id)
            project_status = run_status["status"]

            self._log.debug(run_status)
            self._log.info(
                f"Polling Hex Project {project_id}. Current status: {project_status}."
            )

            if project_status not in VALID_STATUSES:
                raise dg.Failure(
                    f"Received an unexpected status from the API: {project_status}"
                )

            if project_status == COMPLETE:
                break

            if project_status in TERMINAL_STATUSES:
                raise dg.Failure(
                    f"Project Run failed with status {project_status}. "
                    f"See Run URL for more info {run_response['runUrl']}"
                )

            if (
                poll_timeout
                and datetime.datetime.now()
                > poll_start + datetime.timedelta(seconds=poll_timeout)
            ):
                if kill_on_timeout:
                    self.cancel_run(project_id, run_id)
                raise dg.Failure(
                    f"Project {project_id} for run: {run_id}' timed out after "
                    f"{datetime.datetime.now() - poll_start}. "
                    f"Last status was {project_status}. "
                    f"TraceId: {run_status['traceId']}"
                )

            time.sleep(poll_interval)
        return HexOutput(run_response=run_response, status_response=run_status)


@deprecated(
    breaking_version="1.9",
    subject="hex_resource function has been replaced with the configurable HexResource",
    emit_runtime_warning=True,
)
@dg.resource(
    config_schema={
        "api_key": dg.Field(
            dg.StringSource,
            is_required=True,
            description="Hex API Key. You can find this on the Hex settings page",
        ),
        "base_url": dg.Field(
            dg.StringSource,
            default_value="https://app.hex.tech",
            description="Hex Base URL for API requests.",
        ),
        "request_max_retries": dg.Field(
            int,
            default_value=3,
            description="The maximum times requests to the Hex API should be retried "
            "before failing.",
        ),
        "request_retry_delay": dg.Field(
            float,
            default_value=0.25,
            description="Time (in seconds) to wait between each request retry.",
        ),
    },
    description="This resource helps manage Hex",
)
def hex_resource(context) -> HexResource:
    """
    This resource allows users to programmatically interface with the Hex REST API
    """
    return HexResource(
        api_key=context.resource_config["api_key"],
        base_url=context.resource_config["base_url"],
        request_max_retries=context.resource_config["request_max_retries"],
        request_retry_delay=context.resource_config["request_retry_delay"],
    )
