from typing import Any, Dict, Union, Literal

from dlt.common.exceptions import PipelineException
from dlt.common.pipeline import LoadInfo, NormalizeInfo, StepInfo, StepMetrics, SupportsPipeline

from dlt.common.storages.load_package import PackageStorage
from dlt.pipeline.typing import TPipelineStep


class InvalidPipelineName(PipelineException, ValueError):
    def __init__(self, pipeline_name: str, details: str) -> None:
        super().__init__(
            pipeline_name,
            f"The pipeline name `{pipeline_name}` contains invalid characters. The pipeline name is"
            " used to create a pipeline working directory and must be a valid directory name. "
            f"Details: {details}",
        )


class PipelineConfigMissing(PipelineException):
    def __init__(
        self,
        pipeline_name: str,
        config_elem: str,
        step_or_func: Union[TPipelineStep, Literal["dataset"]],
        _help: str = None,
    ) -> None:
        self.config_elem = config_elem
        self.step_or_func = step_or_func
        msg = (
            f"Configuration element `{config_elem}` was not provided and step or function"
            f" `{step_or_func}` cannot be executed"
        )
        if _help:
            msg += f"\n{_help}\n"
        super().__init__(pipeline_name, msg)


class CannotRestorePipelineException(PipelineException):
    def __init__(self, pipeline_name: str, pipelines_dir: str, reason: str) -> None:
        msg = (
            f"Pipeline with `{pipeline_name=:}` and `{pipelines_dir=:}` could not be"
            f" restored: {reason}"
        )
        super().__init__(pipeline_name, msg)


class PipelineStepFailed(PipelineException):
    """Raised by run, extract, normalize and load Pipeline methods."""

    def __init__(
        self,
        pipeline: SupportsPipeline,
        step: TPipelineStep,
        load_id: str,
        exception: BaseException,
        step_info: StepInfo[StepMetrics] = None,
    ) -> None:
        self.pipeline = pipeline
        self.step = step
        self.load_id = load_id
        self.exception = exception
        self.step_info = step_info
        self.has_pending_data = pipeline.has_pending_data
        self.is_package_partially_loaded = False

        package_str = f" when processing package with `{load_id=:}`" if load_id else ""
        msg = (
            f"Pipeline execution failed at `{step=:}`{package_str} with"
            f" exception:\n\n{type(exception)}\n{exception}"
        )
        if isinstance(step_info, (NormalizeInfo, LoadInfo)):
            if self.has_pending_data:
                msg += (
                    "\n\nPending packages are left in the pipeline and will be re-tried on the"
                    " next pipeline run."
                    " If you pass new data to extract to next run, it will be ignored. Run "
                    f"`dlt pipeline {pipeline.pipeline_name} info` for more information or `dlt"
                    f" pipeline {pipeline.pipeline_name} drop-pending-packages` to drop pending"
                    " packages."
                )
            if load_id and step_info and load_id in step_info.loads_ids and step == "load":
                # get package info
                package_info = next(
                    (p for p in step_info.load_packages if p.load_id == load_id), None
                )
                if package_info:
                    self.is_package_partially_loaded = PackageStorage.is_package_partially_loaded(
                        package_info
                    )
                    if self.is_package_partially_loaded:
                        msg += (
                            f"\nWARNING: package `{load_id}` is partially loaded. Data in"
                            " destination could be modified by one of completed load jobs while"
                            " others were not yet executed or were retried. Data in the"
                            " destination may be in inconsistent state. We recommend that you"
                            " retry the load or  review the incident before dropping pending"
                            " packages. See"
                            " https://dlthub.com/docs/running-in-production/running#partially-loaded-packages"
                            " for details"
                        )

        super().__init__(pipeline.pipeline_name, msg)

    def attrs(self) -> Dict[str, Any]:
        # remove attr that should not be published
        attrs_ = super().attrs()
        attrs_.pop("pipeline")
        attrs_.pop("exception")
        attrs_.pop("step_info")
        return attrs_


class PipelineStateEngineNoUpgradePathException(PipelineException):
    def __init__(
        self, pipeline_name: str, init_engine: int, from_engine: int, to_engine: int
    ) -> None:
        self.init_engine = init_engine
        self.from_engine = from_engine
        self.to_engine = to_engine
        super().__init__(
            pipeline_name,
            f"No engine upgrade path for state in pipeline `{pipeline_name}` from `{init_engine}`"
            f" to `{to_engine}`, stopped at `{from_engine}`. You possibly tried to run an older dlt"
            " version against a destination you have previously loaded data to with a newer dlt"
            " version.",
        )


class PipelineHasPendingDataException(PipelineException):
    def __init__(self, pipeline_name: str, pipelines_dir: str) -> None:
        msg = (
            f" Operation failed because pipeline with `{pipeline_name=:}` and `{pipelines_dir=:}`"
            " contains pending extracted files or load packages. Use `dlt pipeline sync`"
            " to reset the local state then run this operation again."
        )
        super().__init__(pipeline_name, msg)


class PipelineNeverRan(PipelineException):
    def __init__(self, pipeline_name: str, pipelines_dir: str) -> None:
        msg = (
            f" Operation failed because pipeline with `{pipeline_name=:}` and `{pipelines_dir=:}`"
            " was never run or was never synced with destination. Use `dlt pipeline sync`"
            " or call `pipeline.sync_destination()` to get pipeline state and set of"
            " schemas from destination."
        )
        super().__init__(pipeline_name, msg)


class PipelineNotActive(PipelineException):
    def __init__(self, pipeline_name: str) -> None:
        super().__init__(
            pipeline_name, f"Pipeline `{pipeline_name}` is not active so it cannot be deactivated"
        )
