"""
Tests for RunningJobsLedger lockless ledger behavior.
"""

# To run just these tests (without loading pytest third-party plugins):
#   PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -q studio_api/tests/test_running_jobs_ledger.py

import logging
import sys
import types

# Provide a minimal structlog stub so tests don't require structlog installed
if "structlog" not in sys.modules:  # pragma: no cover
    sys.modules["structlog"] = types.SimpleNamespace(get_logger=lambda name: logging.getLogger(name))

# Stub minimal Django and django_redis modules to allow importing cache.py without Django
if "django" not in sys.modules:  # pragma: no cover
    sys.modules["django"] = types.ModuleType("django")
if "django.conf" not in sys.modules:  # pragma: no cover
    conf_mod = types.ModuleType("django.conf")
    conf_mod.settings = types.SimpleNamespace(RUNNING_JOBS_CACHE="default")
    sys.modules["django.conf"] = conf_mod
if "django.contrib" not in sys.modules:  # pragma: no cover
    sys.modules["django.contrib"] = types.ModuleType("django.contrib")
if "django.contrib.auth" not in sys.modules:  # pragma: no cover
    sys.modules["django.contrib.auth"] = types.ModuleType("django.contrib.auth")
if "django.contrib.auth.models" not in sys.modules:  # pragma: no cover
    models_mod = types.ModuleType("django.contrib.auth.models")

    class _DummyManager:
        def get(self, **kwargs):
            class _DummyUser:
                pass

            return _DummyUser()

    class User:  # type: ignore
        objects = _DummyManager()

    models_mod.User = User  # type: ignore
    sys.modules["django.contrib.auth.models"] = models_mod
if "django_redis" not in sys.modules:  # pragma: no cover
    sys.modules["django_redis"] = types.SimpleNamespace(get_redis_connection=lambda alias: None)

# Stub studio_api package to avoid importing app/celery/redis during tests
if "studio_api" not in sys.modules:  # pragma: no cover
    studio_api_pkg = types.ModuleType("studio_api")
    bots_pkg = types.ModuleType("studio_api.bots")
    cache_pkg = types.ModuleType("studio_api.bots.cache")
    caches_mod = types.ModuleType("studio_api.bots.cache.caches")
    caches_mod.CACHE_TIMEOUT_5_MINUTE = 300
    utils_mod = types.ModuleType("studio_api.bots.utils")

    def _check_running_jobs_cost(_user):
        return 0

    utils_mod.check_running_jobs_cost = _check_running_jobs_cost
    utils_mod.check_DB_running_jobs_cost = _check_running_jobs_cost
    sys.modules["studio_api"] = studio_api_pkg
    sys.modules["studio_api.bots"] = bots_pkg
    sys.modules["studio_api.bots.cache"] = cache_pkg
    sys.modules["studio_api.bots.cache.caches"] = caches_mod
    sys.modules["studio_api.bots.utils"] = utils_mod

import importlib.util
import pathlib
import sys
import time
import types
from unittest.mock import patch

if "structlog" not in sys.modules:
    sys.modules["structlog"] = types.SimpleNamespace(get_logger=lambda name: logging.getLogger(name))

# Dynamically load the ledger module by file path to avoid importing the Django package
MODULE_PATH = (
    pathlib.Path(__file__).resolve().parents[1] / "studio_api" / "bots" / "billing" / "cache.py"
)
spec = importlib.util.spec_from_file_location("running_jobs_ledger_module", str(MODULE_PATH))
assert spec is not None
cache_module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(cache_module)  # type: ignore
RunningJobsLedger = cache_module.RunningJobsLedger


class DummyUser:
    def __init__(self, user_id):
        self.id = user_id


class FakeRedis:
    def __init__(self):
        self._store = {}
        self._exp = {}

    def _cleanup(self, key):
        exp = self._exp.get(key)
        if exp is not None and time.time() >= exp:
            self._store.pop(key, None)
            self._exp.pop(key, None)

    def exists(self, key):
        self._cleanup(key)
        return 1 if key in self._store else 0

    def hgetall(self, key):
        self._cleanup(key)
        data = self._store.get(key) or {}
        # Return string keys/values to mirror decode_responses=True behavior
        return {str(k): str(v) for k, v in data.items()}

    def hset(self, key, field, value):
        self._cleanup(key)
        self._store.setdefault(key, {})[field] = value
        return 1

    def hsetnx(self, key, field, value):
        self._cleanup(key)
        bucket = self._store.setdefault(key, {})
        if field in bucket:
            return 0
        bucket[field] = value
        return 1

    def hexists(self, key, field):
        self._cleanup(key)
        return 1 if field in (self._store.get(key) or {}) else 0

    def incr(self, key):
        self._cleanup(key)
        cur = self._store.get(key)
        try:
            cur = int(cur) if cur is not None else 0
        except Exception:
            cur = 0
        cur += 1
        self._store[key] = cur
        return cur

    def expire(self, key, seconds):
        self._cleanup(key)
        self._exp[key] = time.time() + int(seconds)
        return True

    def delete(self, *keys):
        for key in keys:
            self._store.pop(key, None)
            self._exp.pop(key, None)
        return True


def make_handler():
    # Avoid calling __init__ (which imports Django); manually construct and set attrs
    h = object.__new__(RunningJobsLedger)
    h.redis = FakeRedis()
    h.ttl_seconds = 1
    return h


@patch.object(RunningJobsLedger, "_get_db_cost", return_value=60)
def test_init_from_db_on_first_read(mock_cost):
    h = make_handler()
    user = DummyUser(1)
    assert h.get_running_jobs_cost(str(user.id)) == 60
    mock_cost.assert_called_once_with(str(user.id))


def test_increment_and_decrement_updates_total():
    h = make_handler()
    user = DummyUser(2)
    assert h.set_ledger_base(str(user.id), 100) == 100
    assert h.increment_ledger_cost(str(user.id), 10, gen_request_id="req1") == 110
    assert h.decrement_ledger_cost(str(user.id), 5, clip_id="clip1") == 105
    assert h.get_running_jobs_cost(str(user.id)) == 105


def test_never_goes_negative():
    h = make_handler()
    user = DummyUser(3)
    assert h.set_ledger_base(str(user.id), 5) == 5
    assert h.decrement_ledger_cost(str(user.id), 20, clip_id="clip2") == 0
    assert h.get_running_jobs_cost(str(user.id)) == 0


@patch.object(RunningJobsLedger, "_get_db_cost", return_value=35)
def test_missing_ledger_on_write_initializes_base(mock_cost):
    h = make_handler()
    user = DummyUser(4)
    assert h.decrement_ledger_cost(str(user.id), 20, clip_id="clip3") == 35
    assert h.get_running_jobs_cost(str(user.id)) == 35
    mock_cost.assert_called_once_with(str(user.id))


def test_zero_deltas_no_change():
    h = make_handler()
    user = DummyUser(5)
    assert h.set_ledger_base(str(user.id), 30) == 30
    assert h.increment_ledger_cost(str(user.id), 0, gen_request_id="req0") == 30
    assert h.decrement_ledger_cost(str(user.id), 0, clip_id="clip0") == 30
    assert h.get_running_jobs_cost(str(user.id)) == 30


@patch.object(RunningJobsLedger, "_get_db_cost", return_value=100)
def test_ttl_expiry_reinitializes_base(_):
    h = make_handler()
    user = DummyUser(6)
    assert h.get_running_jobs_cost(str(user.id)) == 100
    time.sleep(1.2)
    assert h.get_running_jobs_cost(str(user.id)) == 100


def test_spam_creation_idempotent_and_multiple():
    h = make_handler()
    user = DummyUser(7)
    # Warm ledger with a known base to avoid first-increment init short-circuit
    assert h.set_ledger_base(str(user.id), 0) == 0

    # First unique gen
    assert h.increment_ledger_cost(str(user.id), 10, gen_request_id="reqA") == 10
    # Second unique gen accumulates
    assert h.increment_ledger_cost(str(user.id), 20, gen_request_id="reqB") == 30
    # Repeating same gen_request_id is idempotent (overwrite same value, no double count)
    assert h.increment_ledger_cost(str(user.id), 10, gen_request_id="reqA") == 30
    # Another unique gen stacks on top
    assert h.increment_ledger_cost(str(user.id), 5, gen_request_id="reqC") == 35
    # Final check via reader
    assert h.get_running_jobs_cost(str(user.id)) == 35


@patch.object(RunningJobsLedger, "_get_db_cost", return_value=50)
def test_cache_deleted_then_calls_still_work(_):
    h = make_handler()
    user = DummyUser(8)

    # Start from a known state: base 10 and one delta +5
    assert h.set_ledger_base(str(user.id), 10) == 10
    assert h.increment_ledger_cost(str(user.id), 5, gen_request_id="reqX") == 15

    # Simulate cache wipe
    h.redis.delete(h._ledger_key(str(user.id)))

    # Reader should re-initialize from DB (50)
    assert h.get_running_jobs_cost(str(user.id)) == 50

    # Writer after re-init should append delta
    assert h.increment_ledger_cost(str(user.id), 5, gen_request_id="reqY") == 55

    # Simulate cache wipe again, then decrement path
    h.redis.delete(h._ledger_key(str(user.id)))
    # First decrement after missing ledger initializes base and returns it (no delta)
    assert h.decrement_ledger_cost(str(user.id), 5, clip_id="clipZ") == 50
    # Second decrement now appends the delta
    assert h.decrement_ledger_cost(str(user.id), 5, clip_id="clipZ") == 45
