"""CLI configuration management."""

import json
import logging
import os
import warnings
from collections.abc import Callable
from datetime import datetime, timedelta
from functools import cache
from importlib import metadata as importlib_metadata
from pathlib import Path
from typing import TypeVar

import httpx
import msgspec.json
from httpx import HTTPError
from msgspec import Struct
from pytz import UTC
from tomlkit import TOMLDocument, table
from tomlkit.exceptions import TOMLKitError
from tomlkit.items import Table
from tomlkit.toml_file import TOMLFile

from slices_clientlib_core.util.force_ip_version import get_httpx_transport_for_ip_version
from slices_clientlib_core.util.logging_httpx_client import enable_httpx_client_logging
from slices_clientlib_core.util.type_id import is_project_id

from .const import (
    BASE_HTTP_TIMEOUT_S,
    CONFIG_DIR,
    DEFAULT_SLICES_CENTRAL_CONFIG_URL,
    SETTINGS_FILENAME,
    SLICES_CENTRAL_CONFIG_CACHE_FILENAME,
    SLICES_CENTRAL_CONFIG_CACHE_TIMEOUT_MINUTES,
)
from .exceptions import ConfigurationError, from_httpx_exception

logger = logging.getLogger(__name__)

config_dir_location = Path.home() / CONFIG_DIR
cache_dir_location = config_dir_location / "cache"
settings_file_location = config_dir_location / SETTINGS_FILENAME
slices_central_config_cache_file_location = cache_dir_location / SLICES_CENTRAL_CONFIG_CACHE_FILENAME


def _get_http_user_agent():
    """Get HTTP User Agent."""
    return f"slices-clientlib-core/{importlib_metadata.version('slices_clientlib_core')}"


DEFAULT_HTTP_USER_AGENT = _get_http_user_agent()


def _remove_legacy_cache_files():
    legacy_cache_files = [
        config_dir_location / "cache_slices-central-config.json",
        config_dir_location / "cache_slices-bi-config.json",
        config_dir_location / "cache_slice-bi-config.json",
    ]
    for file in legacy_cache_files:
        if file.exists():
            file.unlink()


def init_cache():
    """Create cache dir.

    Also remove legacy cache files.
    """
    config_dir_location.mkdir(exist_ok=True)
    cache_dir_location.mkdir(exist_ok=True)

    _remove_legacy_cache_files()


def clear_cache():
    """Clear cache."""
    # Remove files in cache dir
    for file in cache_dir_location.iterdir():
        # extra safety: only delete files ending with .cache  (old)
        #               and files starting with cache_ (new)
        if file.name.endswith(".cache") or file.name.startswith("cache_"):
            file.unlink()

    _remove_legacy_cache_files()


@cache
def _load_settings() -> TOMLDocument:
    config_dir_location.mkdir(exist_ok=True)

    try:
        if settings_file_location.exists():
            settings_file = TOMLFile(settings_file_location)
            return settings_file.read()
    except TOMLKitError as e:
        raise ConfigurationError(f"Could not read settings file {settings_file_location}") from e

    return TOMLDocument()


def _save_settings(config: TOMLDocument):
    config_dir_location.mkdir(exist_ok=True)
    settings_file = TOMLFile(settings_file_location)

    settings_file.write(config)


def set_config_string(name: str, value: str | None, section: str | None = None):
    """Set config value."""

    settings = _load_settings()

    if section:
        if section not in settings:
            section_table = table()
            settings.add(section, section_table)
        else:
            section_table = settings[section]
            if not isinstance(section_table, Table):
                raise ValueError(f"Expected '{section}' to be a table.")

        if value:
            section_table[name] = value
        elif name in section_table:
            del section_table[name]
    else:
        settings[name] = value

    _save_settings(settings)


def get_config_string(name: str, default=None, section: str | None = None) -> str | None:
    """Get config value."""

    settings = _load_settings()

    if section:
        if section in settings:
            section_item = settings[section]
            if not isinstance(section_item, Table):
                raise ValueError(f"Expected {section} to be a table.")
            return section_item.get(name, default)
        return default
    return str(settings.get(name, default))


class ApiVersion(Struct):
    """Information about a specific version of the API."""

    url: str
    version: str


class ApiEntry(Struct):
    """Information needed to connect to API."""

    base_url: str
    audience: str
    tags: list[str] = []
    versions: list[ApiVersion] = []


class SlicesCentralHubApiClientsConfig(Struct):
    """Nested configuration model used to create slices central hub api client objects."""

    project_authority: ApiEntry
    user_authority: ApiEntry
    experiment_authority: ApiEntry
    pubkey_authority: ApiEntry


class SlicesCentralApiClientsConfig(Struct):
    """Configuration model used to create slices central hub api client objects."""

    central_hub_services: SlicesCentralHubApiClientsConfig


T = TypeVar("T")


def load_config_from_url_or_cache(
    config_cache_file_location: Path,
    config_url: str,
    cache_timeout_minutes: int,
    config_type: type[T],
    client: Callable[[], httpx.Client] | httpx.Client,
    ignore_cache: bool = False,
) -> T:
    """Load configuration from a URL or from a local cache."""
    init_cache()

    if not ignore_cache:
        try:
            if config_cache_file_location.exists():
                modify_time = datetime.fromtimestamp(config_cache_file_location.stat().st_mtime, tz=UTC)
                if modify_time > datetime.now(UTC) - timedelta(minutes=cache_timeout_minutes):
                    with config_cache_file_location.open() as f:
                        return msgspec.json.decode(f.read(), type=config_type)
        except:  # noqa E722
            # Ignore failures reading from cache. We fall back to using the URL.
            logger.exception("Could not read config from cache")
            warnings.warn(
                f"Problem reading config cache from {config_cache_file_location}. Reverting to online version",
                UserWarning,
                stacklevel=3,
            )

    the_client = client if not isinstance(client, Callable) else client()
    response = the_client.get(config_url, headers={"Accept": "application/json"})
    try:
        response.raise_for_status()
        fresh_data = response.content
        try:
            init_cache()
            with config_cache_file_location.open("wb") as f:
                f.write(fresh_data)
        except:  # noqa E722
            # Ignore failures writing to cache.
            warnings.warn(f"Problem writing to config cache at {config_cache_file_location}", UserWarning, stacklevel=3)
        return msgspec.json.decode(fresh_data, type=config_type)
    except HTTPError as err:
        # fallback to old cached version
        if config_cache_file_location.exists():
            with config_cache_file_location.open() as f:
                old_cached_data = f.read()
                warnings.warn(
                    f"Problem fetching online version of config from {config_url}. Reverting to cached version.",
                    UserWarning,
                    stacklevel=3,
                )
                return msgspec.json.decode(old_cached_data, type=config_type)
        raise from_httpx_exception(err) from err


@cache
def load_default_central_api_clients_config(
    client: Callable[[], httpx.Client] | httpx.Client,
    *,
    ignore_cache: bool = False,
) -> SlicesCentralApiClientsConfig:
    """Load default API clients configuration for central slices services.

    This is loaded from the central URL, or from a locally cached copy.
    """
    return load_config_from_url_or_cache(
        slices_central_config_cache_file_location,
        DEFAULT_SLICES_CENTRAL_CONFIG_URL,
        SLICES_CENTRAL_CONFIG_CACHE_TIMEOUT_MINUTES,
        SlicesCentralApiClientsConfig,
        client,
        ignore_cache,
    )


class ConfigBrokenError(Exception):
    """Problem loading config."""


def load_cli_central_api_clients_config(api_config_cli_option: Path | None = None) -> SlicesCentralApiClientsConfig:
    """Load central config taking CLI option --api-config into account."""

    api_config_env_var = os.environ.get("SLICES_CUSTOM_CONFIG")
    if not api_config_cli_option and api_config_env_var:
        api_config_cli_option = Path(api_config_env_var)
        if not api_config_cli_option.is_file():
            raise ConfigurationError(f"Custom config file '{api_config_cli_option}' does not exist")

    if not api_config_cli_option:

        def _no_auth_client_maker():
            return enable_httpx_client_logging(
                httpx.Client(
                    headers={"User-Agent": DEFAULT_HTTP_USER_AGENT, "Accept": "application/json"},
                    follow_redirects=True,
                    max_redirects=5,
                    timeout=httpx.Timeout(timeout=BASE_HTTP_TIMEOUT_S),
                    transport=get_httpx_transport_for_ip_version(),
                )
            )

        return load_default_central_api_clients_config(_no_auth_client_maker)

    try:
        with api_config_cli_option.open() as f:
            config_str = f.read()
        config_dict = json.loads(config_str)
        if config_dict.get("pubkey") or config_dict.get("bi"):
            raise ConfigBrokenError("You are using an old version of the API config. Please upgrade.")

        if config_dict.get("central_hub_services"):
            return msgspec.json.decode(config_str, type=SlicesCentralApiClientsConfig)
        raise ConfigBrokenError(f"Your config file ('{api_config_cli_option}') does not contain a valid config")
    except msgspec.ValidationError as err:
        raise ConfigBrokenError("Something is wrong with your api configuration") from err


class ProjectBrokenError(Exception):
    """Problem loading config."""


def load_current_project_id() -> str | None:
    """Return the current project."""

    if project_id := os.environ.get("SLICES_PROJECT_ID"):
        if not is_project_id(project_id):
            raise ProjectBrokenError(
                f"Invalid project id '{project_id}' passed via environment variable 'SLICES_PROJECT_ID'"
            )
        return project_id

    project_id = get_config_string("default", section="project")

    if project_id and not is_project_id(project_id):
        raise ProjectBrokenError(f"Invalid project id '{project_id}'")

    return project_id
