import pytest

from sqlite_utils.db import Database, _iter_complete_sql_statements
from sqlite_utils.utils import sqlite3


@pytest.mark.parametrize(
    "sql,expected",
    (
        (
            "CREATE TABLE t(id); INSERT INTO t VALUES (1)",
            ["CREATE TABLE t(id);", "INSERT INTO t VALUES (1)"],
        ),
        (
            "INSERT INTO t VALUES ('a;b');",
            ["INSERT INTO t VALUES ('a;b');"],
        ),
        (
            "-- comment;\nCREATE TABLE t(id);",
            ["-- comment;\nCREATE TABLE t(id);"],
        ),
        (
            """
            CREATE TRIGGER t_ai AFTER INSERT ON t
            BEGIN
                UPDATE t SET value = 'a;b' WHERE id = new.id;
                INSERT INTO log VALUES ('x;y');
            END;
            """,
            [
                (
                    "CREATE TRIGGER t_ai AFTER INSERT ON t\n"
                    "            BEGIN\n"
                    "                UPDATE t SET value = 'a;b' WHERE id = new.id;\n"
                    "                INSERT INTO log VALUES ('x;y');\n"
                    "            END;"
                )
            ],
        ),
    ),
)
def test_iter_complete_sql_statements(sql, expected):
    assert list(_iter_complete_sql_statements(sql)) == expected


def test_atomic_commits(fresh_db):
    with fresh_db.atomic():
        fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")

    assert list(fresh_db.table("dogs").rows) == [{"id": 1, "name": "Cleo"}]


def test_atomic_rolls_back(fresh_db):
    with pytest.raises(RuntimeError), fresh_db.atomic():
        fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
        raise RuntimeError("boom")

    assert not fresh_db.table("dogs").exists()


def test_nested_atomic_rolls_back_to_savepoint(fresh_db):
    fresh_db.table("dogs").create({"id": int, "name": str}, pk="id")

    with fresh_db.atomic():
        fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"})
        with pytest.raises(RuntimeError), fresh_db.atomic():
            fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes"})
            raise RuntimeError("boom")
        fresh_db.table("dogs").insert({"id": 3, "name": "Marnie"})

    assert list(fresh_db.table("dogs").rows) == [
        {"id": 1, "name": "Cleo"},
        {"id": 3, "name": "Marnie"},
    ]


def test_outer_atomic_rolls_back_released_savepoint(fresh_db):
    with pytest.raises(RuntimeError), fresh_db.atomic():
        fresh_db.table("dogs").insert({"id": 1, "name": "Cleo"}, pk="id")
        with fresh_db.atomic():
            fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes"})
        raise RuntimeError("boom")

    assert not fresh_db.table("dogs").exists()


def test_executescript_does_not_commit_open_atomic_block(fresh_db):
    with pytest.raises(RuntimeError), fresh_db.atomic():
        fresh_db.executescript("""
                CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT);
                CREATE TRIGGER dogs_ai AFTER INSERT ON dogs
                BEGIN
                    UPDATE dogs SET name = upper(new.name) || '; updated' WHERE id = new.id;
                END;
                -- This comment has a semicolon;
                INSERT INTO dogs VALUES (1, 'Cleo; the first');
            """)
        raise RuntimeError("boom")

    assert not fresh_db.table("dogs").exists()


def test_transform_does_not_commit_open_atomic_block(fresh_db):
    fresh_db.table("dogs").insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")

    with pytest.raises(RuntimeError), fresh_db.atomic():
        fresh_db.table("dogs").insert({"id": 2, "name": "Pancakes", "age": "6"})
        fresh_db.table("dogs").transform(rename={"age": "dog_age"})
        raise RuntimeError("boom")

    assert (
        fresh_db.table("dogs").schema
        == 'CREATE TABLE "dogs" (\n   "id" INTEGER PRIMARY KEY,\n   "name" TEXT,\n   "age" TEXT\n)'
    )
    assert list(fresh_db.table("dogs").rows) == [
        {"id": 1, "name": "Cleo", "age": "5"},
    ]


def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db):
    fresh_db.conn.execute("PRAGMA foreign_keys=ON")
    fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
    fresh_db.table("books").insert(
        {"id": 1, "title": "Book", "author_id": 1},
        pk="id",
        foreign_keys={"author_id"},
    )

    with fresh_db.atomic():
        fresh_db.table("authors").transform(rename={"name": "full_name"})
        assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]

    assert (
        fresh_db.table("authors").schema
        == 'CREATE TABLE "authors" (\n   "id" INTEGER PRIMARY KEY,\n   "full_name" TEXT\n)'
    )
    assert fresh_db.execute("PRAGMA foreign_key_check").fetchall() == []


def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db):
    fresh_db.conn.execute("PRAGMA foreign_keys=ON")
    fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
    fresh_db.table("books").insert(
        {"id": 1, "title": "Book", "author_id": 1},
        pk="id",
        foreign_keys={"author_id"},
    )

    with pytest.raises(RuntimeError), fresh_db.atomic():
        fresh_db.table("authors").transform(rename={"name": "full_name"})
        raise RuntimeError("boom")

    assert (
        fresh_db.table("authors").schema
        == 'CREATE TABLE "authors" (\n   "id" INTEGER PRIMARY KEY,\n   "name" TEXT\n)'
    )
    assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
    assert fresh_db.execute("PRAGMA foreign_key_check").fetchall() == []


def test_transform_detects_foreign_key_check_violations(fresh_db):
    fresh_db.conn.execute("PRAGMA foreign_keys=ON")
    fresh_db.table("authors").insert({"id": 1, "name": "Tina"}, pk="id")
    fresh_db.table("books").insert({"id": 1, "author_id": 2}, pk="id")

    with pytest.raises(sqlite3.IntegrityError):
        fresh_db.table("books").transform(
            add_foreign_keys=(("author_id", "authors", "id"),)
        )

    assert fresh_db.table("books").foreign_keys == []
    assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]


def test_atomic_inside_manual_transaction_uses_savepoint(fresh_db):
    fresh_db.table("t").insert({"id": 1}, pk="id")
    fresh_db.execute("begin")
    with fresh_db.atomic():
        fresh_db.table("t").insert({"id": 2}, pk="id")
    # Nothing is committed until the user's own transaction commits
    assert fresh_db.conn.in_transaction
    fresh_db.rollback()
    assert [r["id"] for r in fresh_db.table("t").rows] == [1]
    # And with a commit instead, the atomic block's writes persist
    fresh_db.execute("begin")
    with fresh_db.atomic():
        fresh_db.table("t").insert({"id": 3}, pk="id")
    fresh_db.commit()
    assert [r["id"] for r in fresh_db.table("t").rows] == [1, 3]


def test_begin_commit_rollback(tmpdir):
    path = str(tmpdir / "test.db")
    db = Database(path)
    db.table("t").insert({"id": 1}, pk="id")
    db.begin()
    db.table("t").insert({"id": 2}, pk="id")
    assert db.conn.in_transaction
    db.rollback()
    assert not db.conn.in_transaction
    assert [r["id"] for r in db.table("t").rows] == [1]
    db.begin()
    db.table("t").insert({"id": 3}, pk="id")
    db.commit()
    db.close()
    db2 = Database(path)
    assert [r["id"] for r in db2.table("t").rows] == [1, 3]
    db2.close()


def test_begin_inside_transaction_errors(fresh_db):
    fresh_db.begin()
    with pytest.raises(sqlite3.OperationalError):
        fresh_db.begin()
    fresh_db.rollback()


def test_commit_and_rollback_without_transaction_are_noops(fresh_db):
    fresh_db.commit()
    fresh_db.rollback()
    assert not fresh_db.conn.in_transaction


def test_execute_write_commits_immediately(tmpdir):
    path = str(tmpdir / "test.db")
    db = Database(path)
    db.table("t").insert({"id": 1}, pk="id")
    db.execute("insert into t (id) values (2)")
    # No implicit transaction is left open
    assert not db.conn.in_transaction
    # A completely separate connection sees the row straight away
    other = sqlite3.connect(path)
    assert other.execute("select count(*) from t").fetchone()[0] == 2
    other.close()
    db.close()


def test_execute_write_respects_explicit_transaction(fresh_db):
    fresh_db.table("t").insert({"id": 1}, pk="id")
    fresh_db.begin()
    fresh_db.execute("insert into t (id) values (2)")
    # Still inside the explicit transaction - not committed
    assert fresh_db.conn.in_transaction
    fresh_db.rollback()
    assert [r["id"] for r in fresh_db.table("t").rows] == [1]


def test_execute_comment_prefixed_begin_leaves_transaction_open(fresh_db):
    # A BEGIN hidden behind a leading comment must not be auto-committed
    # out from under the caller
    fresh_db.table("t").insert({"id": 1}, pk="id")
    fresh_db.execute("-- start a transaction\nbegin")
    assert fresh_db.conn.in_transaction
    fresh_db.execute("insert into t (id) values (2)")
    fresh_db.rollback()
    assert [r["id"] for r in fresh_db.table("t").rows] == [1]


def _sqlite_accepts_bom():
    try:
        sqlite3.connect(":memory:").execute("\ufeffselect 1")
        return True
    except sqlite3.OperationalError:
        return False


@pytest.mark.parametrize("begin_sql", ["; begin", "\ufeffbegin"])
def test_execute_prefixed_begin_leaves_transaction_open(fresh_db, begin_sql):
    # sqlite3 tolerates empty statements and a UTF-8 BOM before the first
    # real token, so a BEGIN behind either must not be auto-committed
    # out from under the caller
    if begin_sql.startswith("\ufeff") and not _sqlite_accepts_bom():
        pytest.skip("This SQLite version rejects a leading byte order mark")
    fresh_db.table("t").insert({"id": 1}, pk="id")
    fresh_db.execute(begin_sql)
    assert fresh_db.conn.in_transaction
    fresh_db.execute("insert into t (id) values (2)")
    fresh_db.rollback()
    assert [r["id"] for r in fresh_db.table("t").rows] == [1]


def test_execute_failed_write_rolls_back_implicit_transaction(tmpdir):
    # A failed write must not leave the driver's implicit transaction open -
    # that would silently disable auto-commit for every subsequent write
    path = str(tmpdir / "test.db")
    db = Database(path)
    db.table("t").insert({"id": 1}, pk="id")
    with pytest.raises(sqlite3.IntegrityError):
        db.execute("insert into t (id) values (1)")
    assert not db.conn.in_transaction
    # Subsequent writes commit as normal and survive closing the connection
    db.table("other").insert({"id": 2})
    db.close()
    db2 = Database(path)
    assert db2.table("other").exists()
    db2.close()


def test_execute_failed_write_preserves_explicit_transaction(fresh_db):
    # A failed write inside an explicit transaction must not roll back
    # the caller's earlier work - only the caller decides that
    fresh_db.table("t").insert({"id": 1}, pk="id")
    fresh_db.begin()
    fresh_db.execute("insert into t (id) values (2)")
    with pytest.raises(sqlite3.IntegrityError):
        fresh_db.execute("insert into t (id) values (1)")
    assert fresh_db.conn.in_transaction
    fresh_db.commit()
    assert [r["id"] for r in fresh_db.table("t").rows] == [1, 2]


def test_execute_failed_write_inside_atomic_preserves_block(fresh_db):
    # A caught failure inside an atomic() block must leave the block's
    # transaction open so its other work still commits
    fresh_db.table("t").insert({"id": 1}, pk="id")
    with fresh_db.atomic():
        fresh_db.execute("insert into t (id) values (2)")
        with pytest.raises(sqlite3.IntegrityError):
            fresh_db.execute("insert into t (id) values (1)")
    assert [r["id"] for r in fresh_db.table("t").rows] == [1, 2]


def test_query_returning_commits_after_iteration(tmpdir):
    if sqlite3.sqlite_version_info < (3, 35, 0):
        import pytest as _pytest

        _pytest.skip("RETURNING requires SQLite 3.35.0 or higher")
    path = str(tmpdir / "test.db")
    db = Database(path)
    db.table("t").insert({"id": 1}, pk="id")
    rows = list(db.query("insert into t (id) values (2) returning id"))
    assert rows == [{"id": 2}]
    assert not db.conn.in_transaction
    other = sqlite3.connect(path)
    assert other.execute("select count(*) from t").fetchone()[0] == 2
    other.close()
    db.close()


TRIGGER_SQL = """
create trigger no_bad before insert on t
when new.v = 'bad'
begin
    select raise(rollback, 'trigger says no');
end
"""


def test_atomic_preserves_error_from_transaction_destroying_trigger(fresh_db):
    # RAISE(ROLLBACK) rolls back the whole transaction and destroys every
    # savepoint - atomic()'s cleanup must not mask the IntegrityError
    # with "cannot rollback - no transaction is active"
    fresh_db.execute("create table t (id integer primary key, v text)")
    fresh_db.execute(TRIGGER_SQL)
    with (
        pytest.raises(sqlite3.IntegrityError, match="trigger says no"),
        fresh_db.atomic(),
    ):
        fresh_db.execute("insert into t (v) values ('bad')")
    assert not fresh_db.conn.in_transaction


def test_nested_atomic_preserves_error_from_transaction_destroying_trigger(
    fresh_db,
):
    # The nested savepoint branch previously raised
    # "no such savepoint" from ROLLBACK TO SAVEPOINT
    fresh_db.execute("create table t (id integer primary key, v text)")
    fresh_db.execute(TRIGGER_SQL)
    with (
        pytest.raises(sqlite3.IntegrityError, match="trigger says no"),
        fresh_db.atomic(),
        fresh_db.atomic(),
    ):
        fresh_db.execute("insert into t (v) values ('bad')")
    assert not fresh_db.conn.in_transaction


def test_atomic_preserves_error_from_insert_or_rollback(fresh_db):
    fresh_db.table("t").insert({"id": 1}, pk="id")
    with pytest.raises(sqlite3.IntegrityError), fresh_db.atomic():
        fresh_db.execute("insert or rollback into t (id) values (1)")
    assert not fresh_db.conn.in_transaction
