"""Resolution and opening of the local (on-disk) store locations.

This module only resolves filesystem locations and opens the local SQLite store.
It never grants permissions, never changes the policy mode, and never disables
the sandbox. Authorization, policy decisions, and sandbox enforcement remain
the responsibility of the security plane.

Precedence for the data directory is: explicit argument, then the
``CYBERBRAIN_DATA_DIR`` environment variable, then the platform-specific user
data directory. Precedence for the database file is: explicit argument, then
``CYBERBRAIN_DB_PATH``, then ``<data dir>/cyberbrain.sqlite3``.

The workspaces directory (``<data dir>/workspaces``) is the *sanctioned root*
for host directories that the execution sandbox may bind read-write.
:func:`resolve_workspace` is the single containment check against that root: a
workspace outside it is refused, because a read-write bind mount of an
arbitrary host directory would hand sandboxed code the whole host regardless
of how well the rest of the sandbox is hardened.

Retention configuration is fail-closed: a malformed ``CYBERBRAIN_RETENTION_DAYS``
value raises :class:`ValueError` instead of silently falling back to the default.
"""

from __future__ import annotations

import logging
import os
from pathlib import Path

from platformdirs import user_data_dir

from cyberbrain26.data_plane.sqlite_store import SQLiteDatabase

__all__ = [
    "DEFAULT_DB_FILENAME",
    "DEFAULT_RETENTION_DAYS",
    "ENV_DATA_DIR",
    "ENV_DB_PATH",
    "ENV_RETENTION_DAYS",
    "ENV_WORKSPACES_ROOT",
    "ensure_data_dir",
    "ensure_workspaces_root",
    "open_database",
    "resolve_data_dir",
    "resolve_db_path",
    "resolve_workspace",
    "retention_days_from_env",
    "workspaces_root",
]

logger = logging.getLogger(__name__)

ENV_DATA_DIR: str = "CYBERBRAIN_DATA_DIR"
ENV_DB_PATH: str = "CYBERBRAIN_DB_PATH"
ENV_RETENTION_DAYS: str = "CYBERBRAIN_RETENTION_DAYS"
ENV_WORKSPACES_ROOT: str = "CYBERBRAIN_WORKSPACES_ROOT"

DEFAULT_DB_FILENAME: str = "cyberbrain.sqlite3"
DEFAULT_RETENTION_DAYS: int = 90

_WORKSPACES_DIRNAME: str = "workspaces"


def _expand(path: str | Path) -> Path:
    """Expand ``~`` and environment references without touching the filesystem."""
    return Path(os.path.expandvars(os.path.expanduser(str(path))))


def resolve_data_dir(explicit: str | Path | None = None) -> Path:
    """Resolve the base data directory.

    Precedence: ``explicit`` argument, then the ``CYBERBRAIN_DATA_DIR``
    environment variable, then :func:`platformdirs.user_data_dir`. The returned
    path is absolute and expanded, but the directory is **not** created as a
    side effect of this call; creation happens in :func:`ensure_data_dir` or
    :func:`open_database`.

    Args:
        explicit: Optional explicit directory overriding all other sources.

    Returns:
        The resolved, absolute data directory path. It need not exist.
    """
    if explicit is not None:
        return _expand(explicit).resolve()
    env_value = os.environ.get(ENV_DATA_DIR)
    if env_value is not None and env_value.strip():
        return _expand(env_value.strip()).resolve()
    return _expand(user_data_dir("CyberBrain26")).resolve()


def ensure_data_dir(data_dir: str | Path | None = None) -> Path:
    """Resolve the data directory and create it with owner-only permissions.

    Args:
        data_dir: Optional explicit directory; see :func:`resolve_data_dir`.

    Returns:
        The resolved data directory, created with mode ``0o700`` where the
        platform supports it.
    """
    resolved = resolve_data_dir(data_dir)
    existed = resolved.exists()
    resolved.mkdir(parents=True, exist_ok=True, mode=0o700)
    if not existed:
        try:
            resolved.chmod(0o700)
        except OSError:
            pass
    return resolved


def resolve_db_path(
    explicit: str | Path | None = None,
    *,
    data_dir: str | Path | None = None,
) -> Path:
    """Resolve the SQLite database file path.

    Precedence: ``explicit`` argument, then the ``CYBERBRAIN_DB_PATH``
    environment variable, then ``<data dir>/cyberbrain.sqlite3``. A non-``None``
    ``data_dir`` argument takes precedence over the ``CYBERBRAIN_DATA_DIR``
    environment variable but never overrides an explicit ``explicit`` path.

    Args:
        explicit: Optional explicit database file path with highest precedence.
        data_dir: Optional data directory used to derive the default filename.

    Returns:
        The resolved, absolute database path. It need not exist.
    """
    if explicit is not None:
        # Reject URI/DSN paths before resolving - they would become real files
        if str(explicit) == ":memory:" or str(explicit).startswith("file:"):
            raise ValueError("SQLiteDatabase requires a persistent file path")
        return _expand(explicit).resolve()
    env_value = os.environ.get(ENV_DB_PATH)
    if env_value is not None and env_value.strip():
        candidate = env_value.strip()
        # Same guard as `explicit` above, and the same two shapes: an in-memory
        # database has no persistent file, and an URI/DSN would become a real
        # file named after the DSN once ``resolve()`` prefixes it with the cwd.
        if candidate == ":memory:" or candidate.startswith("file:"):
            raise ValueError("SQLiteDatabase requires a persistent file path")
        return _expand(candidate).resolve()
    return (resolve_data_dir(data_dir) / DEFAULT_DB_FILENAME).resolve()


def retention_days_from_env(default: int = DEFAULT_RETENTION_DAYS) -> int:
    """Read the retention window from the environment, fail-closed.

    Unset or empty values yield ``default``. Any present value that is not a
    positive integer raises :class:`ValueError`; a malformed value never falls
    back to the default silently.

    Args:
        default: Value returned when the environment variable is unset or empty.

    Returns:
        The configured retention window in days.

    Raises:
        ValueError: If the variable is set to a non-integer or non-positive
            value.
    """
    raw = os.environ.get(ENV_RETENTION_DAYS)
    if raw is None or not raw.strip():
        return default
    text = raw.strip()
    try:
        value = int(text)
    except ValueError as exc:
        raise ValueError(
            f"{ENV_RETENTION_DAYS} must be a positive integer, got {raw!r}"
        ) from exc
    if value <= 0:
        raise ValueError(f"{ENV_RETENTION_DAYS} must be positive, got {raw!r}")
    return value


def open_database(
    *,
    db_path: str | Path | None = None,
    data_dir: str | Path | None = None,
    persist: bool = True,
    retention_days: int | None = None,
) -> SQLiteDatabase | None:
    """Open the local SQLite store, creating its parent directory if needed.

    When ``persist`` is ``False`` the call is a no-op that touches no files and
    no directories; it returns ``None`` instead of a database handle.

    Args:
        db_path: Optional explicit database path; see :func:`resolve_db_path`.
        data_dir: Optional data directory used when ``db_path`` is not given.
        persist: When ``False``, return ``None`` without touching the filesystem.
        retention_days: Optional explicit retention window. Defaults to
            :func:`retention_days_from_env`.

    Returns:
        The opened :class:`SQLiteDatabase`, or ``None`` when ``persist`` is
        ``False``.

    Raises:
        ValueError: If the retention window is malformed or non-positive.
    """
    if not persist:
        return None
    path = resolve_db_path(db_path, data_dir=data_dir)
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    days = retention_days_from_env() if retention_days is None else retention_days
    return SQLiteDatabase(path, retention_days=days)


def workspaces_root(data_dir: str | Path | None = None) -> Path:
    """Return the workspaces directory inside the data directory.

    The directory is **not** created here; callers create it lazily with
    :func:`ensure_workspaces_root`.

    The sanctioned root is deliberately derived from :func:`resolve_data_dir`
    instead of being configured separately, so there is exactly one source of
    truth for "where CyberBrain26 stores its things". Deployments that need a
    different root set ``CYBERBRAIN_WORKSPACES_ROOT`` (or pass the root
    explicitly to the caller); that is an explicit decision to widen the
    sandbox, never a silent fallback.

    Args:
        data_dir: Optional explicit data directory; see :func:`resolve_data_dir`.

    Returns:
        The resolved ``<data dir>/workspaces`` path. It need not exist.
    """
    return (resolve_data_dir(data_dir) / _WORKSPACES_DIRNAME).resolve()


def ensure_workspaces_root(data_dir: str | Path | None = None) -> Path:
    """Resolve the workspaces root and create it with owner-only permissions.

    Args:
        data_dir: Optional explicit data directory; see :func:`resolve_data_dir`.

    Returns:
        The resolved workspaces root, created with mode ``0o700`` where the
        platform supports it.
    """
    resolved = workspaces_root(data_dir)
    existed = resolved.exists()
    resolved.mkdir(parents=True, exist_ok=True, mode=0o700)
    if not existed:
        try:
            resolved.chmod(0o700)
        except OSError as exc:
            # A directory holding host-writable sandbox workspaces must not be
            # left group- or world-readable by accident, so the failure to
            # tighten it is reported rather than swallowed silently.
            logger.warning(
                "could not restrict permissions of workspaces root %s: %s", resolved, exc
            )
    return resolved


def is_within_workspaces_root(path: str | Path, root: str | Path | None = None) -> bool:
    """Report whether ``path`` resolves inside the sanctioned workspaces root.

    Both sides are resolved, so symlinks and ``..`` segments are collapsed
    before the comparison: a symlink inside the root that points at ``/etc``
    resolves to ``/etc`` and is therefore *not* contained.

    Args:
        path: Candidate path, typically a workspace taken from a step payload.
        root: Optional root to compare against; defaults to
            :func:`workspaces_root`.

    Returns:
        ``True`` when the resolved path is the root itself or below it.
    """
    allowed = Path(root).expanduser().resolve() if root is not None else workspaces_root()
    candidate = _expand(path).resolve()
    return candidate == allowed or candidate.is_relative_to(allowed)


def resolve_workspace(workspace: str | Path, *, root: str | Path | None = None) -> Path:
    """Resolve a workspace path and refuse it when it escapes the workspaces root.

    This is the containment check every read-write workspace mount has to pass.
    It is deliberately small and lives next to :func:`workspaces_root` so that
    the sandbox provider and any future policy check share one definition of
    "a workspace we are willing to hand to sandboxed code".

    Args:
        workspace: Candidate workspace path from a step payload.
        root: Optional root to confine to; defaults to :func:`workspaces_root`.

    Returns:
        The resolved workspace path, which is inside ``root``.

    Raises:
        ValueError: If the resolved workspace is outside ``root``. The message
            names both the offending path and the allowed root so the refusal
            is auditable.
    """
    allowed = Path(root).expanduser().resolve() if root is not None else workspaces_root()
    candidate = _expand(workspace).resolve()
    if candidate != allowed and not candidate.is_relative_to(allowed):
        raise ValueError(
            f"workspace {candidate} is outside the allowed workspaces root {allowed}; "
            "refusing to bind-mount it read-write"
        )
    return candidate
