import asyncio
import logging
import os
import shutil
import stat
import struct
from typing import List, Tuple, Set, Iterable

import aiohttp
import safe_radix32
from clarabot_nano_common.common.solr.reflect import SolrSchemaHighlightReflector
from clarabot_nano_common.common.solr.rest import NANO_CORE_NAME_PREFIX

from clarabot_nano.common.solr.api import (
    is_solr_core_available,
    get_solr_core_schema,
    create_solr_core,
    unload_solr_core,
    list_solr_cores,
    get_solr_core_config,
)
from clarabot_nano.indexer.config import IndexerConfig
from clarabot_nano.indexer.idxstatedb import IndexingStateDatabase
from clarabot_nano.indexer.utility import (
    get_index_supported_languages,
    get_effective_languages_by_config,
    get_compiled_core_config,
)
from clarabot_nano.utility.config.cache import CacheMarkByDict

logger = logging.getLogger(__name__)


class IndexInitializationError(Exception):
    pass


class IndexerSolrSchemaStateBindInitializerMixin(object):
    SCHEMA_BIND_MARK_NAME = "idx_schema_bind"
    BIND_CONFIG_KEY = "core_bind"

    async def _initialise(
        self, session: aiohttp.ClientSession, ec: IndexerConfig, index_state_db: IndexingStateDatabase
    ) -> Tuple[str, List[str], dict]:
        """
        Check and if necessary reset the solr schema and the index state database.
        The whole process must succeed in one go for the init/reset to be successful.
        Return with the name of the core and the specific languages the schema supports from reflection.
        """
        try:
            core_name, languages, core_config = await self._initialise_validate(session, ec, index_state_db)
            await self._initialise_cleanup_old(ec, [core_name])  # call cleanup of old data if any
            return core_name, languages, core_config
        except IndexInitializationError:
            logger.warning("Resetting search indexes.", exc_info=True)
        await self._initialise_clean(session, ec, index_state_db)
        await self._initialise_create(session, ec, index_state_db)
        # after new initialization parse the indexed languages
        return await self._initialise_validate(session, ec, index_state_db)

    async def _initialise_validate(
        self, session: aiohttp.ClientSession, ec: IndexerConfig, index_state_db: IndexingStateDatabase
    ) -> Tuple[str, List[str], dict]:
        """
        Check if everything is setup correctly and may be used as-is.
        Validation fails when:
            - generated configset does not match with existing
                - indexed_languages configuration is changed
                - new templates are available (software updated)
            - any of solr core, cache marker does not exist
            - solr core and state db are not bound
        """
        # 1. Check if we have a core binding in the state db.
        core_name = await index_state_db.get_config_str(self.BIND_CONFIG_KEY)
        if not isinstance(core_name, str) or not core_name.startswith(NANO_CORE_NAME_PREFIX):
            raise IndexInitializationError("No active bind for solr core in state db")
        # 2. Ensure that the requested schema (by config) matches the existing configset.
        # This implies that a configset has to exist. If the re-generation of the configset encounters an error then
        # the existing is considered out-of-date.
        if not isinstance(
            existing_mark := CacheMarkByDict.read_mark(ec.solr_data_dir, self.SCHEMA_BIND_MARK_NAME), dict
        ):
            raise IndexInitializationError("Old cache mark is invalid or missing")
        if not isinstance(existing_effective_languages := existing_mark.get("effective_languages"), list):
            raise IndexInitializationError("effective_languages in old cache mark is invalid or missing")
        if not all(isinstance(lang, str) for lang in existing_effective_languages):
            raise IndexInitializationError("effective_languages in old cache mark has invalid value")
        try:
            compiled_core_config = get_compiled_core_config(existing_effective_languages)
        except FileNotFoundError:
            # a language support was dropped most likely
            raise IndexInitializationError("schema compilation failed with old effective_languages")
        cache_marker = CacheMarkByDict(
            {
                "config_languages": ec.indexed_languages,
                "effective_languages": existing_effective_languages,
                "compiled_core_config": compiled_core_config,
            }
        )
        if not cache_marker.check(ec.solr_data_dir, self.SCHEMA_BIND_MARK_NAME):
            raise IndexInitializationError("Cache mark is stale")
        # 3. Ensure that a Solr core exists. (we assume it uses the managed configset)
        if not await is_solr_core_available(session, ec.solr_port, ec.solr_auth_header, core_name, timeout=15.0):
            raise IndexInitializationError("Bound solr core does not exist")
        # return the languages that the current schema supports for indexing
        reflector = SolrSchemaHighlightReflector()  # use highlight-reflector to enforce schema correctness
        reflector.reflect_solr_schema(
            await get_solr_core_schema(session, ec.solr_port, ec.solr_auth_header, core_name, timeout=15.0)
        )
        core_config = await get_solr_core_config(session, ec.solr_port, ec.solr_auth_header, core_name, timeout=15.0)
        return core_name, reflector.languages, core_config

    async def _initialise_clean(
        self, session: aiohttp.ClientSession, ec: IndexerConfig, index_state_db: IndexingStateDatabase
    ):
        """
        Reset/delete/clean everything to make them ready for creation.
        We request the removal of the old core from Solr. This can take some time, but that's fine.
        """
        # clear the indexing state db
        await index_state_db.reset()
        # unload solr core(s) while asking for deleting as well
        core_names = await list_solr_cores(session, ec.solr_port, ec.solr_auth_header, timeout=15.0)
        for core_name in core_names:
            if core_name.startswith(NANO_CORE_NAME_PREFIX):
                await unload_solr_core(session, ec.solr_port, ec.solr_auth_header, core_name, timeout=30.0)
        # delete old cache mark
        CacheMarkByDict.get_mark_path(ec.solr_data_dir, self.SCHEMA_BIND_MARK_NAME).unlink(missing_ok=True)
        # delete old data that is not even being unloaded right now
        await self._initialise_cleanup_old(ec, core_names)

    async def _initialise_cleanup_old(self, ec: IndexerConfig, core_names_to_keep: Iterable[str]):
        """
        Delete old core and configset dirs that are not loaded anymore.
        The configset for the removed cores cannot be removed on some platforms until its cores are all cleaned up.
        Not being able to remove something is ignored here.
        """
        removal = []
        try:
            # delete the old cores
            for item in ec.solr_data_dir.iterdir():
                if (
                    item.name.startswith(NANO_CORE_NAME_PREFIX)
                    and item.is_dir()
                    and item.name not in core_names_to_keep
                ):
                    removal.append(item)
        except OSError:
            pass
        try:
            # collect old configsets
            for item in (ec.solr_data_dir / "configsets").iterdir():
                if (
                    item.name.startswith(NANO_CORE_NAME_PREFIX)
                    and item.is_dir()
                    and item.name not in core_names_to_keep
                ):
                    removal.append(item)
        except OSError:
            pass

        def _delete_old_files():
            for p in removal:
                try:
                    s = p.lstat()  # raises if target does not exist
                    if stat.S_ISDIR(s.st_mode):
                        shutil.rmtree(p)
                    else:
                        p.unlink()
                except OSError:
                    logger.warning("Failed to delete old Solr db item: %s", p, exc_info=True)

        # shutil.rmtree could take a long time so run in a different thread
        await asyncio.get_running_loop().run_in_executor(None, _delete_old_files)

    async def _initialise_create(
        self, session: aiohttp.ClientSession, ec: IndexerConfig, index_state_db: IndexingStateDatabase
    ):
        """
        Create/setup everything so we're ready to work.
        """
        core_name = f"{NANO_CORE_NAME_PREFIX}{safe_radix32.encode_fw(struct.unpack('<q', os.urandom(8))[0])}"
        # create new configset
        effective_languages = get_effective_languages_by_config(self.get_supported_languages(), ec.indexed_languages)
        compiled_core_config = get_compiled_core_config(effective_languages)
        # write new configset
        new_configset_dir = ec.solr_data_dir / "configsets" / core_name
        new_configset_dir.mkdir(parents=True)
        for relative_file_path, file_content in compiled_core_config.items():
            file_path = new_configset_dir / relative_file_path
            file_path.parent.mkdir(parents=True, exist_ok=True)
            file_path.write_text(file_content, encoding="utf-8")
        # write cache marker for the configset
        cache_marker = CacheMarkByDict(
            {
                "config_languages": ec.indexed_languages,
                "effective_languages": effective_languages,
                "compiled_core_config": compiled_core_config,
            }
        )
        cache_marker.write_mark(ec.solr_data_dir, self.SCHEMA_BIND_MARK_NAME)
        # create new core
        await create_solr_core(session, ec.solr_port, ec.solr_auth_header, core_name, core_name, timeout=120.0)
        # bind the new core to the state db
        await index_state_db.set_config_str(self.BIND_CONFIG_KEY, core_name)

    def get_supported_languages(self) -> Set[str]:
        """
        This can be overridden for caching or customization.
        """
        return get_index_supported_languages()

    def force_schema_reset(self, ec: IndexerConfig) -> None:
        """
        Will make sure that the Solr schema is reset during the next initialization.
        This can be used as an optimization when performing a reset of the Nano.
        """
        path = CacheMarkByDict.get_mark_path(ec.solr_data_dir, self.SCHEMA_BIND_MARK_NAME)
        if path.exists():
            try:
                path.unlink()
            except Exception:
                if path.exists():
                    raise
