# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Config reader for the debusine clients."""

import logging
import os
import re
import sys
import urllib.parse
from collections.abc import MutableMapping
from configparser import ConfigParser
from pathlib import Path
from typing import NamedTuple, NoReturn, Self, TextIO
from urllib.parse import urlparse

from debusine.utils.urls import DEFAULT_REQUEST_TIMEOUT, URLPath, parse_timeout

logger = logging.getLogger(__name__)


class ServerInfo(NamedTuple):
    """Information about a known server."""

    #: Server name to use in configuration
    name: str
    #: User-visible description
    desc: str
    #: Default API URL
    api_url: str
    #: Default Scope
    scope: str
    #: API token
    api_token: str | None = None
    #: Default workspace
    default_workspace: str = "System"
    #: Timeout for HTTP requests (connect, read)
    http_timeout: int | tuple[int, int] = DEFAULT_REQUEST_TIMEOUT

    def lint(self) -> list[str]:
        """
        Run consistency checks on the current draft configuration.

        :returns: a list of messages, or empty if everything looks ordinary
        """
        res: list[str] = []

        api_url_parsed = urllib.parse.urlparse(self.api_url)

        if api_url_parsed.hostname == "localhost":
            expected_scheme = "http"
        else:
            expected_scheme = "https"

        if api_url_parsed.scheme != expected_scheme:
            res.append(
                f"Server url is an [bold]{api_url_parsed.scheme}[/] url,"
                f" should it be [bold]{expected_scheme}[/]?"
            )

        if not self.api_url.endswith("/api") and not self.api_url.endswith(
            "/api/"
        ):
            res.append("API URL does not end in /api or /api/")

        return res

    @classmethod
    def from_config(
        cls, name: str, section: MutableMapping[str, str], path: Path
    ) -> Self:
        """Infer a ServerInfo from an existing configuration."""
        inferred = cls.from_string(name, desc="temporary serverinfo")
        if known := KNOWN_SERVERS.get(name):
            default_workspace = known.default_workspace
        else:
            default_workspace = "System"
        raw_timeout = section.get("http-timeout")
        timeout: int | tuple[int, int] = DEFAULT_REQUEST_TIMEOUT

        if raw_timeout:
            try:
                timeout = parse_timeout(raw_timeout)
            except ValueError:
                logger.warning(
                    "Invalid http-timeout value in configuration: %r. "
                    "Falling back to default (%s).",
                    raw_timeout,
                    DEFAULT_REQUEST_TIMEOUT,
                )

        return cls(
            name=name,
            desc=f"Configured in {path}",
            api_url=section.get("api-url", inferred.api_url),
            scope=section.get("scope", inferred.scope),
            api_token=section.get("token", None),
            default_workspace=section.get(
                "default-workspace", default_workspace
            ),
            http_timeout=timeout,
        )

    @classmethod
    def from_string(cls, name: str, desc: str) -> Self | "ServerInfo":
        """Infer a ServerInfo from a user-provided name."""
        if ":" not in name:
            # plain hostname
            server_url_parsed = urllib.parse.ParseResult(
                scheme="",
                netloc=name,
                path="",
                params="",
                query="",
                fragment="",
            )
            server_name = name
        elif re.match(r"^[^/]*:\d+(?:/|$)", name):
            # hostname:port, or just :port
            server_url_parsed = urllib.parse.ParseResult(
                scheme="",
                netloc=(f"localhost{name}" if name.startswith(":") else name),
                path="",
                params="",
                query="",
                fragment="",
            )
            assert server_url_parsed.hostname
            server_name = server_url_parsed.hostname
        else:
            # URL
            server_url_parsed = urllib.parse.urlparse(name)
            assert server_url_parsed.hostname
            server_name = server_url_parsed.hostname

        # Lookup known names
        assert server_url_parsed.hostname
        if known := KNOWN_SERVERS.get(server_url_parsed.hostname):
            return known
        # Lookup known hostnames
        for info in KNOWN_SERVERS.values():
            info_parsed = urllib.parse.urlparse(info.api_url)
            if info_parsed.hostname == server_url_parsed.hostname:
                return info

        if not server_url_parsed.scheme:
            server_url_parsed = server_url_parsed._replace(scheme="https")

        api_path = URLPath(server_url_parsed.path, is_file=False)
        if not api_path or api_path[-1] != "api":
            api_path += "api/"

        api_url = urllib.parse.urlunparse(
            server_url_parsed._replace(path=str(api_path))
        )

        return cls(
            name=server_name, desc=desc, api_url=api_url, scope="debusine"
        )


KNOWN_SERVERS: dict[str, ServerInfo] = {
    info.name: info
    for info in (
        ServerInfo(
            name="debian",
            desc="debusine.debian.net",
            api_url="https://debusine.debian.net/api/",
            scope="debian",
            default_workspace="developers",
        ),
        ServerInfo(
            name="freexian",
            desc="Freexian (E)LTS",
            api_url="https://debusine.freexian.com/api/",
            scope="freexian",
            default_workspace="sandbox",
        ),
        ServerInfo(
            name="localhost",
            desc="debusine-admin runserver for development",
            api_url="http://localhost:8000/api/",
            scope="debusine",
            default_workspace="Playground",
        ),
    )
}


class ConfigHandler(ConfigParser):
    """
    ConfigHandler for the configuration (.ini file) of the Debusine client.

    If the configuration file is not valid it writes an error message
    to stderr and aborts.
    """

    DEFAULT_CONFIG_FILE_PATH = Path.home() / Path(
        '.config/debusine/client/config.ini'
    )

    def __init__(
        self,
        *,
        server_name: str | None = None,
        config_file_path: str | os.PathLike[str] = DEFAULT_CONFIG_FILE_PATH,
        stdout: TextIO = sys.stdout,
        stderr: TextIO = sys.stderr,
    ) -> None:
        """
        Initialize variables and reads the configuration file.

        :param server_name: look up configuration matching this server name
          or FQDN/scope (or None for the default server from the
          configuration)
        :param config_file_path: location of the configuration file
        """
        super().__init__()

        self._server_name = server_name
        self._config_file_path = Path(config_file_path)

        self._stdout = stdout
        self._stderr = stderr

        if not self.read(self._config_file_path):
            self._fail(f'Cannot read {self._config_file_path} .')

    def server_configuration(self) -> ServerInfo:
        """
        Return the server configuration.

        Uses the server specified in the __init__() or the default server
        in the configuration file.
        """
        if self._server_name is None:
            server_name = self._default_server_name()
        else:
            server_name = self._server_name

        return self._server_configuration(server_name)

    def _fail(self, message: str) -> NoReturn:
        """Write message to self._stderr and aborts with exit code 3."""
        self._stderr.write(message + "\n")
        raise SystemExit(3)

    def _default_server_name(self) -> str:
        """Return default server name or aborts."""
        if 'General' not in self:
            self._fail(
                f'[General] section and default-server key must exist '
                f'in {self._config_file_path} to use the default server. '
                f'Add them or specify the server in the command line.'
            )

        if 'default-server' not in self['General']:
            self._fail(
                f'default-server key must exist in [General] in '
                f'{self._config_file_path} . Add it or specify the server '
                f'in the command line.'
            )

        return self['General']['default-server']

    def _server_configuration(self, server_name: str) -> ServerInfo:
        """Return configuration for server_name or aborts."""
        section_name: str | None
        if "/" in server_name:
            # Look up the section by FQDN and scope.
            server_fqdn, scope_name = server_name.split("/")
            for section_name in self.sections():
                if (
                    section_name.startswith("server:")
                    and (
                        (api_url := self[section_name].get("api-url"))
                        is not None
                    )
                    and urlparse(api_url).hostname == server_fqdn
                    and self[section_name].get("scope") == scope_name
                ):
                    break
            else:
                section_name = None
        else:
            # Look up the section by name.
            section_name = f'server:{server_name}'

        if section_name is None or section_name not in self:
            raise ValueError(
                f"No Debusine client configuration for {server_name!r}; "
                f"run 'debusine setup' to configure it"
            )
        server_configuration = self[section_name]

        self._ensure_server_configuration(server_configuration, section_name)

        return ServerInfo.from_config(
            server_name, server_configuration, self._config_file_path
        )

    def _ensure_server_configuration(
        self,
        server_configuration: MutableMapping[str, str],
        server_section_name: str,
    ) -> None:
        """Check keys for server_section_name. Aborts if there are errors."""
        keys = ('api-url', 'scope', 'token')
        optional_keys = frozenset(("default-workspace", "http-timeout"))

        missing_keys = keys - server_configuration.keys()

        if len(missing_keys) > 0:
            self._fail(
                f'Missing required keys in the section '
                f'[{server_section_name}]: {", ".join(sorted(missing_keys))} '
                f'in {self._config_file_path} .'
            )

        extra_keys = server_configuration.keys() - keys - optional_keys

        if len(extra_keys) > 0:
            self._fail(
                f'Invalid keys in the section '
                f'[{server_section_name}]: {", ".join(sorted(extra_keys))} '
                f'in {self._config_file_path} .'
            )
