"""Bucket implementation using SQLite"""

import logging
import sqlite3
from contextlib import nullcontext
from pathlib import Path
from tempfile import gettempdir
from threading import RLock
from time import time, time_ns
from typing import List, Optional, Tuple, Union

from ..abstracts import AbstractBucket, Rate, RateItem
from ..clocks import AbstractClock
from ..utils import dedicated_sqlite_clock_connection

logger = logging.getLogger(__name__)


class Queries:
    CREATE_BUCKET_TABLE = """
    CREATE TABLE IF NOT EXISTS '{table}' (
        name VARCHAR,
        item_timestamp INTEGER
    )
    """
    CREATE_INDEX_ON_TIMESTAMP = """
    CREATE INDEX IF NOT EXISTS '{index_name}' ON '{table_name}' (item_timestamp)
    """
    COUNT_BEFORE_INSERT = """
    SELECT :interval{index} as interval, COUNT(*) FROM '{table}'
    WHERE item_timestamp >= :current_timestamp - :interval{index}
    """
    PUT_ITEM = """
    INSERT INTO '{table}' (name, item_timestamp) VALUES (?, ?)
    """
    LEAK = """
    DELETE FROM "{table}" WHERE rowid IN (
    SELECT rowid FROM "{table}" ORDER BY item_timestamp ASC LIMIT {count});
    """.strip()
    COUNT_BEFORE_LEAK = """SELECT COUNT(*) FROM '{table}' WHERE item_timestamp < {lower_bound}"""
    FLUSH = """DELETE FROM '{table}'"""
    # The below sqls are for testing only
    DROP_TABLE = "DROP TABLE IF EXISTS '{table}'"
    DROP_INDEX = "DROP INDEX IF EXISTS '{index}'"
    COUNT_ALL = "SELECT COUNT(*) FROM '{table}'"
    GET_ALL_ITEM = "SELECT * FROM '{table}' ORDER BY item_timestamp ASC"
    GET_FIRST_ITEM = "SELECT name, item_timestamp FROM '{table}' ORDER BY item_timestamp ASC"
    GET_LAG = """
    SELECT (strftime ('%s', 'now') || substr(strftime ('%f', 'now'), 4)) - (
    SELECT item_timestamp
    FROM '{table}'
    ORDER BY item_timestamp
    ASC
    LIMIT 1
    )
    """
    PEEK = 'SELECT * FROM "{table}" ORDER BY item_timestamp DESC LIMIT 1 OFFSET {count}'


class SQLiteBucket(AbstractBucket):
    """For sqlite bucket, we are using the sql time function as the clock
    item's timestamp wont matter here
    """

    is_async = False
    rates: List[Rate]
    failing_rate: Optional[Rate]
    conn: sqlite3.Connection
    table: str
    full_count_query: str
    lock: RLock
    use_limiter_lock: bool

    def __init__(self, rates: List[Rate], conn: sqlite3.Connection, table: str, lock=None):
        self.conn = conn
        self.table = table
        self.rates = rates

        if not lock:
            self.use_limiter_lock = False
            self.lock = RLock()
        else:
            self.use_limiter_lock = True
            self.lock = lock

    def now(self):
        # TODO: Use Sqlite time source via a Lua script
        return time_ns() // 1000000

    def limiter_lock(self):
        if self.use_limiter_lock:
            return self.lock
        else:
            return None

    def _build_full_count_query(self, current_timestamp: int) -> Tuple[str, dict]:
        full_query: List[str] = []

        parameters = {"current_timestamp": current_timestamp}

        for index, rate in enumerate(self.rates):
            parameters[f"interval{index}"] = rate.interval
            query = Queries.COUNT_BEFORE_INSERT.format(table=self.table, index=index)
            full_query.append(query)

        join_full_query = " union ".join(full_query) if len(full_query) > 1 else full_query[0]
        return join_full_query, parameters

    def put(self, item: RateItem) -> bool:
        with self.lock:
            query, parameters = self._build_full_count_query(item.timestamp)
            cur = self.conn.execute(query, parameters)
            rate_limit_counts = cur.fetchall()
            cur.close()

            # Each row is (interval, count); the UNION-of-COUNTs query returns
            # them in ascending-interval order, matching self.rates. Verify that
            # alignment, then defer the admit decision to the algorithm.
            counts = []
            for (interval, count), rate in zip(rate_limit_counts, self.rates, strict=True):
                assert interval == rate.interval
                counts.append(count)

            decision = self._algorithm.admit(self.rates, counts, item.weight)
            if not decision.allowed:
                self.failing_rate = decision.failing_rate
                return False

            # Bind name + timestamp as parameters; never interpolate the
            # user-supplied name into SQL (injection / quote-crash).
            query = Queries.PUT_ITEM.format(table=self.table)
            rows = [(item.name, item.timestamp)] * item.weight
            self.conn.executemany(query, rows).close()
            self.conn.commit()
            return True

    def leak(self, current_timestamp: Optional[int] = None) -> int:
        """Leaking/clean up bucket"""
        with self.lock:
            if self.conn is None:
                # The background Leaker may call leak() after close() has
                # dropped the connection during teardown (issue #244).
                return 0

            assert current_timestamp is not None
            query = Queries.COUNT_BEFORE_LEAK.format(
                table=self.table,
                lower_bound=self._algorithm.leak_bound(self.rates, current_timestamp),
            )
            cur = self.conn.execute(query)
            count = cur.fetchone()[0]
            query = Queries.LEAK.format(table=self.table, count=count)
            cur.execute(query)
            cur.close()
            self.conn.commit()
            return count

    def flush(self) -> None:
        with self.lock:
            self.conn.execute(Queries.FLUSH.format(table=self.table)).close()
            self.conn.commit()
            self.failing_rate = None

    def count(self) -> int:
        with self.lock:
            cur = self.conn.execute(Queries.COUNT_ALL.format(table=self.table))
            ret = cur.fetchone()[0]
            cur.close()
            return ret

    def peek(self, index: int) -> Optional[RateItem]:
        with self.lock:
            query = Queries.PEEK.format(table=self.table, count=index)
            cur = self.conn.execute(query)
            item = cur.fetchone()
            cur.close()

            if not item:
                return None

            return RateItem(item[0], item[1])

    def close(self):
        with self.lock:
            if self.conn is not None:
                try:
                    self.conn.close()
                    self.conn = None
                except Exception as e:
                    logger.debug("Exception %s closing sql connection", e)

    @classmethod
    def init_from_file(
        cls, rates: List[Rate], table: str = "rate_bucket", db_path: Optional[str] = None, create_new_table: bool = True, use_file_lock: bool = False
    ) -> "SQLiteBucket":
        if db_path is None and use_file_lock:
            raise ValueError("db_path must be specified when using use_file_lock")

        if db_path is None:
            temp_dir = Path(gettempdir())

            db_path = str(temp_dir / f"pyrate_limiter_{time()}.sqlite")

        # TBD: FileLock switched to a thread-local FileLock in 3.11.0.
        # Should we set FileLock's thread_local to False, for cases where user is both multiprocessing & threading?
        # As is, the file lock should be Multi Process - Single Thread and non-filelock is Single Process - Multi Thread
        # A hybrid lock may be needed to gracefully handle both cases
        file_lock = None
        file_lock_ctx = nullcontext()

        if use_file_lock:
            try:
                from filelock import FileLock  # type: ignore[import-untyped]

                file_lock = FileLock(db_path + ".lock")  # type: ignore[no-redef]
                file_lock_ctx: Union[nullcontext, FileLock] = file_lock  # type: ignore[no-redef]
            except ImportError as e:
                raise ImportError("filelock is required for file locking. Please install it as optional dependency") from e

        with file_lock_ctx:
            assert db_path is not None

            sqlite_connection = sqlite3.connect(
                db_path,
                isolation_level="DEFERRED",
                check_same_thread=False,
            )

            cur = sqlite_connection.cursor()
            if use_file_lock:
                # https://www.sqlite.org/wal.html
                cur.execute("PRAGMA journal_mode=WAL;")

                # https://www.sqlite.org/pragma.html#pragma_synchronous
                cur.execute("PRAGMA synchronous=NORMAL;")

            if create_new_table:
                cur.execute(Queries.CREATE_BUCKET_TABLE.format(table=table))

            create_idx_query = Queries.CREATE_INDEX_ON_TIMESTAMP.format(
                index_name=f"idx_{table}_rate_item_timestamp",
                table_name=table,
            )

            cur.execute(create_idx_query)
            cur.close()
            sqlite_connection.commit()

            return cls(rates, sqlite_connection, table=table, lock=file_lock)


class SQLiteClock(AbstractClock):
    """Get timestamp using SQLite as remote clock backend"""

    time_query = "SELECT CAST(ROUND((julianday('now') - 2440587.5)*86400000) As INTEGER)"

    def __init__(self, conn: Union[sqlite3.Connection, SQLiteBucket]):
        """
        In multiprocessing cases, use the bucket, so that a shared lock is used.
        """

        self.lock: Optional[RLock] = None

        if isinstance(conn, SQLiteBucket):
            self.conn = conn.conn
            self.lock = conn.lock
        else:
            self.conn = conn

    @classmethod
    def default(cls):
        conn = dedicated_sqlite_clock_connection()
        return cls(conn)

    def now(self) -> int:
        with self.lock if self.lock else nullcontext():
            cur = self.conn.execute(self.time_query)
            now = cur.fetchone()[0]
            cur.close()
            return int(now)
