import base64
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path

import psutil
from clarabot_nano_common.common.solr.rest import NANO_CORE_NAME_PREFIX, USERNAME, create_solr_auth_header
from clarabot_nano_common.utility.path.java import java_compatible_absolute_path

from clarabot_nano.core.spawn.poll import (
    AbstractSpawnHealthPoller,
    AbstractPolledSpawnWithThread,
    AbstractSpawnHealthHTTPPoller,
)
from clarabot_nano.core.spawn.track import SubprocessTracker
from clarabot_nano.resource import get_resource_path
from clarabot_nano.utility.config.cache import CacheMarkByFiles
from clarabot_nano.utility.network import get_free_ports_for_dynamic_service
from clarabot_nano.utility.subprocess.spawn import platform_fix_spawn_child_kwargs


class SolrSpawnHealthPoller(AbstractSpawnHealthHTTPPoller):
    def __init__(self, spawn):
        AbstractSpawnHealthHTTPPoller.__init__(self, spawn)

    def _check_process_health(self, spawn) -> bool:
        try:
            with self._session.get(
                f"http://{spawn.host}:{spawn.port}/api", headers={"Authorization": spawn.auth_header}, timeout=8.0
            ) as r:
                if r.status_code == 200:
                    data = r.json()
                    if isinstance(data, dict):
                        rh = data.get("responseHeader")
                        if isinstance(rh, dict):
                            return rh.get("status") == 0
        except Exception:
            pass
        return False


class SolrSpawn(AbstractPolledSpawnWithThread):
    """
    Solr cannot start on a random port by itself, so we're hedging our bets and try to start it on random ports
    until we find one that works. In case the solr port would get used up by something else, this is still better
    than having it on a static port and having it never work anymore.

    Start & stop operations can take a long time.
    To avoid blocking the main thread these are detached queues and executed here.
    """

    STOP_KEY = "solrrocks-wereallylikeit"
    PID_PREFIX = "solr"

    def __init__(
        self,
        java_home: str,
        java_executable: str,
        solr_install_dir: Path,
        solr_server_dir: Path,
        solr_data_dir: Path,
        solr_logs_dir: Path,
        solr_pid_dir: Path,
        solr_heap_init: str,
        solr_heap_size: str,
    ):
        # home & executable shall be supplied in java-compatible paths
        self._java_home = java_home
        self._java_executable = java_executable
        self._solr_install_dir = Path(java_compatible_absolute_path(solr_install_dir))
        self._solr_server_dir = Path(java_compatible_absolute_path(solr_server_dir))
        self._solr_data_dir = Path(java_compatible_absolute_path(solr_data_dir))
        self._solr_logs_dir = Path(java_compatible_absolute_path(solr_logs_dir))
        self._solr_pid_dir = solr_pid_dir
        self._solr_heap_init = solr_heap_init
        self._solr_heap_size = solr_heap_size
        self.host = "127.0.0.1"
        self.port, self.stop_port = get_free_ports_for_dynamic_service(2)
        self.key = base64.urlsafe_b64encode(os.urandom(30)).decode("ascii")
        self.auth_header = create_solr_auth_header(self.key)
        AbstractPolledSpawnWithThread.__init__(self)

    def _create_health_poller(self) -> AbstractSpawnHealthPoller:
        return SolrSpawnHealthPoller(self)

    def kill(self):
        """
        The "stop" command already includes a kill after a timeout so nothing to be done here.
        """
        pass

    def get_pid_prefix(self) -> str:
        return self.PID_PREFIX

    def _stop_spawn(self, *args):
        for spawn_data in SubprocessTracker.cleanup(
            self._solr_pid_dir, kill_timeout=10.0, pid_file_prefix=self.get_pid_prefix()
        ):
            if (md := spawn_data.get("metadata")) and isinstance(md, dict):
                if (stop_port := md.get("stop_port")) and isinstance(stop_port, int):
                    try:
                        subprocess.check_call(
                            [
                                self._java_executable,
                                # "$SOLR_SSL_OPTS",
                                "-Dsolr.httpclient.builder.factory="
                                "org.apache.solr.client.solrj.impl.PreemptiveBasicAuthClientBuilderFactory",
                                f"-Dbasicauth={USERNAME}:{md.get('key')}",
                                "-jar",
                                f'{self._solr_server_dir / "start.jar"}',
                                f"STOP.PORT={stop_port}",
                                f"STOP.KEY={self.STOP_KEY}",
                                "--stop",
                            ],
                            timeout=10.0,
                            **platform_fix_spawn_child_kwargs({}),
                        )
                    except subprocess.SubprocessError:  # pragma: no cover
                        pass

    def _start_spawn(self, *args) -> psutil.Popen:
        solr_spawn_config_dir = Path(java_compatible_absolute_path(get_resource_path("spawn", "solr")))
        self._update_base_configs(self._solr_data_dir)
        # The default solr(.cmd) manager scripts do not have the facility to handle dynamic port assignments
        # and cleanup of previous clients robustly. To make our job clear as dey we ignore the startup scripts
        # and parameter the Jetty start.jar ourselves.
        # These options are inspired by the bin/solr(.cmd) bootstrap script, but the following are omitted as
        # they are not utilized/the default is fine:
        #   - JMX
        #   - SolrCloud
        #   - GC logging
        #   - IP ACL
        #   - Log4J
        java_mem = [f"-Xms{self._solr_heap_init}", f"-Xmx{self._solr_heap_size}", "-Xss256k"]
        gc_tune = [
            "-XX:+UseG1GC",
            "-XX:+PerfDisableSharedMem",
            "-XX:+ParallelRefProcEnabled",
            "-XX:MaxGCPauseMillis=250",
            "-XX:+AlwaysPreTouch",
            "-XX:+ExplicitGCInvokesConcurrent",
        ]
        if not sys.platform.startswith("win"):
            gc_tune.append("-XX:+UseLargePages")
        oom_config = []
        if not sys.platform.startswith("win"):
            oom_config.append(
                f"-XX:OnOutOfMemoryError={self._solr_install_dir / 'bin/oom_solr.sh'} {self.port} {self._solr_logs_dir}"
            )  # TODO: fix escaping here
        java_security_manager = [
            "-Djava.security.manager",
            # We use a patched security policy to allow the user overriding the system temporary directory.
            f"-Djava.security.policy={solr_spawn_config_dir / 'security.policy'}",
            f"-Djava.security.properties={self._solr_server_dir / 'etc/security.properties'}",
            # This value is usually expanded to a wildcard when used, but we do not want general networking.
            # Leaving it empty should be fine, but instead we re-assert that only localhost networking is to be used.
            "-Dsolr.internal.network.permission=127.0.0.1:1024-",  # restrict to localhost
        ]
        solr_start_opts = [
            "-server",
            *java_mem,
            *gc_tune,
            *oom_config,
            "-Dsolr.log.muteconsole",
            # "-Dsolr.log.level=${SOLR_LOG_LEVEL}",  # we could set logging level
            f"-Dsolr.log.dir={self._solr_logs_dir}",
            # f"-Xlog:gc*:file={self._solr_logs_dir / 'solr_gc.log'}:time,uptime:filecount=3,filesize=20M",
            "-Dlog4j2.formatMsgNoLookups=true",  # mitigate CVE-2021-44228 for Log4j2<=2.14.1 and Java<=8u121
            f"-Djetty.host={self.host}",
            "-Dsolr.jetty.inetaccess.includes=",
            "-Dsolr.jetty.inetaccess.excludes=",
            f"-Djetty.port={self.port}",
            f"-DSTOP.PORT={self.stop_port}",
            f"-DSTOP.KEY={self.STOP_KEY}",
            "-Duser.timezone=UTC",
            "-XX:-OmitStackTraceInFastThrow",
            f"-Djetty.home={self._solr_server_dir}",
            f"-Dsolr.solr.home={self._solr_data_dir}",
            "-Dsolr.data.home=",  # data is fine in solr.solr.home; NOTE: required for the security.manager to work
            f"-Dsolr.install.dir={self._solr_install_dir}",
            f"-Dsolr.default.confdir={self._solr_server_dir / 'solr/configsets/_default/conf'}",
            *java_security_manager,
            # Support handling very long paths. (Jetty default GET size limit is 8K)
            "-Dsolr.jetty.request.header.size=65536",
            "-Dsolr.jetty.response.header.size=65536",
        ]
        # Jetty is configured without TLS because Solr shall only be accessible inside the host. Network sniffing by
        # root processes is a danger, but that is expected to be secured by the OS and the administrator of the
        # machine. If localhost traffic sniffing is an issue, the attacking process would most likely be able to
        # acquire the access keys anyways. So basic authentication is sufficient to lock out other processes.
        jetty_configs = [
            "--module=http",  # we do not use TLS on localhost
            # "--module=requestlog",  # request logging could be enabled
        ]
        self._solr_logs_dir.mkdir(parents=True, exist_ok=True)
        env = dict(os.environ)
        if self._java_home:
            env["JAVA_HOME"] = self._java_home
        with open(self._solr_logs_dir / "solr-output.log", "w") as log_file:
            p = self._start_reap_popen(
                [
                    self._java_executable,
                    *solr_start_opts,
                    "-jar",
                    "start.jar",
                    *jetty_configs,
                ],
                env=env,
                cwd=self._solr_server_dir,
                stdout=log_file,
                stderr=subprocess.STDOUT,
            )
        SubprocessTracker.record(
            self._solr_pid_dir,
            p,
            metadata={"port": self.port, "stop_port": self.stop_port, "key": self.key},
            pid_file_prefix=self.get_pid_prefix(),
        )
        return p

    def _update_base_configs(self, data_dir: Path):
        """
        Create a cache-sample config from the templates and the current nano-configuration. Unless the cached mark
        matches the newly generated all of the configs will be deleted and re-generated.
        If anyone wishes to customize their schema or core-settings, their only option now is to modify the templates.
        """
        # manage base config
        template_base_dir = get_resource_path("template", "solr")
        cache_marker = CacheMarkByFiles(
            {
                "solr.xml": (template_base_dir / "solr.xml").read_text(encoding="utf-8"),
            }
        )
        if not cache_marker.check(data_dir, mark_name := "solr_base_config"):
            # Solr is not running and the depending processes are waiting for a new instance to start.
            # Resetting everything here is safe and easier than managing unknown config changes for the schema.
            removal = ["configsets"]
            for item in data_dir.iterdir():
                if item.name.startswith(NANO_CORE_NAME_PREFIX) and item.is_dir():
                    removal.append(item.name)
            cache_marker.remove_items_by_template_root_names(data_dir, *removal)
            cache_marker.write_items(data_dir)
            cache_marker.write_mark(data_dir, mark_name)
        # always write new security config when starting an instance
        self._write_new_security_json(data_dir)

    def _write_new_security_json(self, data_dir: Path):
        """
        Basic authentication locks out other processes and other users from querying the Solr index. The security
        config file is saved under the user's home (by default), so it is secure from other users. Unless localhost
        network monitoring is an issue for the machine this is sufficient security.
        """
        security_salt = os.urandom(32)
        security_pass = hashlib.sha256(hashlib.sha256(security_salt + self.key.encode("utf-8")).digest()).digest()
        security_salt = base64.b64encode(security_salt).decode("ascii")
        security_pass = base64.b64encode(security_pass).decode("ascii")
        with (data_dir / "security.json").open("w", encoding="utf-8") as f:
            json.dump(
                {
                    "authentication": {
                        "blockUnknown": True,
                        "class": "solr.BasicAuthPlugin",
                        "credentials": {USERNAME: f"{security_pass} {security_salt}"},
                        "forwardCredentials": False,
                    },
                    "authorization": {
                        "class": "solr.RuleBasedAuthorizationPlugin",
                        "permissions": [{"name": "security-edit", "role": "admin"}],
                        "user-role": {USERNAME: "admin"},
                    },
                },
                f,
            )
