"""Persist vibration-control runtime settings for robot control applications."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import json
import os
import tempfile
import threading
import time
from typing import Callable

UserConfigDirFn = Callable[..., str]

_user_config_dir: UserConfigDirFn | None
try:
    from platformdirs import user_config_dir as _imported_user_config_dir

    _user_config_dir = _imported_user_config_dir
except ImportError:  # pragma: no cover - fallback is exercised in constrained envs.
    _user_config_dir = None

user_config_dir: UserConfigDirFn | None = _user_config_dir

# Environment variable that overrides persisted settings file location.
ENV_CONFIG_PATH = "REFORGE_CONTROL_CONFIG_PATH"
# File name used inside the resolved per-user config directory.
DEFAULT_CONFIG_FILENAME = "control_settings.json"
# On-disk schema version for compatibility checks.
SCHEMA_VERSION = 1
# Stable app identifiers used by platformdirs to compute config directory.
APP_NAME = "reforge"
APP_AUTHOR = "reforge"


def _parse_bool(value: object, default: bool) -> bool:
    """Convert permissive user-provided values into a boolean flag.

    Args:
        value: Raw value from JSON payloads or environment strings.
        default: Value returned when parsing fails.
    Returns:
        `bool` Parsed boolean value.
    Side Effects:
        None.
    Raises:
        None.
    Preconditions:
        None.
    """

    if isinstance(value, bool):
        return value
    if isinstance(value, int):
        return bool(value)
    if isinstance(value, str):
        normalized = value.strip().lower()
        if normalized in {"1", "true", "t", "yes", "y", "on"}:
            return True
        if normalized in {"0", "false", "f", "no", "n", "off"}:
            return False
    return default


def _parse_int(value: object, default: int) -> int:
    """Convert permissive user-provided values into an integer."""

    if isinstance(value, bool):
        return default
    if isinstance(value, int):
        return value
    if isinstance(value, str):
        try:
            return int(value.strip())
        except ValueError:
            return default
    return default


def default_control_settings_path() -> Path:
    """Resolve the control settings file path from env override or OS defaults.

    Args:
        None.
    Returns:
        `Path` Absolute file path for persisted control settings JSON.
    Side Effects:
        Reads environment variable `REFORGE_CONTROL_CONFIG_PATH`.
    Raises:
        None.
    Preconditions:
        None.
    """

    override = os.environ.get(ENV_CONFIG_PATH, "").strip()
    if override:
        return Path(override).expanduser().resolve()

    if user_config_dir is not None:
        config_dir = user_config_dir(APP_NAME, APP_AUTHOR)
        return (Path(config_dir) / DEFAULT_CONFIG_FILENAME).expanduser().resolve()

    # Developer note (Codex): fallback keeps control startup working when
    # optional deps are unavailable in constrained robot environments.
    if os.name == "nt":
        appdata = os.environ.get("APPDATA", "").strip()
        base_dir = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
        return (base_dir / APP_NAME / DEFAULT_CONFIG_FILENAME).expanduser().resolve()

    if os.uname().sysname.lower() == "darwin":
        return (
            (
                Path.home()
                / "Library"
                / "Application Support"
                / APP_NAME
                / DEFAULT_CONFIG_FILENAME
            )
            .expanduser()
            .resolve()
        )

    xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "").strip()
    base_dir = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
    return (base_dir / APP_NAME / DEFAULT_CONFIG_FILENAME).expanduser().resolve()


@dataclass
class ControlSettings:
    """Represent persisted control settings consumed by the runtime control loop.

    Args:
        vibration_control_enabled: Toggle for vibration shaping in control calls.
        schema_version: Integer schema version for compatibility checks.
    Side Effects:
        None.
    Raises:
        None.
    Preconditions:
        None.
    """

    vibration_control_enabled: bool = True  # True means vibration shaping is enabled.
    schema_version: int = SCHEMA_VERSION  # Schema version stored in persisted JSON.


class ControlSettingsManager:
    """Load and persist vibration-control settings with thread-safe access.

    Args:
        config_path: Optional explicit JSON file path. When `None`, uses
            `default_control_settings_path()`.
    Side Effects:
        Reads and may create/write a JSON config file during initialization.
    Raises:
        OSError: If required file-system writes fail.
    Preconditions:
        Process has permission to read/write the resolved config path.
    """

    def __init__(self, config_path: Path | None = None) -> None:
        """Construct the settings manager and load or initialize persisted state.

        Args:
            config_path: Optional file path for persisted JSON settings.
        Returns:
            `None`.
        Side Effects:
            Initializes lock state and may create/write the settings file.
        Raises:
            OSError: If creating directories or writing defaults fails.
        Preconditions:
            The resolved path is valid for the running OS.
        """

        resolved_path = (
            config_path if config_path is not None else default_control_settings_path()
        )
        self._config_path = resolved_path.expanduser().resolve()
        self._lock = threading.Lock()
        self._settings = self._load_settings()

    @property
    def config_path(self) -> Path:
        """Return the resolved persisted settings path.

        Args:
            None.
        Returns:
            `Path` Absolute settings JSON path.
        Side Effects:
            None.
        Raises:
            None.
        Preconditions:
            None.
        """

        return self._config_path

    def get_vibration_enabled(self) -> bool:
        """Read the current in-memory vibration-control flag.

        Args:
            None.
        Returns:
            `bool` Current vibration-control enabled flag.
        Side Effects:
            None.
        Raises:
            None.
        Preconditions:
            None.
        """

        with self._lock:
            return self._settings.vibration_control_enabled

    def set_vibration_enabled(self, enabled: bool) -> None:
        """Update and persist the vibration-control flag.

        Args:
            enabled: Desired vibration-control state.
        Returns:
            `None`.
        Side Effects:
            Mutates in-memory settings and writes JSON to disk atomically.
        Raises:
            OSError: If file write/rename operations fail.
        Preconditions:
            Process has permission to write the settings file.
        """

        with self._lock:
            self._settings.vibration_control_enabled = bool(enabled)
            self._save_settings(self._settings)

    def to_dict(self) -> dict[str, object]:
        """Serialize current settings for API responses and diagnostics.

        Args:
            None.
        Returns:
            `dict[str, object]` Settings payload with schema and config path.
        Side Effects:
            None.
        Raises:
            None.
        Preconditions:
            None.
        """

        with self._lock:
            return {
                "schema_version": self._settings.schema_version,
                "vibration_control_enabled": self._settings.vibration_control_enabled,
                "config_path": str(self._config_path),
            }

    def _load_settings(self) -> ControlSettings:
        """Load persisted settings or initialize defaults when needed.

        Args:
            None.
        Returns:
            `ControlSettings` Loaded or default settings instance.
        Side Effects:
            Reads settings JSON and may overwrite invalid/missing files.
        Raises:
            OSError: If creating/writing default settings fails.
        Preconditions:
            None.
        """

        try:
            raw = json.loads(self._config_path.read_text(encoding="utf-8"))
            return self._deserialize_settings(raw)
        except FileNotFoundError:
            settings = ControlSettings()
            self._save_settings(settings)
            return settings
        except Exception:
            settings = ControlSettings()
            self._save_settings(settings)
            return settings

    def _deserialize_settings(self, raw: dict[str, object]) -> ControlSettings:
        """Create typed settings from a raw JSON dictionary.

        Args:
            raw: JSON object parsed from persisted settings file.
        Returns:
            `ControlSettings` Validated settings with fallback defaults.
        Side Effects:
            None.
        Raises:
            None.
        Preconditions:
            `raw` is a dictionary-like mapping from JSON.
        """

        return ControlSettings(
            vibration_control_enabled=_parse_bool(
                raw.get("vibration_control_enabled"), True
            ),
            schema_version=_parse_int(raw.get("schema_version"), SCHEMA_VERSION),
        )

    def _build_payload(self, settings: ControlSettings) -> dict[str, object]:
        """Build the persisted JSON payload for a settings snapshot.

        Args:
            settings: Settings snapshot to persist.
        Returns:
            `dict[str, object]` JSON-serializable payload.
        Side Effects:
            None.
        Raises:
            None.
        Preconditions:
            None.
        """

        return {
            "schema_version": int(settings.schema_version),
            "vibration_control_enabled": bool(settings.vibration_control_enabled),
            "updated_at_unix_s": time.time(),
        }

    def _save_settings(self, settings: ControlSettings) -> None:
        """Persist settings atomically to avoid partially-written JSON files.

        Args:
            settings: Settings snapshot to persist.
        Returns:
            `None`.
        Side Effects:
            Creates directories/files and writes to disk.
        Raises:
            OSError: If directory creation, fsync, or rename fails.
        Preconditions:
            Process has write permission to the target directory.
        """

        self._config_path.parent.mkdir(parents=True, exist_ok=True)
        payload = self._build_payload(settings)

        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=self._config_path.parent,
            prefix=".tmp_control_settings_",
            suffix=".json",
            delete=False,
        ) as temp_file:
            json.dump(payload, temp_file, indent=2, sort_keys=True)
            temp_file.flush()
            os.fsync(temp_file.fileno())
            temporary_path = Path(temp_file.name)

        # Developer note (Codex): `os.replace` is atomic on local filesystems.
        # This prevents corrupted JSON if power loss occurs mid-write.
        os.replace(temporary_path, self._config_path)
