"""Setup service.

Implements the one-time first-run configuration wizard.  Responsible for
hashing the master password, persisting all initial settings, and
enforcing the rule that setup can only run once.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import bcrypt
from app.utils.logging_compat import get_logger

from app.db import init_db, open_db
from app.repositories import settings_repo as default_settings_repo
from app.utils.async_utils import run_blocking

if TYPE_CHECKING:
    import aiosqlite

    from app.repositories.protocols import SettingsRepository
    from app.services.protocols import Fail2BanMetadataService

log = get_logger(__name__)

# Keys used in the settings table.
_KEY_PASSWORD_HASH = "master_password_hash"
_KEY_SETUP_DONE = "setup_completed"
_KEY_SETUP_STATE = "setup_state"
_KEY_DATABASE_PATH = "database_path"
_KEY_FAIL2BAN_SOCKET = "fail2ban_socket"
_KEY_TIMEZONE = "timezone"
_KEY_SESSION_DURATION = "session_duration_minutes"
_KEY_MAP_COLOR_THRESHOLD_HIGH = "map_color_threshold_high"
_KEY_MAP_COLOR_THRESHOLD_MEDIUM = "map_color_threshold_medium"
_KEY_MAP_COLOR_THRESHOLD_LOW = "map_color_threshold_low"

# Setup state transitions: None → "in_progress" → "complete"
_SETUP_STATE_IN_PROGRESS = "in_progress"
_SETUP_STATE_COMPLETE = "complete"


async def is_setup_complete(
    db: aiosqlite.Connection,
    settings_repo: SettingsRepository = default_settings_repo,
) -> bool:
    """Return ``True`` if initial setup has already been performed.

    Args:
        db: Active aiosqlite connection.
        settings_repo: Repository interface for settings persistence.

    Returns:
        ``True`` when the ``setup_completed`` key exists in settings.
    """
    value = await settings_repo.get_setting(db, _KEY_SETUP_DONE)
    return value == "1"


async def run_setup(
    db: aiosqlite.Connection,
    *,
    master_password: str,
    database_path: str,
    fail2ban_socket: str,
    timezone: str,
    session_duration_minutes: int,
    settings_repo: SettingsRepository = default_settings_repo,
) -> None:
    """Persist the initial configuration and mark setup as complete.

    Executes in three transactional phases to ensure atomicity across the
    bootstrap and runtime databases:
    1. Bootstrap DB: marks setup as "in_progress" and records database_path
    2. Runtime DB: writes all configuration settings atomically
    3. Bootstrap DB: marks setup as "complete"

    If any phase fails, the entire operation can be safely retried. The
    setup_state key allows detection of partial setup states for cleanup.

    Raises:
        RuntimeError: If setup has already been completed, or if runtime DB
            initialization fails.

    Args:
        db: Active aiosqlite connection to the bootstrap database.
        master_password: Plain-text master password chosen by the user.
        database_path: Filesystem path to the BanGUI SQLite database.
        fail2ban_socket: Unix socket path for the fail2ban daemon.
        timezone: IANA timezone identifier (e.g. ``"UTC"``).
        session_duration_minutes: Session validity period in minutes.
        settings_repo: Repository interface for settings persistence.
    """
    # Check if setup is already complete.
    if await is_setup_complete(db, settings_repo=settings_repo):
        raise RuntimeError("Setup has already been completed.")

    log.info("bangui_setup_started")

    # PHASE 0: Compute password hash early (before any DB writes).
    # This ensures the same hash value is used throughout retries,
    # making setup truly idempotent. Bcrypt uses a random salt, so
    # computing this fresh on each retry would create divergent hashes.
    password_bytes = master_password.encode()
    hashed: str = await run_blocking(
        lambda: bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode()
    )
    log.info("bangui_setup_password_hashed")

    # PHASE 1: Bootstrap DB transaction
    # Mark setup as in-progress and record the runtime database path.
    # This is our first commit point — if we get here, the runtime DB
    # MUST be initialized or setup is in an incomplete state.
    try:
        await db.execute("BEGIN IMMEDIATE;")
        await db.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
            (_KEY_SETUP_STATE, _SETUP_STATE_IN_PROGRESS),
        )
        await db.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
            (_KEY_DATABASE_PATH, database_path),
        )
        await db.commit()
    except Exception as e:
        await db.rollback()
        log.error(
            "bangui_setup_failed",
            reason="bootstrap_db_transaction_failed",
            error=str(e),
        )
        raise RuntimeError("Failed to initialize setup state in bootstrap database.") from e

    log.info("bangui_setup_bootstrap_phase_complete", database_path=database_path)

    # PHASE 2: Initialize runtime database.
    # This is outside a transaction because it involves filesystem operations.
    # If this fails, setup_state remains "in_progress" in bootstrap DB, allowing retry.
    runtime_initialized = await _ensure_database_initialized(database_path)
    if not runtime_initialized:
        log.error(
            "bangui_setup_failed",
            reason="runtime_database_initialization_failed",
            database_path=database_path,
        )
        raise RuntimeError("Runtime database could not be initialized.")

    log.info("bangui_setup_runtime_db_initialized", database_path=database_path)

    # PHASE 3: Runtime DB transaction
    # Write all runtime settings atomically. If any setting fails, the entire
    # batch is rolled back. This ensures no partial configuration state.
    runtime_db: aiosqlite.Connection | None = None
    try:
        runtime_db = await open_db(database_path)

        # Prepare all runtime settings for atomic batch write.
        runtime_settings = {
            _KEY_PASSWORD_HASH: hashed,
            _KEY_DATABASE_PATH: database_path,
            _KEY_FAIL2BAN_SOCKET: fail2ban_socket,
            _KEY_TIMEZONE: timezone,
            _KEY_SESSION_DURATION: str(session_duration_minutes),
            _KEY_MAP_COLOR_THRESHOLD_HIGH: "100",
            _KEY_MAP_COLOR_THRESHOLD_MEDIUM: "50",
            _KEY_MAP_COLOR_THRESHOLD_LOW: "20",
        }

        await settings_repo.set_settings_batch(runtime_db, runtime_settings)
        log.info("bangui_setup_runtime_settings_written")

    except Exception as e:
        log.error(
            "bangui_setup_failed",
            reason="runtime_db_transaction_failed",
            error=str(e),
        )
        raise RuntimeError("Failed to write settings to runtime database.") from e
    finally:
        if runtime_db is not None:
            await runtime_db.close()

    # PHASE 4: Bootstrap DB transaction (final)
    # Mark setup as complete. This is the final commit point — once this
    # succeeds, setup is fully complete. If this fails, setup_state remains
    # "in_progress" but can be detected on retry and cleaned up if needed.
    try:
        await db.execute("BEGIN IMMEDIATE;")
        await db.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
            (_KEY_SETUP_STATE, _SETUP_STATE_COMPLETE),
        )
        await db.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
            (_KEY_SETUP_DONE, "1"),
        )
        await db.commit()
    except Exception as e:
        await db.rollback()
        log.error(
            "bangui_setup_failed",
            reason="bootstrap_db_final_transaction_failed",
            error=str(e),
        )
        raise RuntimeError("Failed to mark setup as complete.") from e

    log.info("bangui_setup_completed")


async def get_runtime_database_path(
    db: aiosqlite.Connection,
    settings_repo: SettingsRepository = default_settings_repo,
) -> str | None:
    """Return the runtime database path persisted during initial setup."""
    return await settings_repo.get_setting(db, _KEY_DATABASE_PATH)


async def get_persisted_runtime_settings(
    db: aiosqlite.Connection,
    settings_repo: SettingsRepository = default_settings_repo,
) -> dict[str, str | int]:
    """Return runtime configuration values persisted during initial setup."""
    runtime_settings: dict[str, str | int] = {}

    database_path = await settings_repo.get_setting(db, _KEY_DATABASE_PATH)
    if database_path:
        runtime_settings["database_path"] = database_path

    fail2ban_socket = await settings_repo.get_setting(db, _KEY_FAIL2BAN_SOCKET)
    if fail2ban_socket:
        runtime_settings["fail2ban_socket"] = fail2ban_socket

    timezone = await settings_repo.get_setting(db, _KEY_TIMEZONE)
    if timezone:
        runtime_settings["timezone"] = timezone

    session_duration = await settings_repo.get_setting(db, _KEY_SESSION_DURATION)
    if session_duration is not None:
        try:
            runtime_settings["session_duration_minutes"] = int(session_duration)
        except ValueError:
            log.warning(
                "invalid_setup_setting",
                key=_KEY_SESSION_DURATION,
                value=session_duration,
            )

    return runtime_settings


async def _ensure_database_initialized(database_path: str) -> bool:
    """Create and initialise the configured runtime database if it does not exist."""
    database_path_obj = Path(database_path)
    parent_dir = database_path_obj.parent

    try:
        await run_blocking(parent_dir.mkdir, parents=True, exist_ok=True)
    except PermissionError:
        log.warning(
            "cannot_create_runtime_database_parent",
            database_path=database_path,
            parent=str(parent_dir),
        )
        return False

    db = await open_db(str(database_path_obj))
    try:
        await init_db(db)
    finally:
        await db.close()
    return True


async def get_timezone(
    db: aiosqlite.Connection,
    settings_repo: SettingsRepository = default_settings_repo,
) -> str:
    """Return the configured IANA timezone string."""
    tz = await settings_repo.get_setting(db, _KEY_TIMEZONE)
    return tz if tz else "UTC"


async def get_fail2ban_db_path(
    db: aiosqlite.Connection,
    settings_repo: SettingsRepository = default_settings_repo,
    fail2ban_metadata_service: Fail2BanMetadataService | None = None,
) -> str | None:
    """Resolve the fail2ban database path from the configured socket.

    Args:
        db: Active database connection (used to look up the socket path).
        settings_repo: Repository interface for settings persistence.
        fail2ban_metadata_service: Service for resolving DB path from socket.
            If not provided, uses the default singleton.

    Returns:
        The resolved fail2ban SQLite database path, or None if fail2ban
        is not reachable.
    """
    socket_path = await settings_repo.get_setting(db, _KEY_FAIL2BAN_SOCKET)
    if not socket_path:
        return None

    if fail2ban_metadata_service is None:
        from app.services.fail2ban_metadata_service import (  # noqa: PLC0415
            default_fail2ban_metadata_service,
        )
        service = default_fail2ban_metadata_service
    else:
        service = fail2ban_metadata_service

    try:
        return await service.get_db_path(socket_path)
    except Exception:
        log.warning("could_not_resolve_fail2ban_db_path", socket_path=socket_path)
        return None


