"""Configurations for the SSB timeseries library.

An environment variable TIMESERIES_CONFIG is expected to point to a JSON file with configurations.
If these exist, they will be loaded and put into a Config object CONFIG when the configuration module is loaded.

In most cases, this would happen behind the scene when :py:mod:`ssb_timeseries.dataset` or :py:mod:`ssb_timeseries.catalog` are imported.

Directly accessing the configuration module should only be required when manipulating configurations from Python code.

Example:
    >>> # doctest: +SKIP
    >>> from ssb_timeseries.config import CONFIG
    >>> CONFIG.catalog = 'gs://{bucket}/timeseries/metadata/'
    >>> CONFIG.save()
    >>> # doctest: -SKIP

For switching between preset configurations, use the `timeseries-config` command::

    poetry run timeseries-config <option>

which is equivalent to::

    python ./config.py <option>

See :py:func:`ssb_timeseries.config.main` for details on the named options.
"""

from __future__ import annotations

import json
import logging
import os
import sys
import warnings
from pathlib import Path

try:
    from typing import NotRequired
    from typing import Required
    from typing import Self
    from typing import TypedDict
except ImportError:
    from typing_extensions import NotRequired  # noqa: UP035 #backport to 3.10
    from typing_extensions import Required  # noqa: UP035 #backport to 3.10
    from typing_extensions import Self  # noqa: UP035 #backport to 3.10
    from typing_extensions import TypedDict

from typing import Any
from typing import TypeAlias

from .types import PathStr

# mypy: disable-error-code="assignment, arg-type, override,call-arg,has-type,no-untyped-def,attr-defined,import-untyped,"

_config_logger = logging.getLogger(__name__)
PACKAGE_NAME = "ssb_timeseries"
ENV_VAR_NAME = "TIMESERIES_CONFIG"


class FileRepoConfig(TypedDict):
    """Links a path and a IO handler for a single file based repository."""

    handler: Required[str]
    options: Required[dict[str, Any]]


class Repository(TypedDict):
    """Defines data and metadata handling for time series repositories."""

    name: NotRequired[str]
    directory: Required[FileRepoConfig]
    catalog: NotRequired[FileRepoConfig]
    default: NotRequired[bool]


FileBasedRepository: TypeAlias = Repository


class ConfigDict(TypedDict):
    """Required attributes for configuration."""

    configuration_file: Required[str]
    io_handlers: Required[dict[str, Any]]
    repositories: Required[dict[str, Repository]]
    snapshots: NotRequired[dict[str, Repository]]
    sharing: NotRequired[dict[str, Repository]]
    log_file: NotRequired[str]
    logging: Required[dict[str, Any]]


def is_valid_config(configuration: ConfigDict) -> tuple[bool, object]:
    """Check if a dictionary is a valid configuration :py:class:`ConfigDict`."""
    # The ConfigDict.__required_keys__ includes optional fields like 'snapshots' and 'sharing'
    # which causes a ValidationError when the default configuration is loaded.
    # To fix this, we explicitly define the required keys.
    # missing_required = ConfigDict.__required_keys__ - set(configuration.keys())
    required_keys = {"configuration_file", "io_handlers", "repositories", "logging"}
    missing_required = required_keys - set(configuration.keys())
    if missing_required:
        msg = f"Configuration is missing required fields: {list(missing_required)}\n{configuration}"
        return (False, msg)

    wrong_type = []
    for (
        cfg_key,
        cfg_expected_type,
    ) in ConfigDict().items():  # type: ignore [typeddict-item]
        config_item = configuration.get(cfg_key, None)
        cfg_got_type = type(config_item)
        if cfg_got_type is type(cfg_expected_type):
            wrong_type.append(
                f"{cfg_key} - got {cfg_got_type} - expected {cfg_expected_type}"
            )

    if wrong_type:
        msg = f"Configuration fields have wrong type: {wrong_type}"
        return (False, msg)

    return (True, None)


def unset_env_var() -> str:
    """Unsets the environment variable :py:const:`ENV_VAR_NAME` and returns the value that was unset."""
    return os.environ.pop(ENV_VAR_NAME, "")


def active_file(path: PathStr = "") -> str:
    """If a path is provided, sets environment variable :py:const:`ENV_VAR_NAME` to specify the location of the configuration file.

    Returns the value of the environment variable.
    """
    if path:
        os.environ[ENV_VAR_NAME] = str(path)
        _config_logger.debug(f"Set environment variable {ENV_VAR_NAME} to {path}")

    return os.environ.get(ENV_VAR_NAME, "")


HOME = str(Path.home())

DAPLALAB_WORK = "/home/onyxia/work"
DAPLALAB_FUSE = "/buckets"
SSB_DIR_NAME = "tidsserier"
ROOT_DIR_NAME = "timeseries"
META_DIR_NAME = "metadata"
SSB_CONF_DIR = "konfigurasjon"
LINUX_CONF_DIR = ".config"
SSB_LOGDIR = "logger"
LOGDIR = "logs"
LOGFILE = "timeseries.log"
CONFIGFILE = "timeseries_config.json"

DAPLA_TEAM_CONTEXT = os.getenv("DAPLA_TEAM_CONTEXT", "")
DAPLA_ENV = os.getenv("DAPLA_ENVIRONMENT", "")
"""Returns the Dapla environment: 'prod' | test | dev"""
DAPLA_TEAM = os.getenv("DAPLA_TEAM", "<teamname>")
"""Returns the Dapla team/project name.'"""
DAPLA_BUCKET = f"gs://{DAPLA_TEAM}-{DAPLA_ENV}"
"""Returns the Dapla product bucket name for the current environment: gs://{DAPLA_TEAM}-{DAPLA_ENV}."""

LOGGING_PRESETS = {
    "simple": {
        "version": 1,
        "disable_existing_loggers": False,
        "handlers": {
            "console": {
                "class": "logging.StreamHandler",
                "stream": "ext://sys.stdout",
                "level": "INFO",
            },
        },
        "loggers": {
            PACKAGE_NAME: {
                "level": "INFO",
                "handlers": ["console"],
                "propagate": False,
            }
        },
    },
    "console+file": {
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "default": {
                "format": "%(asctime)s - %(levelname)s - %(message)s",
                "datefmt": "%Y-%m-%d %H:%M:%S",
            },
            "json": {
                "format": '{"time": %(asctime)-s, "level": %(levelname)-s, "message": %(message)s},',
                "datefmt": "%Y-%m-%d %H:%M:%S",
            },
        },
        "handlers": {
            "console": {
                "level": "INFO",
                "class": "logging.StreamHandler",
                "formatter": "default",
                "stream": "ext://sys.stdout",
            },
            "file": {
                "level": "INFO",
                "class": "logging.handlers.RotatingFileHandler",
                "formatter": "default",
                "filename": "ssb_timeseries.log",
                "maxBytes": 10_000,
                "backupCount": 3,
            },
        },
        "loggers": {
            PACKAGE_NAME: {
                "level": "INFO",
                "handlers": ["console", "file"],
                "propagate": False,
            }
        },
    },
}

BUILTIN_IO_HANDLERS = {
    "simple-parquet": {
        "handler": "ssb_timeseries.io.pyarrow_simple.FileSystem",
        "options": {},
    },
    "json": {
        "handler": "ssb_timeseries.io.json_metadata.JsonMetaIO",
        "options": {},
    },
    "snapshots": {
        "handler": "ssb_timeseries.io.snapshots.FileSystem",
        "options": {},
    },
}
PRESETS: dict[str, ConfigDict] = {
    "home": {
        "configuration_file": str(Path(HOME, LINUX_CONF_DIR, PACKAGE_NAME, CONFIGFILE)),
        "io_handlers": BUILTIN_IO_HANDLERS,
        "repositories": {
            DAPLA_TEAM: {
                # "name": "home",
                "directory": {
                    "handler": "parquet",
                    "options": {"path": str(Path(HOME, ROOT_DIR_NAME))},
                },
                "catalog": {
                    "handler": "json",
                    "options": {
                        "path": str(Path(HOME, ROOT_DIR_NAME, META_DIR_NAME)),
                    },
                },
            }
        },
        "log_file": str(Path(HOME, ROOT_DIR_NAME, LOGDIR, LOGFILE)),
        "logging": LOGGING_PRESETS["simple"],
    },
    "daplalab": {
        "configuration_file": str(
            Path(DAPLA_BUCKET, SSB_CONF_DIR, PACKAGE_NAME, CONFIGFILE)
        ),
        "io_handlers": BUILTIN_IO_HANDLERS,
        "repositories": {
            DAPLA_TEAM: {
                "name": DAPLA_TEAM,
                "directory": {
                    "handler": "parquet",
                    "options": {"path": str(Path(DAPLALAB_FUSE, ROOT_DIR_NAME))},
                },
                "catalog": {
                    "handler": "json",
                    "options": {
                        "path": str(Path(DAPLALAB_FUSE, SSB_DIR_NAME, META_DIR_NAME)),
                    },
                },
            }
        },
        "log_file": str(Path(DAPLALAB_FUSE, SSB_LOGDIR, LOGFILE)),
        "logging": LOGGING_PRESETS["simple"],
    },
}

PRESETS["default"] = PRESETS["home"]
PRESETS["defaults"] = PRESETS["home"]
DEFAULTS = PRESETS["default"]


class Config:
    """Configuration class; for reading and writing timeseries configurations.

    If instantiated with no parameters, an existing configuration file is exepected to exist: either in a location specified by the environment variable TIMESERIES_CONFIG or in the default location in the user's home directory. If not, an error is returned.

    If the :py:attr:`configuration_file` attribute is specified, configurations will be loaded from that file. No other parameters are required. A :py:exc:`FileNotFoundError` or :py:exc:`FileDoesNotExist` error will be returned if the file is not found. In this case, no attempt is made to load configurations from locations specified by environment variable or defaults.

    If any additional parameters are provided, they will override values from the configuration file. If the result is not a valid configuration, a ValidationError is raised.

    If one or more parameters are provided, but the `configuration_file` parameter is not among them, configurations are identified by the environment variable TIMESERIES_CONFIG or the default configuration file location (in that order of priority). Provided parameters override values from the configuration file. If the result is not a valid configuration, an error is raised.

    The returned configuration will not be saved, but held in memory only till the :py:meth:`save` method is called. Then the configuration will be savedto a file and the environment variable TIMESERIES_CONFIG set to reflect the location of the file.

    """

    configuration_file: PathStr
    """The path to the configuRation file."""
    repositories: dict[str, Repository]
    """Defines storage locations for time series data and metadata."""
    snapshots: dict[str, Repository]
    """Defines the storage locations for persisting (archiving) data in stable states."""
    sharing: dict[str, Repository]
    """Defines the storage locations for shared data."""
    io_handlers: dict[str, Any]
    """IO handlers for repository, snapshotts and sharing."""
    logging: dict[str, Any]
    """Logging configuration as a valid :py:mod:`logging.dictConfig`."""

    def __init__(self, **kwargs) -> None:  # noqa: D417, ANN003, RUF100
        """Initialize Config object from keyword arguments.

        Keyword Arguments:
            preset (str): Optional. Name of a preset configuration. If provided, the preset configuration is loaded, and no other parameters are considered.
            configuration_file (str): Path to the configuration file. If the parameter is not provided, the environment variable TIMESERIES_CONFIG is used. If the environment variable is not set, the default configuration file location is used.
            repositories (list[FileBasedRepository]): New in version 0.5.0. Replaces bucket, timeseries_root and catalog.
            log_file (str): Path to the log file.
            bucket (str): Name of the GCS bucket.
            ignore_file (bool):

        Raises:
            :py:exc:`FileNotFoundError`: If the configuration file as implied by provided or not provided parameters does not exist.   # noqa: DAR402
            :py:exc:`ValidationError`: If the resulting configuration is not valid.   # noqa: DAR402
            :py:exc:`EnvVarNotDefinedeError`: If the environment variable TIMESERIES_CONFIG is not defined.

        Examples:
            To load an existing preset configuration:

                >>> from ssb_timeseries.config import Config
                >>> config = Config(preset='daplalab')
        """
        preset_name = kwargs.pop("preset", "")
        ignore_file = kwargs.pop("ignore_file", False)
        param_specified_config_file = kwargs.get("configuration_file", "")

        kwargs_are_complete_config = is_valid_config(kwargs)[0]

        if preset_name:
            _config_logger.debug(f"Loading preset configuration {preset_name}.")
            self.apply(PRESETS[preset_name])
            return
        elif kwargs_are_complete_config:
            _config_logger.debug("Complete configuration in parameters.\n%s", kwargs)
            self.apply(kwargs)
            return
        elif param_specified_config_file:
            # if config file is
            _config_logger.info(
                f"Loading configuration from {param_specified_config_file}"
            )

            if set(kwargs.keys()) == {"configuration_file"}:
                # if config file is the only parameter, it is an error for it not to exist
                no_file_is_an_error = True
            else:
                # if kwargs form a complete config, it is ok if the config file does not exist
                # (otherwise it is needed to supplement the kwargs)
                no_file_is_an_error = not is_valid_config(kwargs)[0]

            if not ignore_file:
                config_from_file = load_json_file(
                    path=param_specified_config_file,
                    error_on_missing=no_file_is_an_error,
                )
            else:
                config_from_file = {}

            config_values = PRESETS["default"]
            config_values.update(config_from_file)  # type: ignore [typeddict-item]
            _config_logger.debug(f"FROM FILE: {config_values=}")
        elif active_file():
            # if the path is specified by the environment variable, not finding it is an error
            _config_logger.debug(f"Loading configuration from {active_file()}")
            config_values = load_json_file(
                path=active_file(),
                error_on_missing=True,
            )
        # elif not active_file():
        #    raise MissingEnvironmentVariableError
        else:
            _config_logger.warning(
                f"The environment variable {ENV_VAR_NAME} did not exist and no configuration file parameter was provided. Loading default configuration."
            )
            config_values = PRESETS["defaults"]

        config_values.update(kwargs)  # type: ignore [typeddict-item]
        self.apply(config_values)

    def apply(self, configuration: dict) -> None:
        """Set configuration values from a dictionary."""
        _config_logger.debug(f"APPLIES: {configuration=}")
        log_config = configuration.get("logging", {})
        if not log_config:
            configuration["logging"] = {}
        elif isinstance(log_config, str):
            warnings.warn(
                "string based log config! TO DO: look up named option", stacklevel=2
            )
            configuration["logging"] = {"str": log_config}

        config_ok, reason = is_valid_config(configuration=configuration)

        if not config_ok:
            _config_logger.error(f"Invalid configuration {configuration}\n{reason}.")
            raise ValidationError(f"Invalid configuration:\n{configuration}\n{reason}.")

        logfile = configuration.pop("log_file", "")
        if logfile and not logging:
            # TODO: filehandler should be configured as dictConfig
            # .. and we should not enter this block?
            # --> TODO: Check / remove OR add dictConfig for the following:
            configuration["logging"] = {"logfile": logfile}
        else:
            ...
            # --- if logging is valid logging.dictConfig -->
            # (add file handler first?)
            # handlers = configuration['logging'].get('handlers',{})
            # filehandler =  handlers.get('file',{})
            # if logfile and filehandler:
            #     configuration['logging']['handlers']['file']['filename'] = logfile

        for key, value in configuration.items():
            setattr(self, key, value)

    @property
    def is_valid(self) -> bool:
        """Check if the configuration has all required fields."""
        result: bool = is_valid_config(self.__dict__)[0]
        return result

    @property
    def log_file(self) -> str:
        """Get file name from logging configuration, if a file based log handler is defined."""
        logging = getattr(self, "logging", {})
        handlers = logging.get("handlers", {})
        file_handler = handlers.get("file", {})
        if file_handler:
            return str(file_handler["filename"])
        else:
            return ""

    def save(self, path: PathStr = "") -> None:
        """Saves configurations to the JSON file defined by `path` or :py:attr:`configuration_file`.

        If `path` is set, it will take presence and :attr:`.configuration_file` will be set accordingly.

        Args:
            path (PathStr): Full path of the JSON file to save to. If not specified, it will attempt to use the environment variable TIMESERIES_CONFIG before falling back to the default location `$HOME/.config/ssb_timeseries/timeseries_config.json`.

        Raises:
            ValueError: If `path` is not provided and :attr:`configuration_file` is not set.
        """
        from .io import fs

        if path:
            self.configuration_file = str(path)
        elif not self.configuration_file:
            raise ValueError(
                "Configuration file must have a value or path must be specified."
            )
        else:
            path = self.configuration_file

        fs.write_text(content=str(self), path=str(path), file_format="json")
        if not fs.exists(self.log_file):
            fs.touch(self.log_file)

        active_file(str(path))

    def __getitem__(self, item: str) -> Any | None:
        """Get the value of a configuration."""
        return getattr(self, str(item), None)

    def __eq__(self, other: Self | dict) -> bool:
        """Equality test."""
        if isinstance(other, dict):
            return self.__dict__ == other
        else:
            return self.__dict__ == other.__dict__

    def __str__(self) -> str:
        """Return timeseries configurations as JSON string."""
        return json.dumps(self.__dict__, sort_keys=True, indent=2)

    @classmethod
    def active(cls) -> Self:
        """Force reload the file identified by :py:const:`ENV_VAR_NAME` and return the configuration."""
        return cls(configuration_file=active_file())


class MissingEnvironmentVariableError(Exception):
    """The environment variable TIMESEREIS_CONFIG must be defined."""

    ...


class ValidationError(Exception):
    """Configuration validation error."""

    ...


def load_json_file(path: PathStr, error_on_missing: bool = False) -> dict:
    """Read configurations from a JSON file into a Config object."""
    from .io import fs

    if fs.exists(path):
        from_json = fs.read_json(path)
        if not isinstance(from_json, dict):
            from_json = json.loads(from_json)

        return from_json

    elif error_on_missing:
        raise FileNotFoundError(
            f"A configuration file at {path} file was specified, but does not exist."
        )
    else:
        return {}


class DictObject(object):  # noqa
    """Helper class to convert dict to object."""

    def __init__(self, dict_: dict) -> None:  # noqa: D107
        self.__dict__.update(dict_)

    @classmethod
    def from_dict(cls, d: dict):  # noqa: ANN206, D102
        return json.loads(json.dumps(d), object_hook=DictObject)


def presets(named_config: str) -> dict | ConfigDict:  # noqa: RUF100
    """Set configurations to predefined defaults.

    Raises:
        ValueError: If args is not 'home' | 'daplalab'.
    """
    if named_config in PRESETS:
        cfg = PRESETS[named_config]
        cfg["logging"]["handlers"]["file"]["filename"] = cfg.pop("log_file", "")
        return cfg
    else:
        raise ValueError(
            f"Named configuration preset '{named_config}' was not recognized."
        )


def main(*args: str | PathStr) -> None:
    """Set configurations to predefined defaults when run from command line.

    Use:
        ```
        poetry run timeseries-config <option>
        ```
    or
        ```
        python ./config.py <option>`
        ```

    Args:
        *args (str): 'home' | 'gcs' | 'daplalab'.

    Raises:
        ValueError: If args is not 'home' | 'gcs' | 'daplalab'. # noqa: DAR402

    """
    if args:
        config_identifier: PathStr = args[0]
    else:
        config_identifier = sys.argv[1]

    cfg = Config(preset=config_identifier)
    cfg.save(path=cfg.configuration_file)

    _config_logger.debug(
        f"Preset configuration '{config_identifier}' was applied:\n\t{cfg.__dict__}\nSaved to file: {cfg.configuration_file}.\nEnvironment variable set: {os.getenv('TIMESERIES_CONFIG')=}"
    )


def path_str(*args) -> str:
    """Concatenate paths as string: str(Path(...))."""
    return str(Path(*args))


if __name__ == "__main__":
    """Execute when called directly, ie not via import statements."""
    # ??? `poetry run timeseries-config <option>` does not appear to go this route.
    # --> not obvious that this is a good idea.
    print(f"Name of the script      : {sys.argv[0]=}")
    print(f"Arguments of the script : {sys.argv[1:]=}")
    main(sys.argv[1])
else:
    from .io import fs

    if active_file():
        if fs.exists(active_file()):
            CONFIGFILE = active_file()
        elif DAPLA_TEAM_CONTEXT:
            raise MissingEnvironmentVariableError(
                f"Environment variable {ENV_VAR_NAME} must be defined and point to a configuration file."
            )
        else:
            _config_logger.warning(
                f"No configuration file was found at {active_file()}.\nOther locations may be tried. Files found will be copied to the default location and the first candidate will be set to active, ie copied once more to {DEFAULTS['configuration_file']}"
            )
            if not fs.exists(CONFIGFILE):
                raise FileNotFoundError(
                    f"No configuration file was found at {active_file()}."
                )
    else:
        CONFIGFILE = ""  # PRESETS["defaults"]["configuration_file"]

    active_file(CONFIGFILE)
    CONFIG = Config(configuration_file=CONFIGFILE)
    """A Config object."""
    fs.touch(CONFIG.log_file)
    # do not save
    # CONFIG.save()
