"""Tests for TinyStorageService."""

import os
import tempfile
from unittest.mock import patch, MagicMock

import pytest
from minio.error import S3Error

from tinysdk import TinyStorageService


def test_tinystoragehandler_import():
  """Test that TinyStorageService can be imported."""
  assert TinyStorageService is not None


def test_tinystoragehandler_initialization_with_params(minio_credentials):
  """Test TinyStorageService initialization with explicit parameters."""
  storage = TinyStorageService(**minio_credentials)
  assert storage.endpoint == minio_credentials["endpoint"]
  assert storage.access_key == minio_credentials["access_key"]
  assert storage.secret_key == minio_credentials["secret_key"]


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


def test_tinystoragehandler_has_expected_methods():
  """Test that TinyStorageService has expected methods."""
  assert hasattr(TinyStorageService, "upload_file")
  assert hasattr(TinyStorageService, "download_file")
  assert hasattr(TinyStorageService, "upload_folder")
  assert hasattr(TinyStorageService, "download_folder")
  assert hasattr(TinyStorageService, "delete_file")
  assert hasattr(TinyStorageService, "delete_folder")
  assert hasattr(TinyStorageService, "list_objects")
  assert hasattr(TinyStorageService, "object_exists")
  assert hasattr(TinyStorageService, "close")


def test_upload_folder_uploads_all_files_when_first_already_exists():
  """upload_folder must continue uploading remaining files even if the first one already exists."""
  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    mock_client.fput_object = MagicMock()

    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")

    # first file exists, second and third do not
    storage.object_exists = MagicMock(side_effect=[True, False, False])

    with tempfile.TemporaryDirectory() as tmpdir:
      for i in range(3):
        with open(os.path.join(tmpdir, f"file_{i}.txt"), "w") as f:
          f.write(f"content {i}")

      storage.upload_folder("test-bucket", tmpdir)

      # BUG: currently 0 (function returns immediately after first file)
      # FIX: should be 2 (file_1 and file_2 uploaded, file_0 skipped)
      assert mock_client.fput_object.call_count == 2


def test_download_folder_preserves_nested_directory_structure():
  """download_folder must recreate the full relative path, not just the immediate parent."""
  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value

    mock_obj_a = MagicMock()
    mock_obj_a.object_name = "models/encoder/layer1/weights.pt"
    mock_obj_b = MagicMock()
    mock_obj_b.object_name = "models/decoder/layer1/weights.pt"
    mock_client.list_objects = MagicMock(return_value=[mock_obj_a, mock_obj_b])

    def fake_fget_object(bucket, name, file_path):
      os.makedirs(os.path.dirname(file_path), exist_ok=True)
      with open(file_path, "wb") as f:
        f.write(b"data")

    mock_client.fget_object.side_effect = fake_fget_object

    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")

    with tempfile.TemporaryDirectory() as tmpdir:
      storage.download_folder("bucket", "models", tmpdir)
      encoder_path = os.path.join(tmpdir, "encoder", "layer1", "weights.pt")
      decoder_path = os.path.join(tmpdir, "decoder", "layer1", "weights.pt")
      assert os.path.exists(encoder_path), f"Missing {encoder_path}"
      assert os.path.exists(decoder_path), f"Missing {decoder_path}"


def _make_s3_error(code: str, status: int) -> S3Error:
  """Helper to construct a real S3Error for testing."""
  mock_response = MagicMock()
  mock_response.status = status
  mock_response.headers = {}
  return S3Error(
    code=code,
    message=f"{code} error",
    resource="/bucket/obj",
    request_id="req1",
    host_id="host1",
    response=mock_response,
  )


def test_object_exists_raises_on_access_denied():
  """object_exists must re-raise S3Error when the error is not NoSuchKey."""
  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    mock_client.stat_object = MagicMock(side_effect=_make_s3_error("AccessDenied", 403))

    from tinysdk import TinyStorageService

    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")

    with pytest.raises(S3Error):
      storage.object_exists("bucket", "some-object.txt")


def test_object_exists_returns_false_for_no_such_key():
  """object_exists must return False specifically for NoSuchKey (normal not-found case)."""
  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    mock_client.stat_object = MagicMock(side_effect=_make_s3_error("NoSuchKey", 404))

    from tinysdk import TinyStorageService

    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")

    result = storage.object_exists("bucket", "missing-object.txt")
    assert result is False


def test_close_does_not_raise_when_http_attribute_missing():
  """close() must not raise AttributeError if the internal _http attribute is absent."""
  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    # Remove _http to simulate a future Minio SDK version
    del mock_client._http

    from tinysdk import TinyStorageService

    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")

    # BUG: raises AttributeError because _http access is unconditional
    storage.close()  # must not raise


def test_upload_file_retries_on_network_error():
  """upload_file must retry up to 3 times on urllib3 network errors."""
  import io
  import urllib3.exceptions
  from unittest.mock import patch

  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    mock_client.put_object.side_effect = [
      urllib3.exceptions.HTTPError("connection reset"),
      None,  # succeeds on second attempt
    ]
    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")
    buf = io.BytesIO(b"hello")
    with patch("time.sleep"):
      storage.upload_file("bucket", "test.txt", buf, 5)

  assert mock_client.put_object.call_count == 2


def test_download_file_retries_on_network_error():
  """download_file must retry up to 3 times on urllib3 network errors."""
  import urllib3.exceptions
  from unittest.mock import MagicMock, patch

  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    good_response = MagicMock()
    good_response.read.return_value = b"data"
    mock_client.get_object.side_effect = [
      urllib3.exceptions.HTTPError("timeout"),
      good_response,
    ]
    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")
    with patch("time.sleep"):
      buf, name = storage.download_file("bucket", "test.txt")

  assert mock_client.get_object.call_count == 2
  assert buf.read() == b"data"


def test_upload_result_uses_fput_object_not_bytesio():
  """upload_result must use fput_object (streaming) not load file into BytesIO."""
  import tempfile
  import os
  from unittest.mock import patch

  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")

    with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
      f.write(b"large file content")
      tmp_path = f.name

    try:
      storage.upload_result("bucket", tmp_path)
    finally:
      os.unlink(tmp_path)

  mock_client.fput_object.assert_called_once_with("bucket", tmp_path, tmp_path)
  mock_client.put_object.assert_not_called()


def test_upload_folder_uses_fput_object_not_bytesio():
  """upload_folder must use fput_object (streaming) not load files into BytesIO."""
  import tempfile
  import os
  from unittest.mock import MagicMock, patch

  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")
    storage.object_exists = MagicMock(return_value=False)

    with tempfile.TemporaryDirectory() as tmpdir:
      with open(os.path.join(tmpdir, "file.txt"), "w") as f:
        f.write("content")
      storage.upload_folder("bucket", tmpdir)

  mock_client.fput_object.assert_called_once()
  mock_client.put_object.assert_not_called()


def test_download_folder_uses_fget_object_not_get_object():
  """download_folder must use fget_object (streaming) not load files into BytesIO."""
  import tempfile
  import os
  from unittest.mock import MagicMock, patch

  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    mock_obj = MagicMock()
    mock_obj.object_name = "models/layer1/weights.pt"
    mock_client.list_objects.return_value = [mock_obj]

    def fake_fget_object(bucket, name, file_path):
      os.makedirs(os.path.dirname(file_path), exist_ok=True)
      with open(file_path, "wb") as f:
        f.write(b"weights")

    mock_client.fget_object.side_effect = fake_fget_object

    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")

    with tempfile.TemporaryDirectory() as tmpdir:
      storage.download_folder("bucket", "models", tmpdir)
      assert os.path.exists(os.path.join(tmpdir, "layer1", "weights.pt"))

  mock_client.fget_object.assert_called_once()
  mock_client.get_object.assert_not_called()


def test_upload_file_autodetects_size_when_not_provided():
  """upload_file must infer file_size from the BytesIO buffer when not passed."""
  import io
  from unittest.mock import patch

  with patch("tinysdk.storage.Minio") as MockMinio:
    mock_client = MockMinio.return_value
    storage = TinyStorageService(endpoint="e:9000", access_key="k", secret_key="s")
    content = b"hello world"
    buf = io.BytesIO(content)
    storage.upload_file("bucket", "test.txt", buf)  # no file_size argument

  mock_client.put_object.assert_called_once()
  _, _, _, size_arg = mock_client.put_object.call_args[0]
  assert size_arg == len(content)
