from __future__ import annotations

import copy
import functools
import os
import re
import shutil
import socket
import sys
from contextlib import contextmanager
from io import SEEK_END, StringIO
from pathlib import Path
from typing import Optional, Union, ContextManager, Tuple, MutableMapping, IO

import portalocker
import schema
from ruamel.yaml import YAML

from clarabot_nano.common.config.constants import CommonConfigConstants
from clarabot_nano.common.config.utility import (
    config_get_pid_dir,
    config_get_solr_data_dir,
    config_get_solr_logs_dir,
    config_get_tika_logs_dir,
)
from clarabot_nano.core.config.schema import (
    DEFAULT_CONFIG,
    LOCAL_CONFIG_SCHEMA,
    PARSED_CONFIG_SCHEMA,
    REMOTE_CONFIG_SCHEMA,
)
from clarabot_nano.core.config.utility import get_secrets_fingerprint, create_new_secrets
from clarabot_nano.picoture import PICOTURE_JAR
from clarabot_nano.resource import get_resource_path
from clarabot_nano.utility.dirs import Directories
from clarabot_nano.utility.file import safe_replace_file
from clarabot_nano.utility.java import get_java_version_output, parse_java_major_version
from clarabot_nano.utility.path.common import normalize_validate_resolve_absolute_path, normalize_validate_absolute_path
from clarabot_nano.utility.path.java import java_compatible_absolute_path
from clarabot_nano.utility.path.resolve import resolve_path
from clarabot_nano.utility.path.validate import ValidationError, denormalize_absolute_path
from clarabot_nano.utility.serialize import msgpack_dumps

DROP_STATE_KEY = object()
TIKA_SERVER_JAR_RE = re.compile(r"^tika-server(?:-standard)?-([\d.]+?)\.jar$")


class ConfigLockedByRunningInstance(Exception):
    pass


class NanoBundleRootRequired(Exception):
    pass


class Config(object):
    """
    The main config parsing and setup utility. Since an instance of Nano is tightly tied to a specific config
    and an instance shall only run in a single copy, this is a suitable place to implement singleton-application.
    """

    @classmethod
    @contextmanager
    def lock_and_load(
        cls,
        local_config: Optional[Union[Path, str]] = None,
        bootstrap_config_dir: Optional[str] = None,
        bootstrap_data_dir: Optional[str] = None,
    ) -> ContextManager[Config]:
        """
        Lock the local configuration and then load it appropriately. The locking is necessary to avoid having
        multiple process instances trying to work on a single configuration.
        """
        try:
            if local_config:
                # When running for explicit config, no directories will be selected automatically for bootstrapping.
                local_config = Path(normalize_validate_absolute_path(local_config))
                if bootstrap_config_dir is not None:
                    bootstrap_config_dir = Path(
                        denormalize_absolute_path(normalize_validate_absolute_path(bootstrap_config_dir))
                    )
                if bootstrap_data_dir is not None:
                    bootstrap_data_dir = Path(
                        denormalize_absolute_path(normalize_validate_absolute_path(bootstrap_data_dir))
                    )
            else:
                # Automatically bootstrapping a config makes an easy and adequate default setup under the user's HOME.
                bootstrap_config_dir = Directories.instance().get_user_app_config_dir(
                    *CommonConfigConstants.USER_DATA_PATH_PARTS
                )
                bootstrap_data_dir = Directories.instance().get_user_app_data_dir(
                    *CommonConfigConstants.USER_DATA_PATH_PARTS
                )
                # By default the local config will reside in the config folder specified in itself.
                local_config = bootstrap_config_dir / "local.yml"
            # Check and prepare for bootstrap, or bail out.
            if not local_config.is_file():
                if not bootstrap_config_dir or not bootstrap_data_dir:
                    raise Exception(
                        "Both bootstrap_config_dir and bootstrap_data_dir must be specified for a new instance"
                    )
                local_config.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
            # Load if file is not empty, bootstrap with default configs on the given paths otherwise.
            local_config_lock = portalocker.Lock(local_config, mode="a+", encoding="utf-8", fail_when_locked=True)
            try:
                try:
                    f = local_config_lock.acquire()
                except (OSError, ValidationError) as e:
                    if hasattr(e, "filename") and Path(e.filename) == local_config:
                        raise Exception("Provided path to local configuration must be a readable file") from e
                    raise e
                yield Config._load_or_bootstrap(
                    f, denormalize_absolute_path(local_config), bootstrap_config_dir, bootstrap_data_dir
                )
            finally:
                local_config_lock.release()
        except portalocker.AlreadyLocked:
            # TODO: signal other process to focus UI for user?
            raise ConfigLockedByRunningInstance()

    @classmethod
    def _load_or_bootstrap(
        cls,
        local_config_handle: IO,
        local_config_source: str,
        bootstrap_config_dir: Optional[Path],
        bootstrap_data_dir: Optional[Path],
    ) -> Config:
        """
        Automatically bootstrapping a config makes an easy and adequate default setup under the user's HOME.
        """
        if local_config_handle.seek(0, SEEK_END) > 0:
            return cls._load(local_config_handle, local_config_source)
        if not bootstrap_data_dir or not bootstrap_config_dir:  # pragma: no cover; covered by pre-checks
            raise Exception("Both bootstrap_config_dir and bootstrap_data_dir must be specified for a new instance")
        solr_dir = bootstrap_data_dir / "solr"
        tika_dir = bootstrap_data_dir / "tika"
        logs_dir = bootstrap_data_dir / "logs"
        pid_dir = bootstrap_data_dir / "pid"
        try:
            cls._create_default_config(
                local_config_handle, bootstrap_config_dir, bootstrap_data_dir, solr_dir, tika_dir, logs_dir, pid_dir
            )
        except OSError as e:
            raise Exception("Could not create default configuration") from e
        # Load final config from scratch.
        return cls._load(local_config_handle, local_config_source)

    @classmethod
    def _create_default_config(cls, local_config_handle, config_dir, data_dir, solr_dir, tika_dir, logs_dir, pid_dir):
        # create directories
        config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
        solr_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
        logs_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
        # create "remote" config file from template
        remote_config = cls._create_remote_config_from_template()
        with open(config_dir / "remote.yml", "w", encoding="utf-8") as f:
            YAML().dump(remote_config, f)
        # create "local" config file from template
        with open(get_resource_path("template", "local.yml"), "r", encoding="utf-8") as f:
            local_config = YAML().load(f)
        for log_handler_config in local_config["log_config"]["handlers"].values():
            filename = log_handler_config.get("filename")
            if not filename:
                continue
            log_handler_config["filename"] = str(logs_dir / filename)
        local_config["config_dir"] = str(config_dir)
        local_config["data_dir"] = str(data_dir)
        local_config["solr_dir"] = str(solr_dir)
        local_config["tika_dir"] = str(tika_dir)
        local_config["pid_dir"] = str(pid_dir)
        # write local config
        local_config_handle.seek(0)
        local_config_handle.truncate()
        YAML().dump(local_config, local_config_handle)

    @classmethod
    def _create_remote_config_from_template(cls):
        with open(get_resource_path("template", "remote.yml"), "r", encoding="utf-8") as f:
            remote_config = YAML().load(f)
        remote_config["name"] = f"{socket.gethostname()}"
        # remote_config["name"] = f"{socket.gethostname()}-{os.urandom(4).hex()}"
        return remote_config

    @classmethod
    def _load(cls, local_config_handle: IO, local_config_source: str) -> Config:
        # TODO: migrate?
        config = copy.deepcopy(DEFAULT_CONFIG)
        local_config_handle.seek(0)
        # First and only load of local config.
        config["local"].update(YAML(typ="safe").load(local_config_handle))
        config["local"]["source"] = local_config_source
        try:
            config["local"] = LOCAL_CONFIG_SCHEMA.validate(config["local"])
        except schema.SchemaError as e:
            raise Exception("Invalid local configuration") from e
        # Normalize primary paths from local config to be ready in final form.
        config["local"]["config_dir"] = str(normalize_validate_resolve_absolute_path(config["local"]["config_dir"]))
        config["local"]["data_dir"] = str(normalize_validate_resolve_absolute_path(config["local"]["data_dir"]))
        # First load of remote config. (can be reloaded later)
        config_dir = Path(config["local"]["config_dir"])
        with open(config_dir / "remote.yml", "r", encoding="utf-8") as f:
            config["remote"].update(YAML(typ="safe").load(f))
        try:
            config = PARSED_CONFIG_SCHEMA.validate(config)
        except schema.SchemaError as e:
            raise Exception(f"Invalid configuration read from {config_dir / 'remote.yml'}") from e
        return cls(config)

    def __init__(self, config):
        self._config = config
        self._state = dict()
        # usability checks on complex *LOCAL* values
        self.get_pid_dir()
        self.get_solr_data_dir()
        self.get_solr_logs_dir()
        self.get_tika_logs_dir()
        # check and maybe fix auto-managed values
        self._check_fix_remote_secrets(self.remote["secrets"])

    def _check_fix_remote_secrets(self, secrets):
        if not secrets or secrets[-1] != get_secrets_fingerprint(secrets):
            with self.update_remote_config() as new_remote:
                new_remote["secrets"] = create_new_secrets()
            self.reload_remote_config()

    def update_state(self, s):
        for k, v in s.items():
            if v is DROP_STATE_KEY:
                self._state.pop(k, None)
            else:
                self._state[k] = v

    def serialize(self):
        d = {"state": self._state}
        d.update(self._config)
        return msgpack_dumps(d)

    @property
    def local(self):
        return self._config["local"]

    @property
    def remote(self):
        return self._config["remote"]

    @property
    def state(self):
        return self._state

    @property
    def log_config(self):
        return self._config["local"]["log_config"]

    @property
    def localhost_network_security(self) -> str:
        return self._config["local"]["localhost_network_security"]

    @functools.cache
    def get_java_home_executable(self) -> Tuple[str, str]:
        java_executable = "java.exe" if sys.platform.startswith("win") else "java"
        java_dir = self._config["local"]["java_dir"]
        if java_dir:  # prioritize explicit configuration
            explicit = True
            try:
                java_dir = normalize_validate_resolve_absolute_path(java_dir)
            except (OSError, ValidationError):
                raise Exception(f"Configured path for Java is invalid (local.java_dir): {java_dir}")
        else:  # select the first path that seems valid (bundled, JAVA_HOME)
            explicit = False
            try:
                nano_root_dir = self.get_nano_root()
            except NanoBundleRootRequired:
                nano_root_dir = None
            if nano_root_dir and (java_bundle_dir := resolve_path(nano_root_dir.joinpath("deps", "jre"))).is_dir():
                java_dir = java_bundle_dir
            else:
                java_dir = os.environ.get("JAVA_HOME", "")
                if java_dir:
                    try:
                        java_dir = normalize_validate_resolve_absolute_path(java_dir)
                    except (OSError, ValidationError):
                        raise Exception(f"Path in JAVA_HOME environment variable is invalid: {java_dir}")
        if java_dir:  # try to validate the java home if any
            for path in [("bin", "amd64", java_executable), ("bin", java_executable)]:
                java_exe_path = Path(java_dir, *path)
                if java_exe_path.is_file():
                    return java_compatible_absolute_path(java_dir), java_compatible_absolute_path(java_exe_path)
            if explicit:  # report error if the configuration is explicit
                raise Exception(f"Java is not found in configured path (local.java_dir): {java_dir}")
        if not shutil.which(java_executable):  # look for executable in PATH lastly
            raise Exception("Java is not found in PATH")
        java_dir = ""
        return java_dir, java_executable

    @functools.cache
    def get_solr_install_dir(self) -> Path:
        solr_bundle_dir = self._config["local"]["solr_bundle_dir"]
        if solr_bundle_dir:
            try:
                solr_bundle_dir = normalize_validate_resolve_absolute_path(solr_bundle_dir)
            except (OSError, ValidationError):
                raise Exception(f"Configured path for Solr is invalid (local.solr_bundle_dir): {solr_bundle_dir}")
        else:
            try:
                solr_bundle_dir = self.get_nano_root().joinpath("deps", "solr")
                solr_bundle_dir = resolve_path(solr_bundle_dir, strict=True)
            except NanoBundleRootRequired:
                raise Exception("Without Nano bundle root (CBNANO_ROOT), the local.solr_bundle_dir config must be set")
            except (OSError, ValidationError):
                raise Exception(f"Solr is not found in bundled path: {solr_bundle_dir}")
        if not (solr_bundle_dir / "server/start.jar").is_file():
            raise Exception(f"No Solr start jar is found in directory: {solr_bundle_dir / 'server'}")
        return solr_bundle_dir

    @functools.cache
    def get_tika_server_jar(self) -> str:
        tika_bundle_dir = self._config["local"]["tika_bundle_dir"]
        if tika_bundle_dir:
            try:
                tika_bundle_dir = normalize_validate_resolve_absolute_path(tika_bundle_dir)
            except (OSError, ValidationError):
                raise Exception(f"Configured path for Tika is invalid (local.tika_bundle_dir): {tika_bundle_dir}")
        else:
            try:
                tika_bundle_dir = self.get_nano_root().joinpath("deps", "tika")
                tika_bundle_dir = resolve_path(tika_bundle_dir, strict=True)
            except NanoBundleRootRequired:
                raise Exception("Without Nano bundle root (CBNANO_ROOT), the local.tika_bundle_dir config must be set")
            except (OSError, ValidationError):
                raise Exception(f"Tika is not found in bundled path: {tika_bundle_dir}")
        # select most recent version
        jars = []
        for item in tika_bundle_dir.iterdir():
            if item.is_file():
                m = TIKA_SERVER_JAR_RE.match(item.name)
                if m:
                    version = tuple(int(v) for v in m.group(1).split(".") if v)
                    if len(version) == 2:
                        version += (0,)
                    if len(version) == 3:
                        jars.append((item, version))
        if not jars:
            raise Exception(f"No Tika server jar found in directory: {tika_bundle_dir}")
        tika_server_jar = max(jars, key=lambda e: e[1])[0]
        return str(tika_server_jar)

    @functools.cache
    def get_picoture_jar(self) -> str:
        # NOTE: This could be configurable, but it's bundled now.
        try:
            return str(normalize_validate_resolve_absolute_path(PICOTURE_JAR))
        except (OSError, ValidationError):
            raise Exception(f"Picoture is missing from the distribution: {PICOTURE_JAR}")

    def check_dependencies(self):
        _, java_executable = self.get_java_home_executable()
        if parse_java_major_version(get_java_version_output(java_executable)) < 11:
            raise Exception("Java 11 is the minimum required version")
        self.get_solr_install_dir()
        self.get_tika_server_jar()
        self.get_picoture_jar()

    @functools.cache
    def get_pid_dir(self) -> Path:
        return config_get_pid_dir(self._config)

    @functools.cache
    def get_solr_data_dir(self) -> Path:
        return config_get_solr_data_dir(self._config)

    @functools.cache
    def get_solr_logs_dir(self) -> Path:
        return config_get_solr_logs_dir(self._config)

    def get_solr_heap_size(self) -> Path:
        return self.local["solr_heap_size"]

    @functools.cache
    def get_tika_logs_dir(self) -> Path:
        return config_get_tika_logs_dir(self._config)

    def get_tika_heap_size(self) -> Path:
        return self.local["tika_heap_size"]

    @functools.cache
    def get_picoture_logs_dir(self) -> Path:
        return Path(self.local["data_dir"], "logs", "picoture")

    def get_picoture_heap_size(self) -> Path:
        return self.local["picoture_heap_size"]

    @functools.cache
    def get_nano_root(self) -> Path:
        """
        The nano's "root" directory is where it is installed and dependencies can be found.
        """
        try:
            return normalize_validate_resolve_absolute_path(os.environ.get("CBNANO_ROOT", ""))
        except Exception as e:
            raise NanoBundleRootRequired("Nano root (CBNANO_ROOT environment variable) is required") from e

    # def __getitem__(self, item):
    #     return self._config[item]

    @contextmanager
    def update_remote_config(self, reset: bool = False) -> ContextManager[MutableMapping]:
        """
        Change the remote.yml config safely: Load & change raw merge updated with default config, validate and then
        save new if everything is fine.
        """
        config_dir = Path(self._config["local"]["config_dir"])
        remote_yml = config_dir / "remote.yml"
        if reset:
            # regenerate raw config from template
            new_remote = self._create_remote_config_from_template()
        else:
            # load raw config for editing
            with open(remote_yml, "r", encoding="utf-8") as f:
                new_remote = YAML().load(f)
            if new_remote is None:
                new_remote = {}
        yield new_remote
        # serialize changed config
        sio = StringIO()
        YAML().dump(new_remote, sio)
        # validate new by loading in safe-mode and merging with default
        config = copy.deepcopy(DEFAULT_CONFIG["remote"])
        sio.seek(0)
        config.update(YAML(typ="safe").load(sio))
        REMOTE_CONFIG_SCHEMA.validate(config)
        # write it to file
        safe_replace_file(remote_yml, sio.getvalue().encode("utf-8"))
        sio.close()

    def reload_remote_config(self):
        config_dir = Path(self._config["local"]["config_dir"])
        config = copy.deepcopy(DEFAULT_CONFIG["remote"])
        config.update(YAML(typ="safe").load(config_dir / "remote.yml"))
        config = REMOTE_CONFIG_SCHEMA.validate(config)
        self._config["remote"] = config
        PARSED_CONFIG_SCHEMA.validate(self._config)
        # this may be recursive, but should never go more than a single depth
        self._check_fix_remote_secrets(self.remote["secrets"])

    def reset_remote_config(self):
        with self.update_remote_config(reset=True) as new_remote:
            new_remote["secrets"] = create_new_secrets()  # would be done anyways by reload_remote_config()
        self.reload_remote_config()

    def check_state_update_valid(self, state_kvs):
        if state_kvs:
            s = self._state
            self._state = copy.deepcopy(self._state)
            try:
                self.update_state(state_kvs)
            finally:
                self._state = s
