"""Tests for TinyDBService."""

import json
import mongomock
import pytest
from bson.objectid import ObjectId
from unittest.mock import patch, MagicMock

from tinysdk import TinyDBService


def test_tinydbservice_import():
  """Test that TinyDBService can be imported."""
  assert TinyDBService is not None


def test_tinydbservice_initialization_with_params(mongodb_credentials):
  """Test TinyDBService initialization with explicit parameters."""
  db = TinyDBService(**mongodb_credentials)
  assert db.db_link == mongodb_credentials["db_link"]
  assert db.db_name == mongodb_credentials["db_name"]


def test_tinydbservice_initialization_without_params_fails():
  """Test that TinyDBService initialization fails without credentials."""
  with pytest.raises(AssertionError):
    TinyDBService()


def test_tinydbservice_has_crud_methods():
  """Test that TinyDBService has expected CRUD methods."""
  assert hasattr(TinyDBService, "create")
  assert hasattr(TinyDBService, "read")
  assert hasattr(TinyDBService, "update")
  assert hasattr(TinyDBService, "delete")
  assert hasattr(TinyDBService, "create_multiple")
  assert hasattr(TinyDBService, "exists")


def test_create_returns_string_id(mongo_db):
  """create() must return the inserted document ID as a string."""
  doc_id = mongo_db.create("users", {"name": "Alice", "email": "alice@example.com"})
  assert isinstance(doc_id, str)
  assert len(doc_id) == 24  # ObjectId hex string


def test_create_with_objectid_id_does_not_crash(mongo_db):
  """create() must not crash when the document already has an ObjectId _id."""
  existing_id = ObjectId()
  doc_id = mongo_db.create("id_test", {"_id": existing_id, "name": "existing"})
  assert doc_id == str(existing_id)


def test_create_with_string_id_converts_to_objectid(mongo_db):
  """create() must convert a string _id to ObjectId."""
  str_id = str(ObjectId())
  doc_id = mongo_db.create("id_test", {"_id": str_id, "name": "strid"})
  assert doc_id == str_id


def test_read_returns_list(mongo_db):
  """read() must return a list of documents."""
  mongo_db.create("users", {"name": "Bob"})
  results = mongo_db.read("users")
  assert isinstance(results, list)
  assert len(results) >= 1


def test_read_with_filter(mongo_db):
  """read() must apply filter correctly."""
  mongo_db.create("filter_test", {"name": "Carol", "role": "admin"})
  mongo_db.create("filter_test", {"name": "Dave", "role": "user"})
  results = mongo_db.read("filter_test", filter={"role": "admin"})
  assert all(r["role"] == "admin" for r in results)


def test_read_with_sort(mongo_db):
  """read() must sort results by the given field and direction."""
  mongo_db.create("sort_test", {"score": 10})
  mongo_db.create("sort_test", {"score": 5})
  mongo_db.create("sort_test", {"score": 20})
  results = mongo_db.read("sort_test", sort=("score", 1))
  scores = [r["score"] for r in results]
  assert scores == sorted(scores)


def test_read_with_pagination(mongo_db):
  """read() must apply page/limit correctly."""
  for i in range(5):
    mongo_db.create("paging_test", {"i": i})
  page0 = mongo_db.read("paging_test", page=0, limit=2)
  page1 = mongo_db.read("paging_test", page=1, limit=2)
  assert len(page0) == 2
  assert len(page1) == 2
  assert page0 != page1


def test_read_does_not_mutate_caller_filter_dict(mongo_db):
  """read() must not modify the caller's filter dict."""
  doc_id = mongo_db.create("mut_test", {"val": "hello"})
  caller_filter = {"_id": doc_id}
  mongo_db.read("mut_test", filter=caller_filter)
  assert isinstance(caller_filter["_id"], str), f"filter dict was mutated: _id is now {type(caller_filter['_id'])}"


def test_update_returns_modified_count(mongo_db):
  """update() must return the number of modified documents."""
  mongo_db.create("update_test", {"name": "Eve", "status": "pending"})
  mongo_db.create("update_test", {"name": "Frank", "status": "pending"})
  count = mongo_db.update("update_test", {"status": "pending"}, {"$set": {"status": "done"}})
  assert count == 2


def test_update_returns_zero_when_no_match(mongo_db):
  """update() must return 0 when no documents matched."""
  count = mongo_db.update("update_test", {"name": "NoSuchUser"}, {"$set": {"x": 1}})
  assert count == 0


def test_delete_returns_deleted_count(mongo_db):
  """delete() must return the number of deleted documents."""
  mongo_db.create("del_test", {"name": "to_delete"})
  mongo_db.create("del_test", {"name": "to_delete"})
  count = mongo_db.delete("del_test", {"name": "to_delete"})
  assert count == 2


def test_delete_by_string_id(mongo_db):
  """delete() with a string _id must convert it to ObjectId and remove the document."""
  doc_id = mongo_db.create("del_test", {"name": "by_id"})
  assert mongo_db.exists("del_test", {"_id": doc_id})
  mongo_db.delete("del_test", {"_id": doc_id})
  assert not mongo_db.exists("del_test", {"_id": doc_id})


def test_delete_returns_zero_when_no_match(mongo_db):
  """delete() must return 0 when no documents matched."""
  count = mongo_db.delete("del_test", {"name": "ghost"})
  assert count == 0


def test_exists_returns_true_when_document_found(mongo_db):
  """exists() must return True when a matching document exists."""
  mongo_db.create("ex_test", {"email": "x@y.com"})
  assert mongo_db.exists("ex_test", {"email": "x@y.com"})


def test_exists_returns_false_when_no_document(mongo_db):
  """exists() must return False when no matching document exists."""
  assert not mongo_db.exists("ex_test", {"email": "nobody@nowhere.com"})


def test_tinydbservice_has_upsert_method():
  """Test that TinyDBService has the upsert method."""
  assert hasattr(TinyDBService, "upsert")


def test_upsert_inserts_when_no_match(mongo_db):
  """upsert() must insert a new document when no match is found and return True."""
  is_new = mongo_db.upsert(
    "upsert_test",
    {"name": "Alice"},
    {
      "$setOnInsert": {"role": "admin"},
      "$set": {"lastSeen": "today"},
    },
  )
  assert is_new is True
  docs = mongo_db.read("upsert_test", {"name": "Alice"})
  assert len(docs) == 1
  assert docs[0]["role"] == "admin"
  assert docs[0]["lastSeen"] == "today"


def test_upsert_updates_when_match_exists(mongo_db):
  """upsert() must update the existing document and return False."""
  mongo_db.create("upsert_test", {"name": "Bob", "role": "user", "score": 10})
  is_new = mongo_db.upsert(
    "upsert_test",
    {"name": "Bob"},
    {
      "$setOnInsert": {"role": "should_not_apply"},
      "$set": {"score": 20},
    },
  )
  assert is_new is False
  docs = mongo_db.read("upsert_test", {"name": "Bob"})
  assert len(docs) == 1
  assert docs[0]["role"] == "user"  # $setOnInsert must NOT overwrite
  assert docs[0]["score"] == 20


def test_upsert_setOnInsert_only_applies_on_insert(mongo_db):
  """$setOnInsert fields must only be set when the document is first created."""
  mongo_db.upsert(
    "upsert_test",
    {"docId": "doc1"},
    {
      "$setOnInsert": {"createdAt": "t1", "category": "A"},
      "$set": {"modelTrace.m1": {"conf": 0.9}},
    },
  )
  mongo_db.upsert(
    "upsert_test",
    {"docId": "doc1"},
    {
      "$setOnInsert": {"createdAt": "t2", "category": "B"},
      "$set": {"modelTrace.m2": {"conf": 0.8}},
    },
  )
  docs = mongo_db.read("upsert_test", {"docId": "doc1"})
  assert len(docs) == 1
  assert docs[0]["createdAt"] == "t1"  # first insert wins
  assert docs[0]["category"] == "A"  # first insert wins
  assert docs[0]["modelTrace"]["m1"]["conf"] == 0.9
  assert docs[0]["modelTrace"]["m2"]["conf"] == 0.8


def test_upsert_with_push_operator(mongo_db):
  """upsert() must support $push for array fields."""
  mongo_db.upsert(
    "upsert_test",
    {"docId": "doc2"},
    {
      "$setOnInsert": {"bucketName": "results"},
      "$push": {"fileNames": "file1.json"},
    },
  )
  mongo_db.upsert(
    "upsert_test",
    {"docId": "doc2"},
    {
      "$setOnInsert": {"bucketName": "should_not_apply"},
      "$push": {"fileNames": "file2.json"},
    },
  )
  docs = mongo_db.read("upsert_test", {"docId": "doc2"})
  assert docs[0]["bucketName"] == "results"
  assert docs[0]["fileNames"] == ["file1.json", "file2.json"]


def test_upsert_with_string_id_filter(mongo_db):
  """upsert() must convert string _id in filter to ObjectId."""
  doc_id = mongo_db.create("upsert_test", {"val": "original"})
  is_new = mongo_db.upsert("upsert_test", {"_id": doc_id}, {"$set": {"val": "updated"}})
  assert is_new is False
  docs = mongo_db.read("upsert_test", {"_id": doc_id})
  assert docs[0]["val"] == "updated"


def test_upsert_with_set_only(mongo_db):
  """upsert() must work with only $set (no $setOnInsert)."""
  is_new = mongo_db.upsert("upsert_test", {"name": "SetOnly"}, {"$set": {"score": 100}})
  assert is_new is True
  is_new = mongo_db.upsert("upsert_test", {"name": "SetOnly"}, {"$set": {"score": 200}})
  assert is_new is False
  docs = mongo_db.read("upsert_test", {"name": "SetOnly"})
  assert docs[0]["score"] == 200


def test_upsert_with_inc_operator(mongo_db):
  """upsert() must support $inc for atomic counter operations."""
  mongo_db.upsert("upsert_test", {"docId": "counter"}, {"$inc": {"views": 1}})
  mongo_db.upsert("upsert_test", {"docId": "counter"}, {"$inc": {"views": 1}})
  mongo_db.upsert("upsert_test", {"docId": "counter"}, {"$inc": {"views": 1}})
  docs = mongo_db.read("upsert_test", {"docId": "counter"})
  assert docs[0]["views"] == 3


def test_upsert_with_dot_notation_nested_fields(mongo_db):
  """upsert() must support dot notation for setting nested fields atomically."""
  mongo_db.upsert(
    "upsert_test",
    {"docId": "nested"},
    {
      "$setOnInsert": {"category": "A"},
      "$set": {"modelTrace.model1": {"conf": 0.9, "ts": "t1"}},
    },
  )
  mongo_db.upsert(
    "upsert_test",
    {"docId": "nested"},
    {
      "$setOnInsert": {"category": "B"},
      "$set": {"modelTrace.model2": {"conf": 0.8, "ts": "t2"}},
    },
  )
  docs = mongo_db.read("upsert_test", {"docId": "nested"})
  assert docs[0]["category"] == "A"
  assert docs[0]["modelTrace"]["model1"]["conf"] == 0.9
  assert docs[0]["modelTrace"]["model2"]["conf"] == 0.8


def test_upsert_retries_on_transient_mongodb_error():
  """upsert() must retry on transient MongoDB errors."""
  from pymongo.errors import ServerSelectionTimeoutError

  with patch("tinysdk.database.MongoClient") as MockClient:
    mock_client = MagicMock()
    mock_db = MagicMock()
    mock_col = MagicMock()
    MockClient.return_value = mock_client
    mock_client.get_database.return_value = mock_db
    mock_db.get_collection.return_value = mock_col
    mock_col.update_one.side_effect = [
      ServerSelectionTimeoutError("primary not found"),
      MagicMock(upserted_id=ObjectId()),
    ]

    db = TinyDBService(db_link="mongodb://test", db_name="test")
    with patch("time.sleep"):
      result = db.upsert("test_col", {"name": "retry"}, {"$set": {"val": 1}})

  assert result is True
  assert mock_col.update_one.call_count == 2


def test_upsert_does_not_mutate_caller_filter_dict(mongo_db):
  """upsert() must not modify the caller's filter dict."""
  doc_id = mongo_db.create("mut_test", {"val": "hello"})
  caller_filter = {"_id": doc_id}
  mongo_db.upsert("mut_test", caller_filter, {"$set": {"val": "world"}})
  assert isinstance(caller_filter["_id"], str), f"filter dict was mutated: _id is now {type(caller_filter['_id'])}"


def test_create_multiple_returns_list_of_string_ids(mongo_db):
  """create_multiple() must return a list of string document IDs."""
  ids = mongo_db.create_multiple("multi_test", [{"name": "alpha"}, {"name": "beta"}, {"name": "gamma"}])
  assert isinstance(ids, list)
  assert len(ids) == 3
  for id_str in ids:
    assert isinstance(id_str, str)


def test_create_multiple_is_json_serializable(mongo_db):
  """create_multiple() result must be JSON-serializable."""
  ids = mongo_db.create_multiple("multi_test", [{"name": "a"}, {"name": "b"}])
  serialized = json.dumps(ids)
  data = json.loads(serialized)
  assert len(data) == 2


def test_index_created_only_once_per_collection(mongo_db):
  """Index creation must run once per collection lifetime, not on every CRUD call."""
  mongo_db.index_config["perf_test"] = [[("field", 1), {"unique": True}]]

  with patch.object(mongomock.collection.Collection, "create_index") as mock_ci:
    for i in range(3):
      mongo_db.create("perf_test", {"field": f"val_{i}"})

    assert mock_ci.call_count == 1


def test_close_releases_connection_pool():
  """close() must call client.close() to release the MongoDB connection pool."""
  with patch("tinysdk.database.MongoClient") as MockClient:
    mock_client = MagicMock()
    MockClient.return_value = mock_client
    mock_client.get_database.return_value = MagicMock()
    db = TinyDBService(db_link="mongodb://test", db_name="test")
    db.close()
  mock_client.close.assert_called_once()


def test_connection_timeout_passed_to_mongo_client():
  """TinyDBService must pass serverSelectionTimeoutMS to MongoClient."""
  with patch("tinysdk.database.MongoClient") as MockClient:
    MockClient.return_value.get_database.return_value = MagicMock()
    TinyDBService(db_link="mongodb://test", db_name="test", connection_timeout_ms=3000)
  MockClient.assert_called_once_with("mongodb://test", serverSelectionTimeoutMS=3000)


def test_connection_timeout_defaults_to_5000():
  """Default serverSelectionTimeoutMS must be 5000 ms."""
  with patch("tinysdk.database.MongoClient") as MockClient:
    MockClient.return_value.get_database.return_value = MagicMock()
    TinyDBService(db_link="mongodb://test", db_name="test")
  MockClient.assert_called_once_with("mongodb://test", serverSelectionTimeoutMS=5000)


def test_create_retries_on_transient_mongodb_error():
  """create() must retry up to 3 times on transient MongoDB errors."""
  from pymongo.errors import ServerSelectionTimeoutError

  fake_id = ObjectId()

  with patch("tinysdk.database.MongoClient") as MockClient:
    mock_client = MagicMock()
    mock_db = MagicMock()
    mock_col = MagicMock()
    MockClient.return_value = mock_client
    mock_client.get_database.return_value = mock_db
    mock_db.get_collection.return_value = mock_col
    mock_col.insert_one.side_effect = [
      ServerSelectionTimeoutError("primary not found"),
      MagicMock(inserted_id=fake_id),
    ]

    db = TinyDBService(db_link="mongodb://test", db_name="test")
    with patch("time.sleep"):
      doc_id = db.create("users", {"name": "John"})

  assert doc_id == str(fake_id)
  assert mock_col.insert_one.call_count == 2
