"""Transaction-rollback sandbox for database resources.

Provides isolation through database transaction semantics
(``BEGIN`` / ``ROLLBACK`` / ``COMMIT``).  The concrete backend
uses Python's stdlib ``sqlite3`` module; PostgreSQL and MySQL
backends are stubs that raise ``NotImplementedError``.

Read-only mode is supported via SQLite's ``PRAGMA query_only``.

Implements the
:class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox`
protocol.

Lifecycle::

    sandbox = TransactionSandbox(resource_id, original_path)
    ctx = sandbox.create(plan_id)       # opens connection, BEGIN
    sandbox.execute("INSERT ...")        # within transaction
    result = sandbox.commit("msg")      # COMMIT
    sandbox.cleanup()                   # close connection

Based on:
    - implementation_plan.md group M7.post-resource-db
    - Sandbox protocol (B3.1)
"""

from __future__ import annotations

import contextlib
import logging
import sqlite3
from datetime import datetime

from ulid import ULID

from cleveragents.infrastructure.sandbox.protocol import (
    CommitResult,
    SandboxCommitError,
    SandboxContext,
    SandboxCreationError,
    SandboxRollbackError,
    SandboxStateError,
    SandboxStatus,
)

logger = logging.getLogger(__name__)


class TransactionSandbox:
    """Sandbox that isolates changes using database transactions.

    Opens a database connection and wraps all operations in a
    transaction.  ``commit()`` issues ``COMMIT``; ``rollback()``
    issues ``ROLLBACK``.

    Currently only SQLite is fully implemented via the stdlib
    ``sqlite3`` module.  The ``original_path`` parameter is
    interpreted as the SQLite database file path (or ``:memory:``).

    Implements the
    :class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox`
    protocol.
    """

    def __init__(
        self,
        resource_id: str,
        original_path: str,
        *,
        read_only: bool = False,
    ) -> None:
        """Initialise a transaction sandbox.

        Args:
            resource_id: Identifier of the database resource.
            original_path: Database file path or connection string.
            read_only: When ``True``, enforce read-only mode on the
                connection (SQLite ``PRAGMA query_only = ON``).

        Raises:
            ValueError: If *resource_id* is empty.
            ValueError: If *original_path* is empty.
        """
        if not resource_id:
            raise ValueError("resource_id cannot be empty")
        if not original_path:
            raise ValueError("original_path cannot be empty")

        self._sandbox_id: str = str(ULID())
        self._resource_id: str = resource_id
        self._original_path: str = original_path
        self._read_only: bool = read_only
        self._status: SandboxStatus = SandboxStatus.PENDING
        self._context: SandboxContext | None = None
        self._connection: sqlite3.Connection | None = None

    # -- protocol properties -------------------------------------------------

    @property
    def sandbox_id(self) -> str:
        """Unique identifier for this sandbox instance."""
        return self._sandbox_id

    @property
    def status(self) -> SandboxStatus:
        """Current lifecycle status."""
        return self._status

    @property
    def context(self) -> SandboxContext | None:
        """Context after creation, ``None`` before ``create``."""
        return self._context

    # -- protocol methods ----------------------------------------------------

    def create(self, plan_id: str) -> SandboxContext:
        """Open a database connection and begin a transaction.

        Args:
            plan_id: The plan that owns this sandbox.

        Returns:
            A :class:`SandboxContext` with metadata about the
            transaction sandbox.

        Raises:
            ValueError: If *plan_id* is empty.
            SandboxStateError: If not in ``PENDING`` status.
            SandboxCreationError: If the database connection fails.
        """
        if not plan_id:
            raise ValueError("plan_id cannot be empty")

        SandboxStatus.assert_transition(self._status, SandboxStatus.CREATED)

        try:
            self._connection = sqlite3.connect(
                self._original_path,
                isolation_level=None,  # manual transaction control
            )
            # Begin explicit transaction
            self._connection.execute("BEGIN")

            if self._read_only:
                self._connection.execute("PRAGMA query_only = ON")

        except sqlite3.Error as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxCreationError(
                f"Failed to open database for resource {self._resource_id}: {exc}"
            ) from exc

        self._context = SandboxContext(
            sandbox_id=self._sandbox_id,
            sandbox_path=self._original_path,
            original_path=self._original_path,
            resource_id=self._resource_id,
            plan_id=plan_id,
            created_at=datetime.now(),
            metadata={
                "strategy": "transaction_rollback",
                "read_only": self._read_only,
                "db_path": self._original_path,
            },
        )
        self._status = SandboxStatus.CREATED

        logger.info(
            "Created transaction sandbox: plan=%s resource=%s path=%s read_only=%s",
            plan_id,
            self._resource_id,
            self._original_path,
            self._read_only,
        )

        return self._context

    def get_path(self, resource_path: str) -> str:
        """Return the database path (resource_path is ignored for DB).

        For database sandboxes the ``resource_path`` argument is not
        meaningful in the filesystem sense.  This method returns the
        database file path for compatibility with the protocol.

        Args:
            resource_path: Ignored (kept for protocol compatibility).

        Returns:
            The database file path.

        Raises:
            SandboxStateError: If sandbox is not in a usable status.
            ValueError: If *resource_path* attempts directory traversal.
        """
        if self._status not in (
            SandboxStatus.CREATED,
            SandboxStatus.ACTIVE,
        ):
            raise SandboxStateError(
                f"Cannot resolve path in status {self._status.value}"
            )

        if ".." in resource_path.split("/"):
            raise ValueError(f"Path traversal not allowed: {resource_path}")

        if self._status == SandboxStatus.CREATED:
            self._status = SandboxStatus.ACTIVE

        return self._original_path

    def commit(self, message: str | None = None) -> CommitResult:
        """Commit the database transaction.

        Issues ``COMMIT`` on the underlying connection.

        Args:
            message: Optional log message.

        Returns:
            A :class:`CommitResult` describing the outcome.

        Raises:
            SandboxCommitError: If the COMMIT fails.
            SandboxStateError: If sandbox is not in a committable status.
        """
        if self._status not in (
            SandboxStatus.CREATED,
            SandboxStatus.ACTIVE,
        ):
            raise SandboxStateError(f"Cannot commit from status {self._status.value}")

        SandboxStatus.assert_transition(self._status, SandboxStatus.COMMITTED)

        try:
            if self._connection is not None:
                self._connection.execute("COMMIT")
        except sqlite3.Error as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxCommitError(
                f"Failed to commit transaction for sandbox {self._sandbox_id}: {exc}"
            ) from exc

        self._status = SandboxStatus.COMMITTED

        logger.info(
            "Committed transaction sandbox: sandbox_id=%s message=%s",
            self._sandbox_id,
            message,
        )

        return CommitResult(
            sandbox_id=self._sandbox_id,
            success=True,
            commit_ref=None,
            changed_files=[],
            added_files=[],
            deleted_files=[],
            error=None,
            timestamp=datetime.now(),
        )

    def rollback(self) -> None:
        """Roll back the database transaction.

        When rolling back from ``ACTIVE``, issues ``ROLLBACK`` on the
        underlying connection and begins a new transaction so the sandbox
        can be re-used.

        When rolling back from ``COMMITTED``, the database ``COMMIT``
        has already been issued and cannot be undone.  Raises
        :class:`SandboxRollbackError` so that the manager's atomic
        commit protocol correctly reports this sandbox in the
        ``failed_rollback_ids`` list rather than silently claiming
        success.

        Raises:
            SandboxRollbackError: If the ROLLBACK fails or if the
                sandbox is in ``COMMITTED`` state (irreversible).
            SandboxStateError: If called in an invalid status.
        """
        if self._status not in (SandboxStatus.ACTIVE, SandboxStatus.COMMITTED):
            raise SandboxStateError(f"Cannot rollback from status {self._status.value}")

        if self._status == SandboxStatus.COMMITTED:
            # Database COMMIT cannot be reversed.  Raise so the
            # manager's atomic commit protocol correctly reports this
            # sandbox as NOT rolled back rather than silently claiming
            # the rollback succeeded.
            raise SandboxRollbackError(
                f"Cannot undo database COMMIT for sandbox {self._sandbox_id} "
                f"— transaction changes are permanent"
            )

        SandboxStatus.assert_transition(self._status, SandboxStatus.ROLLED_BACK)

        try:
            if self._connection is not None:
                self._connection.execute("ROLLBACK")
                # Begin a new transaction so the sandbox can be reused
                self._connection.execute("BEGIN")
        except sqlite3.Error as exc:
            self._status = SandboxStatus.ERRORED
            raise SandboxRollbackError(
                f"Failed to rollback transaction for sandbox {self._sandbox_id}: {exc}"
            ) from exc

        self._status = SandboxStatus.ROLLED_BACK

        logger.info(
            "Rolled back transaction sandbox: sandbox_id=%s",
            self._sandbox_id,
        )

    def cleanup(self) -> None:
        """Close the database connection.

        Attempts a ROLLBACK before closing to discard any uncommitted
        changes.  Idempotent -- safe to call multiple times.

        Raises:
            SandboxError: On unexpected errors during cleanup.
        """
        if self._status == SandboxStatus.CLEANED_UP:
            return

        logger.debug(
            "Cleaning up transaction sandbox: sandbox_id=%s path=%s",
            self._sandbox_id,
            self._original_path,
        )

        if self._connection is not None:
            with contextlib.suppress(sqlite3.Error):
                # Discard any uncommitted work
                if self._status in (
                    SandboxStatus.CREATED,
                    SandboxStatus.ACTIVE,
                    SandboxStatus.ROLLED_BACK,
                ):
                    self._connection.execute("ROLLBACK")
            with contextlib.suppress(sqlite3.Error):
                self._connection.close()
            self._connection = None

        self._status = SandboxStatus.CLEANED_UP

        logger.info(
            "Cleaned up transaction sandbox: sandbox_id=%s",
            self._sandbox_id,
        )

    # -- database helper methods ---------------------------------------------

    def execute(
        self, sql: str, params: tuple[object, ...] = ()
    ) -> list[tuple[object, ...]]:
        """Execute a SQL statement within the sandbox transaction.

        This is a convenience method for database sandbox usage.
        It is not part of the Sandbox protocol.

        Args:
            sql: SQL statement to execute.
            params: Bind parameters.

        Returns:
            List of result rows (empty for non-SELECT statements).

        Raises:
            SandboxStateError: If sandbox is not in a usable status.
            sqlite3.Error: If the SQL execution fails.
        """
        if self._status not in (
            SandboxStatus.CREATED,
            SandboxStatus.ACTIVE,
        ):
            raise SandboxStateError(
                f"Cannot execute SQL in status {self._status.value}"
            )

        if self._status == SandboxStatus.CREATED:
            self._status = SandboxStatus.ACTIVE

        if self._connection is None:
            raise SandboxStateError("Database connection not available")

        cursor = self._connection.execute(sql, params)
        rows: list[tuple[object, ...]] = cursor.fetchall()
        return rows
