"""JSON-based hierarchical settings manager.

Supports access via key lists (``["section", "key"]``) and dot-notation
(``"section.key"``).  Thread-safe, auto-creates missing keys, and persists
to disk with atomic write + backup.
"""

import json
import logging
import os
import threading
from typing import Any, Dict, List, Optional, Union

log = logging.getLogger(__name__)

KeyElement = Union[int, str]
KeyPath = Union[List[KeyElement], str]


def _parse_key(key: KeyPath) -> List[KeyElement]:
    """Normalise a key to a list.

    Accepts either a list (``["a", "b", 0]``) or a dot-separated string
    (``"a.b.0"``).  Numeric segments in dot-notation are converted to int
    so they work as array indices.
    """
    if isinstance(key, list):
        return key
    parts: List[KeyElement] = []
    for segment in key.split("."):
        try:
            parts.append(int(segment))
        except ValueError:
            parts.append(segment)
    return parts


class Settings:
    """Thread-safe JSON settings with hierarchical key access.

    Parameters
    ----------
    file_path : str or None
        Path to the JSON file.  If *None*, settings live in memory only
        (no persistence).
    auto_save : bool
        When *True* (default), every mutation automatically persists to disk.
    """

    _instances: Dict[str, "Settings"] = {}

    def __init__(
        self,
        file_path: Optional[str] = None,
        auto_save: bool = True,
    ) -> None:
        self.file_path = file_path
        self.auto_save = auto_save
        self._json: Any = {}
        self._lock = threading.RLock()

        if file_path is not None:
            parent = os.path.dirname(file_path)
            if parent:
                os.makedirs(parent, exist_ok=True)
            self.reload()

    # ------------------------------------------------------------------
    # Singleton / named instances
    # ------------------------------------------------------------------
    @staticmethod
    def get_instance(name: str = "main", file_path: Optional[str] = None) -> "Settings":
        """Return a named singleton.  Created on first call with *file_path*."""
        if name not in Settings._instances:
            if file_path is None:
                file_path = f"data/settings/settings_{name}.json"
            Settings._instances[name] = Settings(file_path)
        return Settings._instances[name]

    # ------------------------------------------------------------------
    # Persistence
    # ------------------------------------------------------------------
    def save(self) -> None:
        """Persist settings to disk using atomic write + backup."""
        if self.file_path is None:
            return
        with self._lock:
            log.info("Saving settings to %s", self.file_path)
            tmp = self.file_path + ".tmp"
            bak = self.file_path + ".bak"
            try:
                with open(tmp, "w") as f:
                    json.dump(self._json, f, indent=4)
            except Exception:
                log.warning("Could not write temp file %s", tmp)
                if os.path.exists(tmp):
                    os.remove(tmp)
                return

            try:
                if os.path.exists(bak):
                    os.remove(bak)
                if os.path.exists(self.file_path):
                    os.rename(self.file_path, bak)
                os.rename(tmp, self.file_path)
            except Exception:
                log.warning("Error replacing settings file %s", self.file_path)

    def reload(self) -> None:
        """Reload settings from disk, falling back to backup then empty dict."""
        if self.file_path is None:
            return
        with self._lock:
            log.info("Loading settings from %s", self.file_path)
            try:
                with open(self.file_path) as f:
                    self._json = json.load(f)
                return
            except Exception:
                log.warning("Could not load %s", self.file_path)

            bak = self.file_path + ".bak"
            try:
                with open(bak) as f:
                    self._json = json.load(f)
                log.info("Loaded from backup %s", bak)
                return
            except Exception:
                log.warning("Could not load backup either, starting with empty settings")
                self._json = {}
                self.save()

    # ------------------------------------------------------------------
    # Read / write
    # ------------------------------------------------------------------
    def get(self, key: KeyPath, default: Any = None) -> Any:
        """Get a value by key path, auto-creating it with *default* if missing.

        Type compatibility is checked: if the stored type differs from the
        default's type (and they are not both numeric), the default wins.
        """
        key_list = _parse_key(key)
        try:
            with self._lock:
                node = self._json
                for i, k in enumerate(key_list):
                    is_last = i == len(key_list) - 1

                    if is_last:
                        return self._get_leaf(node, k, default, key_list)
                    else:
                        node = self._traverse(node, k, key_list, i)
        except Exception:
            log.warning("Could not access value with key %s", key_list)
        return default

    def set(self, key: KeyPath, value: Any) -> None:
        """Set a value by key path, auto-creating intermediate nodes."""
        key_list = _parse_key(key)
        try:
            with self._lock:
                node = self._json
                for i, k in enumerate(key_list):
                    is_last = i == len(key_list) - 1

                    if is_last:
                        self._set_leaf(node, k, value)
                    else:
                        node = self._traverse(node, k, key_list, i)
        except Exception:
            log.warning("Could not set value for key %s", key_list)

    def exist(self, key: KeyPath) -> bool:
        """Return *True* if the key path exists."""
        key_list = _parse_key(key)
        try:
            with self._lock:
                node = self._json
                for i, k in enumerate(key_list):
                    is_last = i == len(key_list) - 1
                    if isinstance(k, int):
                        if not isinstance(node, list) or len(node) <= k:
                            return False
                        if is_last:
                            return True
                        node = node[k]
                    else:
                        if not isinstance(node, dict) or k not in node:
                            return False
                        if is_last:
                            return True
                        node = node[k]
        except Exception:
            pass
        return False

    def length(self, key: KeyPath) -> int:
        """Return the length of a list/dict at the given key, or -1."""
        key_list = _parse_key(key)
        try:
            with self._lock:
                node = self._json
                for k in key_list:
                    if isinstance(k, int):
                        if not isinstance(node, list) or len(node) <= k:
                            return -1
                        node = node[k]
                    else:
                        if not isinstance(node, dict) or k not in node:
                            return -1
                        node = node[k]
                return len(node)
        except Exception:
            pass
        return -1

    def delete_key(self, key: KeyPath) -> bool:
        """Delete the value at key path.  Returns *True* on success."""
        key_list = _parse_key(key)
        if not self.exist(key_list):
            return False
        try:
            with self._lock:
                node = self._json
                for k in key_list[:-1]:
                    node = node[k]
                last = key_list[-1]
                if isinstance(last, int) and isinstance(node, list):
                    del node[last]
                elif isinstance(node, dict) and last in node:
                    del node[last]
                else:
                    return False
                if self.auto_save:
                    self.save()
                return True
        except Exception:
            log.warning("Could not delete key %s", key_list)
        return False

    def move_key(self, old_key: KeyPath, new_key: KeyPath) -> bool:
        """Move a value from *old_key* to *new_key*."""
        old_list = _parse_key(old_key)
        new_list = _parse_key(new_key)
        if not self.exist(old_list):
            return False
        if self.exist(new_list):
            return False
        value = self.get(old_list)
        self.delete_key(old_list)
        self.set(new_list, value)
        return True

    # ------------------------------------------------------------------
    # Serialisation helpers
    # ------------------------------------------------------------------
    def to_json(self) -> str:
        """Return the full settings as a formatted JSON string."""
        with self._lock:
            return json.dumps(self._json, indent=4)

    def load_json(self, json_string: str) -> None:
        """Replace settings from a JSON string, then persist."""
        with self._lock:
            try:
                self._json = json.loads(json_string)
            except Exception:
                log.warning("Could not parse JSON string for settings")
                return
            self.save()

    def get_value_from_env(
        self,
        env_name: str,
        key: KeyPath,
        default: Any,
    ) -> Any:
        """Read from environment variable, falling back to settings/default."""
        val = os.environ.get(env_name)
        if val is not None:
            self.set(key, val)
            return val
        return self.get(key, default)

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------
    def _traverse(self, node: Any, k: KeyElement, key_list: list, idx: int) -> Any:
        """Navigate one level deeper, auto-creating missing nodes."""
        next_is_int = isinstance(key_list[idx + 1], int)
        if isinstance(k, int):
            while isinstance(node, list) and len(node) <= k:
                node.append({})
            return node[k]
        else:
            if k not in node:
                node[k] = [] if next_is_int else {}
            return node[k]

    def _get_leaf(self, node: Any, k: KeyElement, default: Any, key_list: list) -> Any:
        """Read or auto-create the leaf value."""
        if isinstance(k, int):
            if isinstance(node, list) and len(node) > k:
                stored = node[k]
                if not self._types_compatible(stored, default):
                    node[k] = default
                    if self.auto_save:
                        self.save()
                    return default
                return stored
            # Auto-create
            if isinstance(node, list):
                node.append(default)
            if self.auto_save:
                self.save()
            return default
        else:
            if k in node:
                stored = node[k]
                if not self._types_compatible(stored, default):
                    node[k] = default
                    if self.auto_save:
                        self.save()
                    return default
                return stored
            node[k] = default
            if self.auto_save:
                self.save()
            return default

    def _set_leaf(self, node: Any, k: KeyElement, value: Any) -> None:
        """Write a leaf value."""
        if isinstance(k, int):
            if isinstance(node, list):
                while len(node) <= k:
                    node.append(None)
                if node[k] != value:
                    node[k] = value
                    if self.auto_save:
                        self.save()
            return
        if k not in node or node[k] != value:
            node[k] = value
            if self.auto_save:
                self.save()

    @staticmethod
    def _types_compatible(stored: Any, default: Any) -> bool:
        if default is None:
            return True
        if isinstance(stored, (int, float)) and isinstance(default, (int, float)):
            return True
        return type(stored) is type(default)
