import json
import os
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, Union

from loguru import logger
from sqlalchemy import func, or_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

from ... import defaults
from ...__version__ import __version__ as package_version
from ...database.models import Setting, SettingType
from ..models.settings import (
    AppSetting,
    BaseSetting,
    LLMSetting,
    ReportSetting,
    SearchSetting,
)


def check_env_setting(key: str) -> str | None:
    """
    Checks environment variables for a particular setting.

    Args:
        key: The database key for the setting.

    Returns:
        The setting from the environment variables, or None if the variable
        is not set.

    """
    env_variable_name = f"LDR_{'_'.join(key.split('.')).upper()}"
    env_value = os.getenv(env_variable_name)
    if env_value is not None:
        logger.debug(f"Overriding {key} setting from environment variable.")
    return env_value


class SettingsManager:
    """
    Manager for handling application settings with database storage and file fallback.
    Provides methods to get and set settings, with the ability to override settings in memory.
    """

    _UI_ELEMENT_TO_SETTING_TYPE = {
        "text": str,
        # SQLAlchemy should already handle JSON parsing.
        "json": lambda x: x,
        "password": str,
        "select": str,
        "number": float,
        "range": float,
        "checkbox": bool,
    }

    def __init__(self, db_session: Optional[Session] = None):
        """
        Initialize the settings manager

        Args:
            db_session: SQLAlchemy session for database operations
        """
        self.db_session = db_session
        self.db_first = True  # Always prioritize DB settings

        # Store the thread ID this instance was created in
        self._creation_thread_id = threading.get_ident()

        # Initialize settings lock as None - will be checked lazily
        self.__settings_locked = None

    def _check_thread_safety(self):
        """Check if this instance is being used in the same thread it was created in."""
        current_thread_id = threading.get_ident()
        if self.db_session and current_thread_id != self._creation_thread_id:
            raise RuntimeError(
                f"SettingsManager instance created in thread {self._creation_thread_id} "
                f"is being used in thread {current_thread_id}. This is not thread-safe! "
                f"Create a new SettingsManager instance within the current thread context."
            )

    @property
    def settings_locked(self) -> bool:
        """Check if settings are locked (lazy evaluation)."""
        if self.__settings_locked is None:
            try:
                self.__settings_locked = self.get_setting(
                    "app.lock_settings", False
                )
                if self.settings_locked:
                    logger.info(
                        "Settings are locked. Disabling all settings changes."
                    )
            except Exception:
                # If we can't check, assume not locked
                self.__settings_locked = False
        return self.__settings_locked

    @property
    def default_settings(self) -> Dict[str, Any]:
        """
        Returns:
            The default settings, loaded from JSON files and merged.
            Automatically discovers and loads all .json files in the defaults directory
            and its subdirectories.

        """
        settings = {}

        try:
            # Get the defaults package path
            defaults_path = Path(defaults.__file__).parent

            # Find all JSON files recursively in the defaults directory
            json_files = list(defaults_path.rglob("*.json"))

            # Sort for consistent loading order
            json_files.sort()

            logger.info(f"Found {len(json_files)} JSON settings files")

            # Load and merge all JSON files
            for json_file in json_files:
                try:
                    with open(json_file, "r") as f:
                        file_settings = json.load(f)

                    # Get relative path for logging
                    relative_path = json_file.relative_to(defaults_path)

                    # Warn about key conflicts
                    conflicts = set(settings.keys()) & set(file_settings.keys())
                    if conflicts:
                        logger.warning(
                            f"Keys {conflicts} from {relative_path} override existing values"
                        )

                    settings.update(file_settings)
                    logger.debug(f"Loaded {relative_path}")

                except json.JSONDecodeError:
                    logger.exception(f"Invalid JSON in {json_file}")
                except Exception as e:
                    logger.warning(f"Could not load {json_file}: {e}")

        except Exception as e:
            logger.warning(f"Error loading settings files: {e}")

        logger.info(f"Loaded {len(settings)} total settings")
        return settings

    def __get_typed_setting_value(
        self,
        setting: Type[Setting],
        default: Any = None,
        check_env: bool = True,
    ) -> Any:
        """
        Extracts the value for a particular setting, ensuring that it has the
        correct type.

        Args:
            setting: The setting to get the value for.
            default: Default value to return if the value of the setting is
                invalid.
            check_env: If true, it will check the environment variable for
                this setting before reading from the DB.

        Returns:
            The value of the setting.

        """
        setting_type = self._UI_ELEMENT_TO_SETTING_TYPE.get(
            setting.ui_element, None
        )
        if setting_type is None:
            logger.warning(
                "Got unknown type {} for setting {}, returning default value.",
                setting.ui_element,
                setting.key,
            )
            return default

        # Check environment variable first, then database.
        if check_env:
            env_value = check_env_setting(setting.key)
            if env_value is not None:
                try:
                    return setting_type(env_value)
                except ValueError:
                    logger.warning(
                        "Setting {} has invalid value {}. Falling back to DB.",
                        setting.key,
                        env_value,
                    )

        # If environment variable does not exist, read from the database.
        try:
            return setting_type(setting.value)
        except ValueError:
            logger.warning(
                "Setting {} has invalid value {}. Returning default.",
                setting.key,
                setting.value,
            )
            return default

    def __query_settings(self, key: str | None = None) -> List[Type[Setting]]:
        """
        Abstraction for querying settings that also transparently handles
        reading the default settings file if the DB is not enabled.

        Args:
            key: The key to read. If None, it will read everything.

        Returns:
            The settings it queried.

        """
        if self.db_session:
            self._check_thread_safety()
            query = self.db_session.query(Setting)
            if key is not None:
                # This will find exact matches and any subkeys.
                query = query.filter(
                    or_(
                        Setting.key == key,
                        Setting.key.startswith(f"{key}."),
                    )
                )
            return query.all()

        else:
            logger.debug(
                "DB is disabled, reading setting '{}' from defaults file.", key
            )

            settings = []
            for candidate_key, setting in self.default_settings.items():
                if key is None or (
                    candidate_key == key or candidate_key.startswith(f"{key}.")
                ):
                    settings.append(Setting(key=candidate_key, **setting))

            return settings

    def get_setting(
        self, key: str, default: Any = None, check_env: bool = True
    ) -> Any:
        """
        Get a setting value

        Args:
            key: Setting key
            default: Default value if setting is not found
            check_env: If true, it will check the environment variable for
                this setting before reading from the DB.

        Returns:
            Setting value or default if not found
        """
        # If using database first approach and session available, check database
        try:
            settings = self.__query_settings(key)
            if len(settings) == 1:
                # This is a bottom-level key.
                return self.__get_typed_setting_value(
                    settings[0], default, check_env
                )
            elif len(settings) > 1:
                # This is a higher-level key.
                settings_map = {}
                for setting in settings:
                    output_key = setting.key.removeprefix(f"{key}.")
                    settings_map[output_key] = self.__get_typed_setting_value(
                        setting, default, check_env
                    )
                return settings_map
        except SQLAlchemyError as e:
            logger.exception(
                f"Error retrieving setting {key} from database: {e}"
            )

        # Return default if not found
        return default

    def set_setting(self, key: str, value: Any, commit: bool = True) -> bool:
        """
        Set a setting value

        Args:
            key: Setting key
            value: Setting value
            commit: Whether to commit the change

        Returns:
            True if successful, False otherwise
        """
        if not self.db_session:
            logger.error(
                "Cannot edit setting {} because no DB was provided.", key
            )
            return False
        if self.settings_locked:
            logger.error("Cannot edit setting {} because they are locked.", key)
            return False

        # Always update database if available
        try:
            self._check_thread_safety()
            setting = (
                self.db_session.query(Setting)
                .filter(Setting.key == key)
                .first()
            )
            if setting:
                if not setting.editable:
                    logger.error(
                        "Cannot change setting '{}' because it "
                        "is marked as non-editable.",
                        key,
                    )
                    return False

                setting.value = value
                setting.updated_at = (
                    func.now()
                )  # Explicitly set the current timestamp
            else:
                # Determine setting type from key
                setting_type = SettingType.APP
                if key.startswith("llm."):
                    setting_type = SettingType.LLM
                elif key.startswith("search."):
                    setting_type = SettingType.SEARCH
                elif key.startswith("report."):
                    setting_type = SettingType.REPORT

                # Create a new setting
                new_setting = Setting(
                    key=key,
                    value=value,
                    type=setting_type,
                    name=key.split(".")[-1].replace("_", " ").title(),
                    ui_element="text",
                    description=f"Setting for {key}",
                )
                self.db_session.add(new_setting)

            if commit:
                self.db_session.commit()
                # Emit WebSocket event for settings change
                self._emit_settings_changed([key])

            return True
        except SQLAlchemyError:
            logger.exception("Error setting value")
            self.db_session.rollback()
            return False

    def get_all_settings(self) -> Dict[str, Any]:
        """
        Get all settings

        Returns:
            Dictionary of all settings
        """
        result = {}

        # Add database settings if available
        try:
            for setting in self.__query_settings():
                result[setting.key] = dict(
                    value=setting.value,
                    type=setting.type.name,
                    name=setting.name,
                    description=setting.description,
                    category=setting.category,
                    ui_element=setting.ui_element,
                    options=setting.options,
                    min_value=setting.min_value,
                    max_value=setting.max_value,
                    step=setting.step,
                    visible=setting.visible,
                    editable=False
                    if self.settings_locked
                    else setting.editable,
                )

                # Override from the environment variables if needed.
                env_value = check_env_setting(setting.key)
                if env_value is not None:
                    result[setting.key]["value"] = env_value
                    # Mark it as non-editable, because changes to the DB
                    # value have no effect as long as the environment
                    # variable is set.
                    result[setting.key]["editable"] = False
        except SQLAlchemyError as e:
            logger.exception(
                f"Error retrieving all settings from database: {e}"
            )

        return result

    def create_or_update_setting(
        self, setting: Union[BaseSetting, Dict[str, Any]], commit: bool = True
    ) -> Optional[Setting]:
        """
        Create or update a setting

        Args:
            setting: Setting object or dictionary
            commit: Whether to commit the change

        Returns:
            The created or updated Setting model, or None if failed
        """
        if not self.db_session:
            logger.warning(
                "No database session available, cannot create/update setting"
            )
            return None
        if self.settings_locked:
            logger.error("Cannot edit settings because they are locked.")
            return None

        # Convert dict to BaseSetting if needed
        if isinstance(setting, dict):
            # Determine type from key if not specified
            if "type" not in setting and "key" in setting:
                key = setting["key"]
                if key.startswith("llm."):
                    setting_obj = LLMSetting(**setting)
                elif key.startswith("search."):
                    setting_obj = SearchSetting(**setting)
                elif key.startswith("report."):
                    setting_obj = ReportSetting(**setting)
                else:
                    setting_obj = AppSetting(**setting)
            else:
                # Use generic BaseSetting
                setting_obj = BaseSetting(**setting)
        else:
            setting_obj = setting

        try:
            # Check if setting exists
            db_setting = (
                self.db_session.query(Setting)
                .filter(Setting.key == setting_obj.key)
                .first()
            )

            if db_setting:
                # Update existing setting
                if not db_setting.editable:
                    logger.error(
                        "Cannot change setting '{}' because it "
                        "is marked as non-editable.",
                        setting_obj.key,
                    )
                    return None

                db_setting.value = setting_obj.value
                db_setting.name = setting_obj.name
                db_setting.description = setting_obj.description
                db_setting.category = setting_obj.category
                db_setting.ui_element = setting_obj.ui_element
                db_setting.options = setting_obj.options
                db_setting.min_value = setting_obj.min_value
                db_setting.max_value = setting_obj.max_value
                db_setting.step = setting_obj.step
                db_setting.visible = setting_obj.visible
                db_setting.editable = setting_obj.editable
                db_setting.updated_at = (
                    func.now()
                )  # Explicitly set the current timestamp
            else:
                # Create new setting
                db_setting = Setting(
                    key=setting_obj.key,
                    value=setting_obj.value,
                    type=setting_obj.type,  # It's already a SettingType enum
                    name=setting_obj.name,
                    description=setting_obj.description,
                    category=setting_obj.category,
                    ui_element=setting_obj.ui_element,
                    options=setting_obj.options,
                    min_value=setting_obj.min_value,
                    max_value=setting_obj.max_value,
                    step=setting_obj.step,
                    visible=setting_obj.visible,
                    editable=setting_obj.editable,
                )
                self.db_session.add(db_setting)

            if commit:
                self.db_session.commit()
                # Emit WebSocket event for settings change
                self._emit_settings_changed([setting_obj.key])

            return db_setting

        except SQLAlchemyError as e:
            logger.exception(
                f"Error creating/updating setting {setting_obj.key}: {e}"
            )
            self.db_session.rollback()
            return None

    def delete_setting(self, key: str, commit: bool = True) -> bool:
        """
        Delete a setting

        Args:
            key: Setting key
            commit: Whether to commit the change

        Returns:
            True if successful, False otherwise
        """
        if not self.db_session:
            logger.warning(
                "No database session available, cannot delete setting"
            )
            return False

        try:
            # Remove from database
            result = (
                self.db_session.query(Setting)
                .filter(Setting.key == key)
                .delete()
            )

            if commit:
                self.db_session.commit()

            return result > 0
        except SQLAlchemyError:
            logger.exception("Error deleting setting")
            self.db_session.rollback()
            return False

    def load_from_defaults_file(
        self, commit: bool = True, **kwargs: Any
    ) -> None:
        """
        Import settings from the defaults settings file.

        Args:
            commit: Whether to commit changes to database
            **kwargs: Will be passed to `import_settings`.

        """
        self.import_settings(self.default_settings, commit=commit, **kwargs)

    def db_version_matches_package(self) -> bool:
        """
        Returns:
            True if the version saved in the DB matches the package version.

        """
        db_version = self.get_setting("app.version")
        logger.debug(
            f"App version saved in DB is {db_version}, have package "
            f"settings from version {package_version}."
        )

        return db_version == package_version

    def update_db_version(self) -> None:
        """
        Updates the version saved in the DB based on the package version.

        """
        logger.debug(f"Updating saved DB version to {package_version}.")

        self.delete_setting("app.version", commit=False)
        version = Setting(
            key="app.version",
            value=package_version,
            description="Version of the app this database is associated with.",
            editable=False,
            name="App Version",
            type=SettingType.APP,
            ui_element="text",
            visible=False,
        )

        self.db_session.add(version)
        self.db_session.commit()

    def import_settings(
        self,
        settings_data: Dict[str, Any],
        commit: bool = True,
        overwrite: bool = True,
        delete_extra: bool = False,
    ) -> None:
        """
        Import settings directly from the export format. This can be used to
        re-import settings that have been exported with `get_all_settings()`.

        Args:
            settings_data: The raw settings data to import.
            commit: Whether to commit the DB after loading the settings.
            overwrite: If true, it will overwrite the value of settings that
                are already in the database.
            delete_extra: If true, it will delete any settings that are in
                the database but don't have a corresponding entry in
                `settings_data`.

        """
        logger.debug(f"Importing {len(settings_data)} settings")

        for key, setting_values in settings_data.items():
            if not overwrite:
                existing_value = self.get_setting(key)
                if existing_value is not None:
                    # Preserve the value from this setting.
                    setting_values["value"] = existing_value

            # Delete any existing setting so we can completely overwrite it.
            self.delete_setting(key, commit=False)

            setting = Setting(key=key, **setting_values)
            self.db_session.add(setting)

        if commit or delete_extra:
            self.db_session.commit()
            logger.info(f"Successfully imported {len(settings_data)} settings")
            # Emit WebSocket event for all imported settings
            self._emit_settings_changed(list(settings_data.keys()))

        if delete_extra:
            all_settings = self.get_all_settings()
            for key in all_settings:
                if key not in settings_data:
                    logger.debug(f"Deleting extraneous setting: {key}")
                    self.delete_setting(key, commit=False)

    def _create_setting(self, key, value, setting_type):
        """Create a setting with appropriate metadata"""

        # Determine appropriate category
        category = None
        ui_element = "text"

        # Determine category based on key pattern
        if key.startswith("app."):
            category = "app_interface"
        elif key.startswith("llm."):
            if any(
                param in key
                for param in [
                    "temperature",
                    "max_tokens",
                    "n_batch",
                    "n_gpu_layers",
                ]
            ):
                category = "llm_parameters"
            else:
                category = "llm_general"
        elif key.startswith("search."):
            if any(
                param in key
                for param in ["iterations", "questions", "results", "region"]
            ):
                category = "search_parameters"
            else:
                category = "search_general"
        elif key.startswith("report."):
            category = "report_parameters"

        # Determine UI element type based on value
        if isinstance(value, bool):
            ui_element = "checkbox"
        elif isinstance(value, (int, float)) and not isinstance(value, bool):
            ui_element = "number"
        elif isinstance(value, (dict, list)):
            ui_element = "textarea"

        # Build setting object
        setting_dict = {
            "key": key,
            "value": value,
            "type": setting_type.value.lower(),
            "name": key.split(".")[-1].replace("_", " ").title(),
            "description": f"Setting for {key}",
            "category": category,
            "ui_element": ui_element,
        }

        # Create the setting in the database
        self.create_or_update_setting(setting_dict, commit=False)

    def _emit_settings_changed(self, changed_keys: list = None):
        """
        Emit WebSocket event when settings change

        Args:
            changed_keys: List of setting keys that changed
        """
        try:
            # Import here to avoid circular imports
            from .socket_service import SocketIOService

            try:
                socket_service = SocketIOService()
            except ValueError:
                logger.debug(
                    "Not emitting socket event because server is not initialized."
                )
                return

            # Get the changed settings
            settings_data = {}
            if changed_keys:
                for key in changed_keys:
                    setting_value = self.get_setting(key)
                    if setting_value is not None:
                        settings_data[key] = {"value": setting_value}

            # Emit the settings change event
            from datetime import datetime, UTC

            socket_service.emit_socket_event(
                "settings_changed",
                {
                    "changed_keys": changed_keys or [],
                    "settings": settings_data,
                    "timestamp": datetime.now(UTC).isoformat(),
                },
            )

            logger.debug(
                f"Emitted settings_changed event for keys: {changed_keys}"
            )

        except Exception:
            logger.exception("Failed to emit settings change event")
            # Don't let WebSocket emission failures break settings saving
