from __future__ import annotations

import functools
import logging
import os
import sys
import warnings
from collections import UserDict
from contextlib import contextmanager
from typing import (
    IO,
    TYPE_CHECKING,
    Any,
    Generic,
    Iterator,
    List,
    Mapping,
    Sequence,
    Tuple,
    TypeVar,
    overload,
)

from mkdocs import exceptions, utils
from mkdocs.utils import weak_property

if TYPE_CHECKING:
    from mkdocs.config.defaults import MkDocsConfig


log = logging.getLogger('mkdocs.config')


T = TypeVar('T')


class BaseConfigOption(Generic[T]):
    def __init__(self) -> None:
        self.warnings: list[str] = []
        self.default = None

    @property
    def default(self):
        try:
            # ensure no mutable values are assigned
            return self._default.copy()
        except AttributeError:
            return self._default

    @default.setter
    def default(self, value):
        self._default = value

    def validate(self, value: object, /) -> T:
        return self.run_validation(value)

    def reset_warnings(self) -> None:
        self.warnings = []

    def pre_validation(self, config: Config, key_name: str) -> None:
        """
        Before all options are validated, perform a pre-validation process.

        The pre-validation process method should be implemented by subclasses.
        """

    def run_validation(self, value: object, /):
        """
        Perform validation for a value.

        The run_validation method should be implemented by subclasses.
        """
        return value

    def post_validation(self, config: Config, key_name: str) -> None:
        """
        After all options have passed validation, perform a post-validation
        process to do any additional changes dependent on other config values.

        The post-validation process method should be implemented by subclasses.
        """

    def __set_name__(self, owner, name):
        if name.endswith('_') and not name.startswith('_'):
            name = name[:-1]
        self._name = name

    @overload
    def __get__(self, obj: Config, type=None) -> T:
        ...

    @overload
    def __get__(self, obj, type=None) -> BaseConfigOption:
        ...

    def __get__(self, obj, type=None):
        if not isinstance(obj, Config):
            return self
        return obj[self._name]

    def __set__(self, obj, value: T):
        if not isinstance(obj, Config):
            raise AttributeError(
                f"can't set attribute ({self._name}) because the parent is a {type(obj)} not a {Config}"
            )
        obj[self._name] = value


class ValidationError(Exception):
    """Raised during the validation process of the config on errors."""

    def __eq__(self, other):
        return type(self) is type(other) and str(self) == str(other)


PlainConfigSchemaItem = Tuple[str, BaseConfigOption]
PlainConfigSchema = Sequence[PlainConfigSchemaItem]

ConfigErrors = List[Tuple[str, Exception]]
ConfigWarnings = List[Tuple[str, str]]


class Config(UserDict):
    """
    Base class for MkDocs configuration, plugin configuration (and sub-configuration) objects.

    It should be subclassed and have `ConfigOption`s defined as attributes.
    For examples, see mkdocs/contrib/search/__init__.py and mkdocs/config/defaults.py.

    Behavior as it was prior to MkDocs 1.4 is now handled by LegacyConfig.
    """

    _schema: PlainConfigSchema
    config_file_path: str

    def __init_subclass__(cls):
        schema = dict(getattr(cls, '_schema', ()))
        for attr_name, attr in cls.__dict__.items():
            if isinstance(attr, BaseConfigOption):
                schema[getattr(attr, '_name', attr_name)] = attr
        cls._schema = tuple(schema.items())

        for attr_name, attr in cls._schema:
            attr.required = True
            if getattr(attr, '_legacy_required', None) is not None:
                raise TypeError(
                    f"{cls.__name__}.{attr_name}: "
                    "Setting 'required' is unsupported in class-based configs. "
                    "All values are required, or can be wrapped into config_options.Optional"
                )

    def __new__(cls, *args, **kwargs) -> Config:
        """Compatibility: allow referring to `LegacyConfig(...)` constructor as `Config(...)`."""
        if cls is Config:
            return LegacyConfig(*args, **kwargs)
        return super().__new__(cls)

    def __init__(self, config_file_path: str | bytes | None = None):
        super().__init__()
        self.__user_configs: list[dict] = []
        self.set_defaults()

        self._schema_keys = {k for k, v in self._schema}
        # Ensure config_file_path is a Unicode string
        if config_file_path is not None and not isinstance(config_file_path, str):
            try:
                # Assume config_file_path is encoded with the file system encoding.
                config_file_path = config_file_path.decode(encoding=sys.getfilesystemencoding())
            except UnicodeDecodeError:
                raise ValidationError("config_file_path is not a Unicode string.")
        self.config_file_path = config_file_path or ''

    def set_defaults(self) -> None:
        """
        Set the base config by going through each validator and getting the
        default if it has one.
        """
        for key, config_option in self._schema:
            self[key] = config_option.default

    def _validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
        failed: ConfigErrors = []
        warnings: ConfigWarnings = []

        for key, config_option in self._schema:
            try:
                value = self.get(key)
                self[key] = config_option.validate(value)
                warnings.extend((key, w) for w in config_option.warnings)
                config_option.reset_warnings()
            except ValidationError as e:
                failed.append((key, e))
                break

        for key in set(self.keys()) - self._schema_keys:
            warnings.append((key, f"Unrecognised configuration name: {key}"))

        return failed, warnings

    def _pre_validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
        failed: ConfigErrors = []
        warnings: ConfigWarnings = []

        for key, config_option in self._schema:
            try:
                config_option.pre_validation(self, key_name=key)
                warnings.extend((key, w) for w in config_option.warnings)
                config_option.reset_warnings()
            except ValidationError as e:
                failed.append((key, e))

        return failed, warnings

    def _post_validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
        failed: ConfigErrors = []
        warnings: ConfigWarnings = []

        for key, config_option in self._schema:
            try:
                config_option.post_validation(self, key_name=key)
                warnings.extend((key, w) for w in config_option.warnings)
                config_option.reset_warnings()
            except ValidationError as e:
                failed.append((key, e))

        return failed, warnings

    def validate(self) -> tuple[ConfigErrors, ConfigWarnings]:
        failed, warnings = self._pre_validate()

        run_failed, run_warnings = self._validate()

        failed.extend(run_failed)
        warnings.extend(run_warnings)

        # Only run the post validation steps if there are no failures, warnings
        # are okay.
        if len(failed) == 0:
            post_failed, post_warnings = self._post_validate()
            failed.extend(post_failed)
            warnings.extend(post_warnings)

        return failed, warnings

    def load_dict(self, patch: dict) -> None:
        """Load config options from a dictionary."""
        if not isinstance(patch, dict):
            raise exceptions.ConfigurationError(
                "The configuration is invalid. Expected a key-"
                f"value mapping (dict) but received: {type(patch)}"
            )

        self.__user_configs.append(patch)
        self.update(patch)

    def load_file(self, config_file: IO) -> None:
        """Load config options from the open file descriptor of a YAML file."""
        warnings.warn(
            "Config.load_file is not used since MkDocs 1.5 and will be removed soon. "
            "Use MkDocsConfig.load_file instead",
            DeprecationWarning,
        )
        return self.load_dict(utils.yaml_load(config_file))

    @weak_property
    def user_configs(self) -> Sequence[Mapping[str, Any]]:
        warnings.warn(
            "user_configs is never used in MkDocs and will be removed soon.", DeprecationWarning
        )
        return self.__user_configs


@functools.lru_cache(maxsize=None)
def get_schema(cls: type) -> PlainConfigSchema:
    """Extract ConfigOptions defined in a class (used just as a container) and put them into a schema tuple."""
    if issubclass(cls, Config):
        return cls._schema
    return tuple((k, v) for k, v in cls.__dict__.items() if isinstance(v, BaseConfigOption))


class LegacyConfig(Config):
    """A configuration object for plugins, as just a dict without type-safe attribute access."""

    def __init__(self, schema: PlainConfigSchema, config_file_path: str | None = None):
        self._schema = tuple((k, v) for k, v in schema)  # Re-create just for validation
        super().__init__(config_file_path)


@contextmanager
def _open_config_file(config_file: str | IO | None) -> Iterator[IO]:
    """
    A context manager which yields an open file descriptor ready to be read.

    Accepts a filename as a string, an open or closed file descriptor, or None.
    When None, it defaults to `mkdocs.yml` in the CWD. If a closed file descriptor
    is received, a new file descriptor is opened for the same file.

    The file descriptor is automatically closed when the context manager block is existed.
    """
    # Default to the standard config filename.
    if config_file is None:
        paths_to_try = ['mkdocs.yml', 'mkdocs.yaml']
    # If it is a string, we can assume it is a path and attempt to open it.
    elif isinstance(config_file, str):
        paths_to_try = [config_file]
    # If closed file descriptor, get file path to reopen later.
    elif getattr(config_file, 'closed', False):
        paths_to_try = [config_file.name]
    else:
        result_config_file = config_file
        paths_to_try = None

    if paths_to_try:
        # config_file is not a file descriptor, so open it as a path.
        for path in paths_to_try:
            path = os.path.abspath(path)
            log.debug(f"Loading configuration file: {path}")
            try:
                result_config_file = open(path, 'rb')
                break
            except FileNotFoundError:
                continue
        else:
            raise exceptions.ConfigurationError(f"Config file '{paths_to_try[0]}' does not exist.")
    else:
        log.debug(f"Loading configuration file: {result_config_file}")
        # Ensure file descriptor is at beginning
        try:
            result_config_file.seek(0)
        except OSError:
            pass

    try:
        yield result_config_file
    finally:
        if hasattr(result_config_file, 'close'):
            result_config_file.close()


def load_config(
    config_file: str | IO | None = None, *, config_file_path: str | None = None, **kwargs
) -> MkDocsConfig:
    """
    Load the configuration for a given file object or name.

    The config_file can either be a file object, string or None. If it is None
    the default `mkdocs.yml` filename will loaded.

    Extra kwargs are passed to the configuration to replace any default values
    unless they themselves are None.
    """
    options = kwargs.copy()

    # Filter None values from the options. This usually happens with optional
    # parameters from Click.
    for key, value in options.copy().items():
        if value is None:
            options.pop(key)

    with _open_config_file(config_file) as fd:
        # Initialize the config with the default schema.
        from mkdocs.config.defaults import MkDocsConfig

        if config_file_path is None:
            if sys.stdin and fd is not sys.stdin.buffer:
                config_file_path = getattr(fd, 'name', None)
        cfg = MkDocsConfig(config_file_path=config_file_path)
        # load the config file
        cfg.load_file(fd)

    # Then load the options to overwrite anything in the config.
    cfg.load_dict(options)

    errors, warnings = cfg.validate()

    for config_name, warning in warnings:
        log.warning(f"Config value '{config_name}': {warning}")

    for config_name, error in errors:
        log.error(f"Config value '{config_name}': {error}")

    for key, value in cfg.items():
        log.debug(f"Config value '{key}' = {value!r}")

    if len(errors) > 0:
        raise exceptions.Abort("Aborted with a configuration error!")
    elif cfg.strict and len(warnings) > 0:
        raise exceptions.Abort(
            f"Aborted with {len(warnings)} configuration warnings in 'strict' mode!"
        )

    return cfg
