"""
For advanced usage, let's make sure you understand savepoints first.

About Savepoints
================

You shouldn't just think of savepoints as "named, nested commits",
because the terminology is slightly different.
You should get comfortable with the
`official sqlite documentation for savepoints <https://sqlite.org/lang_savepoint.html>`__.

Essentially, savepoints are a stack of frames, each holding some amount of database changes.
The first created savepoint is the "outermost" savepoint,
the most recently created savepoint is the "innermost" savepoint,
and the ones in between are "outer" and "inner" relative to each other.

When you want to okay your changes, instead of "committing", you "release" that frame.
If there's an outer frame, that means the changes are now "merged" into that frame,
like removing a fence sectioning off a field.
If the frame you released is already the outermost, last one, then those changes
are written to the database.

With plain ``BEGIN..COMMIT`` transactions, we tend to think of rollbacks as
"cancelling". And indeed, a vanilla ``ROLLBACK`` on its own will cancel everything.
But when we use ``rollback to SAVEPOINT_NAME``, it's more like
"restarting" that savepoint. All changes within that frame are discarded,
but the frame remains active! For clarity, we refer to this as
"rolling back to *after*" the savepoint.

You can still have the "cancellation" behavior by immediately releasing
a just-rolled-back savepoint. We call this "rolling back *before*" the savepoint.

Now that we understand how savepoints work in sqlite,
let's see how they work in coolqlite.

Advanced Usage
==============

.. warning:: This part of the API is somewhat experimental.

We try to map an active savepoint to a context manager with-block 1:1.

This means that if you do anything that would make the savepoint
no longer active (releasing it, rolling back before it,
rolling back before or after an outer savepoint),
this should be viewed as an "early exit" from that with-block.

This is accomplished with a :exc:`TransactionControlException`.

.. admonition:: Isn't it bad to use exceptions for control flow?

    Because Exceptions aren't reflected in a function's signature
    in many languages, using them for control flow is considered
    *surprising* and *hidden* behavior.

    Here it's not as hidden and surprising, because this is only ever raised
    by methods you yourself called in ``ALL_CAPS``.
    Also, control flow only ever jumps to after the with-block holding
    the context manager you called that method on.

    Also, it is the only way to model a conditional early exit from a
    context manager-- that sounds a lot like what an exception does to me.

    Just think of it as a special ``return`` that is scoped to that
    with-block instead of a function.

.. warning:: This means it is **always incorrect** to catch a
    :exc:`TransactionControlException` without re-raising it.

    You may need to handle this specially if writing your own
    context manager or catching exceptions that happen within
    a :class:`Savepoint` context manager.

An innermost savepoint can do the following:

1. Roll back to *after* the savepoint was started,
   discarding all changes since then,
   but staying within the savepoint.

2. Roll back to *before* the savepoint was started,
   discarding all changes since then,
   and *exiting* / erasing that savepoint

3. Release the savepoint, committing changes to the database
   and *exiting* / erasing this savepoint.

Following the 1:1 rule, this means that 2 and 3 should exit
the with-block, but 1 should stay in it.

With this in mind, let's do a similar database setup.

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: setup
    :end-before: # endregion setup


Scenario 1, rolling back to after, is straightforward:

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: rollback after
    :end-before: # endregion rollback after

You'll understand why the method is called that later.


Scenario 2, rolling back before, is a little more unusual.
Since it means exiting the savepoint, we exit the with-block
early with an exception. Because that happens, the method is
in ``ALL_CAPS`` to call out how it's different.

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: rollback before
    :end-before: # endregion rollback before


Scenario 3, releasing (like committing), is similar.
Because it means you're "exiting" the savepoint,
we exit the with-block early again with an exception.

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: early release
    :end-before: # endregion early release


Nested Savepoints
-----------------

A savepoint that isn't the innermost can do all of the above,
with one caveat. Let's go backwards.

Scenario 3, releasing, works similarly when called on an
outer savepoint.

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: nested release
    :end-before: # endregion nested release

Scenario 2, rolling back before, also similar.

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: nested rollback before
    :end-before: # endregion nested rollback before

Scenario 1, rolling back after, is where things get tricky.

If you're nested within an outer savepoint, what would it mean to
rollback after it? The outer savepoint keeps going, sure, but
that means your nested one should be erased. Sure enough, that means
the inner one needs to exit early with an exception.

Note that we're calling this method on the savepoint we want to
reset-after *to*, not the inner nested one!

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: nested release after
    :end-before: # endregion nested release after

Caveat: ON CONFLICT ROLLBACK
============================

There is one tricky case that we're still figuring out how to handle well.

We mentioned how an unqualified ``ROLLBACK`` will tear down all savepoints.
Well, that applies to rollbacks that happen from ``ON CONFLICT ROLLBACK``
triggers on unique columns, as well as ``INSERT OR ROLLBACK`` queries.

.. TODO:: verify the insert part. Is has to be the same, right?

Recall that in our example we made the name column
``UNIQUE ON CONFLICT ROLLBACK``.

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: good rollback from violation
    :end-before: # endregion good rollback from violation

This is handled correctly by the exception handler for the savepoint
context manager. In fact, only one of them needs to see it to properly
invalidate all of the others!

But, what if you somehow kept going in a savepoint with-block?

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: complication from rollback
    :end-before: # endregion complication from rollback

.. TODO:: Improve the error message on that.

An invalidated savepoint context manager knows to not touch anything
on exit. But we can't stop you from using the methods.
For this reason, we encourage you to let those errors bubble up
to the outermost savepoint.

We may offer a method to deliberately "jump" to the outermost transaction
when this happens, as we can more or less detect when this happens.
But I'm still thinking about the API for that.

But even worse is when the context manager doesn't see it at all:

.. literalinclude:: /../examples/savepoints_advanced.py
    :start-after: # region: oh god it's even worse
    :end-before: # endregion oh god it's even worse

.. TODO:: I think I can handle that better with similar detection techniques.
"""

import typing
import sqlite3 as sql3
from functools import wraps
from typing import TYPE_CHECKING, Any, Never, Self

from coolqlite._common import _DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK, CoolqliteError
from coolqlite.transactions import _SavepointStatus, IncorrectTransactionControlError


if TYPE_CHECKING:
    from coolqlite._connection import Connection


class TransactionControlException(CoolqliteError):
    """An exception used when an operation on a savepoint
    should alter control flow.

    .. warning:: It is **always** incorrect to catch this
       without re-raising it.

       You may need to handle this specially if writing your own
       context manager or catching exceptions that happen within
       a :class:`Savepoint` context manager.

    .. admonition:: Isn't it bad to use exceptions for control flow?

       Because Exceptions aren't reflected in a function's signature
       in many languages, it's considered to be surprising hidden behavior.

       It's not that hidden or surprising, because this is only ever raised
       by methods you yourself called in ``ALL_CAPS``.

       Also, it is the only way to model a conditional early exit from a
       context manager-- that sounds a lot like what an exception does to me.

    .. warning:: To show that this is special, we may change this to inherit
        from :exc:`BaseException` in the future.
    """

    # Oh yeah, three admonitions. That's how you know it's a good idea.

    pass


class _RollbackToAfterNotInnermost(TransactionControlException):
    __match_args__ = ("id_",)

    def __init__(self, id: str):
        super().__init__()
        self.id_ = id

    def __repr__(self):  # pragma: no cover
        return f"<ROLLING BACK TO AFTER (not innermost): {self.id_}>"


class _RollbackToBefore(TransactionControlException):
    __match_args__ = ("id_",)

    def __init__(self, id: str):
        super().__init__()
        self.id_ = id

    def __repr__(self):  # pragma: no cover
        return f"<ROLLING BACK TO BEFORE: {self.id_}>"


class _Release(TransactionControlException):
    __match_args__ = ("id_",)

    def __init__(self, id: str):
        super().__init__()
        self.id_ = id

    def __repr__(self):  # pragma: no cover
        return f"<RELEASING: {self.id_}>"


def _must_be_active(fn):
    @wraps(fn)
    def _active_check(self: AdvancedSavepoint, *args, **kwargs):
        if self._status is not _SavepointStatus.ACTIVE:
            raise IncorrectTransactionControlError(
                "Attempted to call a savepoint method on a savepoint"
                f"that was not active, but rather was {self._status}"
            )
        return fn(self, *args, **kwargs)

    return _active_check


class AdvancedSavepoint:
    """A context manager representing a sqlite savepoint.

    Create with the :meth:`coolqlite.Connection.with_savepoint` method.

    .. warning:: *Only* use this as a context manager,
       the methods assume you are and things will break if you don't.

    .. warning:: Methods in ``ALL_CAPS`` use
        :exc:`TransactionControlException` to work and enforce correctness.

        Understand then when writing code within the ``with`` block,
        using other context managers that interact with exceptions,
        and when using ``try``/``except``/``finally``.

        In other words, the exceptions they throw are not *problems*,
        unless you mishandle them.

        The methods are properly type-annotated, so tools should show
        that code that follows is unreachable.

        See the :mod:`module docs <coolqlite.transactions>`
        for details.
    """

    def __init__(
        self,
        private,
        id: str,
        connec: Connection[Any],
        parent: AdvancedSavepoint | None,
    ):
        assert private is _DONT_INIT_YOURSELF_OR_THINGS_WILL_BREAK
        self._id = id
        self._status: _SavepointStatus = _SavepointStatus.JUST_BORN
        self._connec = connec
        self._parent = parent

    # note: exceptions raised in enter do not trigger exit
    # (what would they even call it on? not all managers return self)
    def __enter__(self) -> Self:
        match self._status:
            case _SavepointStatus.JUST_BORN:
                self._status = _SavepointStatus.ACTIVE
                return self
            case _SavepointStatus.ACTIVE:
                raise IncorrectTransactionControlError(
                    "tried to enter the same savepoint more than once"
                )
            case _SavepointStatus.DEAD:
                raise IncorrectTransactionControlError(
                    "tried to enter an already-used savepoint"
                )

    def __exit__(self, _exc_type, exc_val: BaseException | None, _tb) -> bool:
        # I guess we also might want an exception for
        # "I know we're invalidated just get me out of here".
        dead = self._status is _SavepointStatus.DEAD
        match (dead, exc_val):
            case (True, None):
                return True
            case (False, None):
                self._do_release()
                return True
            case (True, TransactionControlException()):
                raise IncorrectTransactionControlError(
                    "Somehow got a transaction control exception after being invalidated"
                )
            case (False, _Release(id_)):
                self._do_release()
                # if this was for us, then we're done.
                return id_ == self._id
            case (False, _RollbackToBefore(id_=self._id)):
                self._do_rollback_before()
                return True
            case (False, _RollbackToAfterNotInnermost(id_)) if (
                self._parent is not None and id_ == self._parent._id
            ):
                self._do_rollback_before()
                self._parent._do_rollback_after()
                return True
            case (False, _RollbackToAfterNotInnermost(id_=self._id)):
                raise IncorrectTransactionControlError(
                    "This method should only be called on non-innermost transactions"
                )
            case (True, _):
                # some other exception while we're dead
                return False
            case (False, sql3.IntegrityError()) if (
                not self._connec._connec.in_transaction
            ):
                # this was a ON CONFLICT ROLLBACK,
                # so all savepoints are now invalidated.
                self._connec._invalidate_all_savepoints_due_to_on_conflict_rollback()
                return False
            case (False, _):
                # either a third-party exception
                # or a TransactionControl that wasn't for us.
                # in either case, rollback-to-before
                # but keep it going.
                self._do_rollback_before()
                return False
            case unreachable:  # pragma: no cover
                # TODO: pyright correctly reduces tuple[Never, Never] to Never,
                # mypy doesn't. Hmm.
                typing.assert_never(unreachable)

    def _do_release(self):
        self._connec._release(self._id)
        self._status = _SavepointStatus.DEAD

    def _do_rollback_before(self):
        self._connec._rollback_to_before(self._id)
        self._status = _SavepointStatus.DEAD

    def _do_rollback_after(self):
        # connection will raise the rror if this isn't the innermost
        self._connec._rollback_to_after(self._id)

    @_must_be_active
    def rollback_to_after_this_innermost(self):
        """Roll back this, the innermost savepoint,
        to after it started.
        This will undo all changes since then.

        Note that this doesn't "re-run" the context manager block,
        it merely discards previous changes that happened within.

        .. warning::
            Using this on anything but the innermost savepoint
            is incorrect and will raise an
            :exc:`IncorrectTransactionControlError`.

            If you wish to use this on an outer savepoint,
            see :meth:`ROLLBACK_TO_AFTER_NOT_INNERMOST`.
        """
        self._do_rollback_after()

    @_must_be_active
    def ROLLBACK_TO_AFTER_NOT_INNERMOST(self) -> Never:
        """Roll back this, an outer savepoint,
        to after it started.
        Specifically, this will raise an exception
        that is caught by the context manager one level
        within this one.

        Note that this doesn't "re-run" the context manager block,
        it merely discards previous changes that happened within.

        See the :mod:`module docs <coolqlite.transactions>`
        for details.

        .. warning::
            Using this on anything but an outer savepoint
            is incorrect and will raise an
            :exc:`IncorrectTransactionControlError`.

            If you wish to use this on an inner savepoint,
            see :meth:`rollback_to_after_this_innermost`.
        """
        raise _RollbackToAfterNotInnermost(self._id)

    @_must_be_active
    def ROLLBACK_TO_BEFORE(self) -> Never:
        """Roll back before this savepoint.
        Specifically, this will raise an exception
        that is caught by this context manager.

        See the :mod:`module docs <coolqlite.transactions>`
        for details.
        """
        raise _RollbackToBefore(self._id)

    @_must_be_active
    def RELEASE(self) -> Never:
        """Release this savepoint.
        If there are outer savepoints,
        this "merges" the changes into them.
        If this is the outermost savepoint,
        this commits changes to the database.
        Specifically, this will raise an exception
        that is caught by this context manager.

        See the :mod:`module docs <coolqlite.transactions>`
        for details.
        """
        raise _Release(self._id)


_OLD_TODOS = """
TODO: if each savepoint keeps a reference to its parent,
then it can "prime" them for receiving the exception.
if on or method call it doesn't, then that means something swallowed
it and that's an IncorrectError!

TODO: proper del behavior. should probably be an error if active?

TODO: don't forget to check for generic sql errors
when doing an operation, just in case there was an
`ON CONFLICT ROLLBACK`.
but in the methods that handle it.
or do we make note of it here
so we might guess that something is tainted?
eh would be error prone...
ughhhh what's worse or what's better.
for now: explicit is better than implicit.
if you don't deactivate these yourself,
that's an error.

TODO: can we somehow handle an integrity error ourselves?
we probably don't know if it's from an abort
or a rollback.
unless we check right after, right?
if they caught it, on the next savepoint-related call,
we can check.

TODO: clarify in docs that caps methods use exceptions.
"""

__all__ = [
    "AdvancedSavepoint",
    "TransactionControlException",
    "IncorrectTransactionControlError",
]
