#!/usr/bin/env python3

# Libervia XMPP Client
# Copyright (C) 2009-2025 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""Configuration related useful methods"""

from collections import defaultdict
from pathlib import Path
from socket import getfqdn
from typing import Annotated, Literal, TypeVar, cast

from pydantic import (
    AfterValidator,
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    create_model,
    field_validator,
)
from pydantic.types import PathType
from pydantic_settings import (
    BaseSettings,
    PydanticBaseSettingsSource,
    SettingsConfigDict,
    TomlConfigSettingsSource,
)

from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C, xdg_data_home
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.tools.common.files_utils import get_downloads_dir

log = getLogger(__name__)

expanduser_validator = AfterValidator(lambda p: p.expanduser().resolve())

cached_config: "Settings|None" = None
DynamicSettingsRegister = dict[str, "type[DynamicSettingsBase]"]
DynamicSettings: dict[str, DynamicSettingsRegister | type["DynamicSettingsBase"]] = (
    defaultdict(DynamicSettingsRegister)
)


def autocreatedir(p: Path) -> Path:
    """If directory doesn't exists, create it.

    Will also create parents, permission are set for user-only access.
    """
    p.mkdir(mode=0o700, parents=True, exist_ok=True)
    return p


autocreatedir_validator = AfterValidator(autocreatedir)


def _validate_executable_file(path: Path) -> Path:
    """Validate that a file is executable."""
    stat_result = path.stat()
    if not (stat_result.st_mode & 0o100):
        raise ValueError(f"File {path} is not executable.")
    return path


FilePath = Annotated[Path, expanduser_validator, PathType("file")]
ExecutableFilePath = Annotated[
    Path,
    expanduser_validator,
    PathType("file"),
    AfterValidator(_validate_executable_file),
]
DirectoryPathAuto = Annotated[
    Path, expanduser_validator, autocreatedir_validator, PathType("dir")
]
DirectoryPath = Annotated[Path, expanduser_validator, PathType("dir")]


class ConfigOptions(BaseModel):
    """Configuration options."""

    # We need to validate default to be sure that autocreatedir_validator is run.
    model_config = ConfigDict(extra="forbid", validate_default=True)


class DynamicSettingsBase(ConfigOptions):
    """Settings which are loaded at runtime."""

    _key: str = ""
    _sub_key: str | None = None

    @classmethod
    def _init_subclass(cls, /, key: str, plugin_info: dict | None = None) -> None:
        """Initialize the sublass, and register it in DynamicSettings.

        @param key: name of the field which will be used to store those settings.
        @param info: ``PLUGIN_INFO`` data of the plugin adding settings.
            Use None when the data is from a frontend and not a plugin.
        """
        cls._key = key
        if plugin_info is not None:
            sub_key = plugin_info.get(C.PI_CONFIG_KEY) or plugin_info[C.PI_IMPORT_NAME]
            assert sub_key
            # FIXME: We transform key here, but in the future plugins key name must be
            #   normalized on plugin import.
            sub_key = sub_key.replace("-", "_").lower()
            cls._sub_key = sub_key
            register = DynamicSettings[key]
            assert isinstance(register, dict)
            if sub_key in register:
                raise exceptions.ConflictError(
                    f"There is already a plugin registered for {key!r}.{sub_key!r}."
                )
            register[sub_key] = cls
        else:
            cls._sub_key = None
            DynamicSettings[key] = cls


class PluginSettings(DynamicSettingsBase):
    """Configuration options for plugins."""

    def __init_subclass__(cls, /, plugin_info: dict, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        super()._init_subclass(key="plugins", plugin_info=plugin_info)


class ComponentSettings(DynamicSettingsBase):
    """Configuration options for components."""

    def __init_subclass__(cls, /, plugin_info: dict, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        super()._init_subclass(key="components", plugin_info=plugin_info)


class FrontendSettings(DynamicSettingsBase):

    def __init_subclass__(cls, /, key: str, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        super()._init_subclass(key=key)


D = TypeVar("D", bound=DynamicSettingsBase)


class PbBridgePbSettings(ConfigOptions):
    type: Literal["pb"] = "pb"
    connection_type: Literal["unix_socket", "socket"] = Field(
        default="unix_socket", description="type of connection to use."
    )
    port: int = Field(
        default=8789, description='TCP port to use when ``connection_type`` is "socket"'
    )
    socket_dir: DirectoryPathAuto = Field(
        default=C.TEMP_DIR,
        description=(
            "Directory where the UNIX socket file will be created when using unix_socket"
            " connection type."
        ),
    )
    host: str = Field(
        default="localhost", description="Host to connect to when using socket connection"
    )


class DBusBridgeSettings(ConfigOptions):
    type: Literal["dbus"] = "dbus"
    int_prefix: str = Field(
        default="org.libervia.Libervia", description="Interface prefix"
    )


BridgeSettings = Annotated[
    DBusBridgeSettings | PbBridgePbSettings, Field(discriminator="type")
]

B = TypeVar("B", bound=BridgeSettings)


class WebSettings(ConfigOptions):
    # FIXME: to be moved to web frontend.
    model_config = ConfigDict(extra="allow")
    sites_path_public: dict[str, str] = Field(
        default_factory=dict, description="Dictionary of public site paths"
    )
    sites_path_private_dict: dict[str, str] = Field(
        default_factory=dict, description="Dictionary of private site paths"
    )


class EmailSettings(ConfigOptions):

    server: str = Field(default="localhost", description="Email server address")
    port: int = Field(default=25, description="Email server port")
    username: str | None = Field(default=None, description="Email server username")
    password: str | None = Field(default=None, description="Email server password")
    starttls: bool = Field(default=False, description="Use STARTTLS for email connection")
    auth: bool = Field(
        default=False, description="Enable authentication for email connection"
    )
    from_: str | None = Field(
        default="NOREPLY@example.net",
        description="Sender address for emails",
        alias="from",
    )
    sender_domain: str | None = Field(default=None, description="Email sender domain")


class TLSSettings(ConfigOptions):
    certificate: FilePath | None = Field(
        default=None, description="Path to TLS certificate file"
    )
    private_key: FilePath | None = Field(
        default=None, description="Path to TLS private key file"
    )
    chain: FilePath | None = Field(
        default=None,
        description="Path to TLS certificate chain file (intermediate certificates)",
    )


class CommonSettings(ConfigOptions):
    # "media_dir": "/usr/share/" + Const.APP_NAME_FILE + "/media",
    media_dir: DirectoryPath | None = Field(
        default=None, description="Directory for media files"
    )
    local_dir: DirectoryPathAuto = Field(
        default=xdg_data_home / C.APP_NAME_FILE,
        description="Local directory for Libervia data",
    )
    downloads_dir: DirectoryPathAuto = Field(
        default_factory=get_downloads_dir,
        description="Directory where files can be downloaded by default.",
    )
    log_dir: DirectoryPath = Field(
        default_factory=lambda data: data["local_dir"],
        description="Directory where log files will be written. Default to local_dir.",
    )
    log_level: str | None = Field(
        default=None, description="Logging level (debug, info, warning, error)"
    )
    log_fmt: str | None = Field(default=None, description="Log message format")
    bridges: list[BridgeSettings] = Field(
        default_factory=list,
        description=(
            "Bridge is the name of the IPC used to communicate between backend and"
            " frontends. Leave empty to let Libervia determine the best one. If you"
            " indicate several bridges, they will run in parallel."
        ),
    )
    pid_dir: DirectoryPathAuto = Field(
        default=C.TEMP_DIR,
        description=(
            "Directory where PID files will be stored. Default is a subdirectory of the"
            " system's temporary directory."
        ),
    )
    xmpp_domain: str = Field(
        default=getfqdn(),
        description=(
            "XMPP domain of the main server. Local hostname will be used by default."
        ),
    )
    new_account_domain: str | None = Field(
        default_factory=lambda data: data["xmpp_domain"],
        description=(
            "Domain to use when creating new account. Default to ``xmpp_domain``."
        ),
    )
    public_url: str | None = Field(default=None, description="Public URL for the server")
    # FIXME: we don't use JIDType here because config is also parsed by frontend, and
    #   JIDType uses Twisted's JID class.
    pubsub_service: str | None = Field(
        default=None,
        description=(
            "Main pubsub service to use. If not set, it will be discovered automatically."
        ),
    )
    admins_list: list[str] = Field(
        default_factory=list, description="List of profile names of admin users"
    )
    email_admins_list: list[str] = Field(
        default_factory=list, description="List of admin email addresses"
    )
    mutli_users: bool = Field(default=False, description="Enable multi-user support")
    pubsub_cache_strategy: Literal["no_cache"] | None = Field(
        default=None, description="PubSub cache strategy"
    )
    hosts_dict: dict[str, dict] = Field(
        default_factory=dict, description="Dictionary of host configurations"
    )
    ack_timeout: int = Field(
        default=35,
        description=(
            "Acknowledgement timeout in seconds. If the server doesn't acknowledge a"
            " stanza within this time, the connection will be aborted."
        ),
    )
    force_autodisconnect: bool = Field(
        default=False, description="Force automatic disconnection"
    )
    # FIXME: Seems not used anymore, delete?
    use_local_shared_tmp: bool = Field(
        default=False,
        description=(
            "If set, a temporary dir in cache will be used to share files between backend"
            " and frontends, useful when they are separated (e.g. when using containers)."
            " If unset, a temporary dir will be automatically created in os-relevant"
            " location."
        ),
    )
    allow_external_search: bool = Field(
        default=False, description="Allow external search functionality"
    )
    image_max: tuple[int, int] = Field(
        default=(1200, 720), description="Maximum dimensions for images (width, height)"
    )
    init_wait_for_service: str | None = Field(
        default=None,
        description=(
            "Wait for a network service to become available before starting the backend."
            " Format is 'host:port[:timeout][:service_name]' where host can be an"
            " IPv4/IPv6 address or hostname, port is the service port number, timeout is"
            " optional (default 60 seconds) and service_name is optional (defaults to"
            " 'service'). Example: 'localhost:5222:30:XmppServer'"
        ),
    )
    init_script: FilePath | None = Field(
        default=None,
        description="Path to an executable script to be run during backend startup.",
    )
    group_chat_search_default_jid: str = Field(
        default="api@search.jabber.network",
        description="JID of the entity used for group chat searches.",
    )

    @field_validator("bridges", mode="after")
    @classmethod
    def set_bridges_auto(cls, bridges: list[BridgeSettings]) -> list[BridgeSettings]:
        if not bridges:
            # FIXME: We default to DBus for now.
            bridges = [DBusBridgeSettings()]
        return bridges

    def get_bridge_conf(self, bridge_type: type[B]) -> B:
        """Get bridge configuration by type.

        @param type: The type of bridge to find (e.g. ``pb``, ``dbus``)
        @return: The configuration for the specified bridge.
        @raise exceptions.InternalError: If no bridge configuration is found for the given
            type.
        """
        for bridge_conf in self.bridges:
            if isinstance(bridge_conf, bridge_type):
                return bridge_conf
        # We raise internal error because there should not be any request for conf if the
        # bridge is not in "bridges" and thus instanciating.
        raise exceptions.InternalError(
            f"No bridge configuration found for type {type!r}."
        )


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        extra="allow",
        env_prefix="LIBERVIA_",
        env_nested_delimiter="__",
        toml_file=C.CONFIG_FILES,
    )

    common: CommonSettings = Field(default_factory=CommonSettings)
    email: EmailSettings = Field(default_factory=EmailSettings)
    tls: TLSSettings = Field(default_factory=TLSSettings)
    web: WebSettings = Field(default_factory=WebSettings)
    plugins: dict = Field(default_factory=dict, description="Plugin specific settings.")
    components: dict = Field(
        default_factory=dict, description="Component specific settings."
    )

    def get_settings(self, dyn_settings_cls: type[D]) -> D:
        """Get dynamic settings instance.

        @param dyn_settings_cls: The dynamtic settings class to retrieve.
        @return: The instance of the specified settings.
        @raise exceptions.InternalError: If no settings are found. This should not happen
            as the dynamtic classes are automatically registered.
        """
        try:
            settings = getattr(self, dyn_settings_cls._key)
            if dyn_settings_cls._sub_key is None:
                return settings
            else:
                return getattr(settings, dyn_settings_cls._sub_key)
        except AttributeError:
            raise exceptions.InternalError(
                f"No settings found for {dyn_settings_cls._sub_key!r}, they should"
                " be imported dynamically."
            )

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        return (
            init_settings,
            env_settings,
            dotenv_settings,
            file_secret_settings,
            TomlConfigSettingsSource(
                settings_cls,
                # FIXME: deep_merge is not yet available, will be for pydantic-settings
                #   2.13
                # deep_merge=True
            ),
        )


def parse_main_conf(log_filenames: bool = False, ignore_cache=False) -> Settings:
    """Look for main .toml configuration files, and parse them.

    @param log_filenames: if True, log filenames of read config files.
    @param ignore_cache: if True, settings will be reparsed even if they are already in
        cache. Notable useful when dynamic setting (e.g.; from plugins) have been added.
    """
    global cached_config

    if log_filenames:
        existing_conf_file = []
        for path_str in C.CONFIG_FILES:
            path = Path(path_str)
            if path.is_file() and path.exists():
                existing_conf_file.append(str(path))

        if existing_conf_file:
            log.info(
                _("Configuration will be read from: {filenames}.").format(
                    filenames=", ".join(existing_conf_file)
                )
            )
        else:
            log.info(_("No configuration file found."))

    if ignore_cache or cached_config is None:
        dynamic_settings_kw = {}
        for key, settings in DynamicSettings.items():
            if isinstance(settings, dict):
                NewSettingsModel = create_model(
                    f"{key.title()}Settings",
                    **cast(
                        dict,
                        {
                            sub_key: (settings_type, Field(default_factory=settings_type))
                            for sub_key, settings_type in settings.items()
                        },
                    ),
                    __config__=ConfigDict(extra="forbid"),
                )
                dynamic_settings_kw[key] = (
                    NewSettingsModel,
                    Field(default_factory=NewSettingsModel),
                )
            else:
                dynamic_settings_kw[key] = (
                    settings,
                    Field(default_factory=settings),
                )

        LiberviaSettings = create_model(
            "LiberviaSettings", __base__=Settings, **dynamic_settings_kw
        )

        try:
            cached_config = LiberviaSettings()
        except ValidationError as e:
            log.error(f"Settings are invalid, please fix your configuration: {e}")
            raise exceptions.ConfigError
        except:
            log.exception("Can't parse settings.")
            raise exceptions.InternalError

    return cached_config
