from unittest.mock import MagicMock
from ZODB.utils import u64
from zodb_convert.copier import _try_parallel_delegation
from zodb_convert.copier import copy_transactions
from zodb_convert.copier import detect_capabilities
from zodb_convert.copier import get_incremental_start_tid
from zodb_convert.copier import storage_has_data

import transaction
import ZODB
import ZODB.FileStorage


class TestStorageHasData:
    def test_empty_storage(self, source_filestorage):
        assert storage_has_data(source_filestorage) is False

    def test_populated_storage(self, populated_source):
        assert storage_has_data(populated_source) is True


class TestDetectCapabilities:
    def test_filestorage(self, source_filestorage, dest_filestorage):
        caps = detect_capabilities(source_filestorage, dest_filestorage)
        assert caps["source_has_iterator"] is True
        assert caps["dest_has_restore"] is True
        assert caps["source_has_blobs"] is True
        assert caps["dest_has_blobs"] is True
        assert caps["dest_has_blob_restore"] is True

    def test_mapping_storage(self, source_mapping_storage, dest_mapping_storage):
        caps = detect_capabilities(source_mapping_storage, dest_mapping_storage)
        assert caps["source_has_iterator"] is True
        assert caps["dest_has_restore"] is False
        assert caps["source_has_blobs"] is False
        assert caps["dest_has_blobs"] is False
        assert caps["dest_has_blob_restore"] is False


class TestCopyEmpty:
    def test_copy_empty_storage(self, source_filestorage, dest_filestorage):
        txn_count, obj_count, blob_count = copy_transactions(
            source_filestorage, dest_filestorage
        )
        assert txn_count == 0
        assert obj_count == 0
        assert blob_count == 0


class TestCopyTransactions:
    def test_copy_single_transaction(self, source_filestorage, dest_filestorage):
        # ZODB.DB creates an initial root txn + our explicit commit = 2 txns total
        db = ZODB.DB(source_filestorage)
        conn = db.open()
        root = conn.root()
        root["key"] = "value"
        transaction.commit()
        conn.close()
        db.close()

        txn_count, obj_count, blob_count = copy_transactions(
            source_filestorage, dest_filestorage
        )
        # 2 txns: initial root + our commit
        assert txn_count == 2
        assert obj_count >= 1
        assert blob_count == 0

        # Verify data in destination
        db2 = ZODB.DB(dest_filestorage)
        conn2 = db2.open()
        root2 = conn2.root()
        assert root2["key"] == "value"
        conn2.close()
        db2.close()

    def test_copy_multiple_transactions(self, populated_source, dest_filestorage):
        txn_count, obj_count, _blob_count = copy_transactions(
            populated_source, dest_filestorage
        )
        # 4 txns: initial root + 3 explicit commits from populated_source
        assert txn_count == 4
        assert obj_count > 0

        # Verify data
        db = ZODB.DB(dest_filestorage)
        conn = db.open()
        root = conn.root()
        assert root["key1"] == "value1"
        assert root["key2"] == 42
        assert root["key3"] == {"nested": [1, 2, 3]}
        conn.close()
        db.close()

    def test_copy_preserves_tids(self, populated_source, dest_filestorage):
        copy_transactions(populated_source, dest_filestorage)

        source_tids = [txn.tid for txn in populated_source.iterator()]
        dest_tids = [txn.tid for txn in dest_filestorage.iterator()]
        assert source_tids == dest_tids

    def test_copy_preserves_metadata(self, populated_source, dest_filestorage):
        copy_transactions(populated_source, dest_filestorage)

        dest_txns = list(dest_filestorage.iterator())
        # Index 0 is the initial root transaction (empty metadata)
        # Our transactions start at index 1
        assert b"user1" in dest_txns[1].user
        assert b"First transaction" in dest_txns[1].description
        assert b"user2" in dest_txns[2].user
        assert b"user3" in dest_txns[3].user


class TestCopyBlobs:
    def test_copy_with_blobs(self, populated_source, dest_filestorage):
        _txn_count, _obj_count, blob_count = copy_transactions(
            populated_source, dest_filestorage
        )
        assert blob_count >= 1

        # Verify blob data
        db = ZODB.DB(dest_filestorage)
        conn = db.open()
        root = conn.root()
        blob = root["blob1"]
        with blob.open("r") as f:
            assert f.read() == b"Hello, blob world!"
        conn.close()
        db.close()

    def test_copy_no_blobs_to_non_blob_dest(
        self, source_filestorage, dest_mapping_storage
    ):
        """When dest doesn't support blobs, blob records are still copied (data only)."""
        # Use source with only simple data (no blobs) to avoid store() conflict issues
        db = ZODB.DB(source_filestorage)
        conn = db.open()
        root = conn.root()
        root["key"] = "value"
        transaction.commit()
        conn.close()
        db.close()

        txn_count, _obj_count, blob_count = copy_transactions(
            source_filestorage, dest_mapping_storage
        )
        assert txn_count >= 1
        assert blob_count == 0


class TestCopyFallback:
    def test_fallback_to_store(self, source_mapping_storage, dest_mapping_storage):
        """MappingStorage doesn't implement IStorageRestoreable, use store() fallback."""
        # Use MappingStorage as source too (simple single-txn case)
        db = ZODB.DB(source_mapping_storage)
        conn = db.open()
        root = conn.root()
        root["key"] = "value"
        transaction.commit()
        conn.close()
        db.close()

        txn_count, obj_count, _blob_count = copy_transactions(
            source_mapping_storage, dest_mapping_storage
        )
        assert txn_count >= 1
        assert obj_count >= 1

        # Verify data in mapping storage
        db2 = ZODB.DB(dest_mapping_storage)
        conn2 = db2.open()
        root2 = conn2.root()
        assert root2["key"] == "value"
        conn2.close()
        db2.close()


class TestDryRun:
    def test_dry_run_no_writes(self, populated_source, dest_filestorage):
        txn_count, obj_count, _blob_count = copy_transactions(
            populated_source, dest_filestorage, dry_run=True
        )
        # 4 txns: initial root + 3 explicit
        assert txn_count == 4
        assert obj_count > 0

        # Destination should still be empty
        assert storage_has_data(dest_filestorage) is False


class TestIncremental:
    def test_get_incremental_start_tid_empty(self, populated_source, dest_filestorage):
        result = get_incremental_start_tid(populated_source, dest_filestorage)
        assert result is None

    def test_get_incremental_start_tid_with_data(
        self, populated_source, dest_filestorage
    ):
        # Copy data first so destination has transactions
        copy_transactions(populated_source, dest_filestorage)
        tid = get_incremental_start_tid(populated_source, dest_filestorage)
        assert tid is not None
        last = dest_filestorage.lastTransaction()
        assert u64(tid) == u64(last) + 1

    def test_incremental_copy(self, temp_dir):
        """Copy some transactions, add more to source, copy again incrementally."""
        import os

        src_path = os.path.join(temp_dir, "inc_source.fs")
        src_blobs = os.path.join(temp_dir, "inc_source_blobs")
        dst_path = os.path.join(temp_dir, "inc_dest.fs")
        dst_blobs = os.path.join(temp_dir, "inc_dest_blobs")

        # Create source with 2 transactions
        source = ZODB.FileStorage.FileStorage(src_path, blob_dir=src_blobs)
        db = ZODB.DB(source)
        conn = db.open()
        root = conn.root()
        root["key1"] = "value1"
        transaction.commit()
        root["key2"] = "value2"
        transaction.commit()
        conn.close()
        db.close()

        # Open storages for copy
        source = ZODB.FileStorage.FileStorage(src_path, blob_dir=src_blobs)
        dest = ZODB.FileStorage.FileStorage(dst_path, blob_dir=dst_blobs)
        copy_transactions(source, dest)
        source.close()
        dest.close()

        # Add third transaction to source
        source = ZODB.FileStorage.FileStorage(src_path, blob_dir=src_blobs)
        db = ZODB.DB(source)
        conn = db.open()
        root = conn.root()
        root["key3"] = "value3"
        transaction.commit()
        conn.close()
        db.close()

        # Incremental copy
        source = ZODB.FileStorage.FileStorage(src_path, blob_dir=src_blobs)
        dest = ZODB.FileStorage.FileStorage(dst_path, blob_dir=dst_blobs)
        start_tid = get_incremental_start_tid(source, dest)
        txn_count, _obj_count, _blob_count = copy_transactions(
            source, dest, start_tid=start_tid
        )
        assert txn_count == 1  # Only the new transaction

        # Verify all data
        db2 = ZODB.DB(dest)
        conn2 = db2.open()
        root2 = conn2.root()
        assert root2["key1"] == "value1"
        assert root2["key2"] == "value2"
        assert root2["key3"] == "value3"
        conn2.close()
        db2.close()

    def test_incremental_with_wallclock_tid(self, temp_dir):
        """Destination has a wall-clock TID beyond source range (from ZODB.DB init).

        get_incremental_start_tid should scan for the real resume point.
        """
        from ZODB.TimeStamp import TimeStamp

        import os

        src_path = os.path.join(temp_dir, "wc_source.fs")
        src_blobs = os.path.join(temp_dir, "wc_source_blobs")
        dst_path = os.path.join(temp_dir, "wc_dest.fs")
        dst_blobs = os.path.join(temp_dir, "wc_dest_blobs")

        # Create source with data
        source = ZODB.FileStorage.FileStorage(src_path, blob_dir=src_blobs)
        db = ZODB.DB(source)
        conn = db.open()
        root = conn.root()
        root["key1"] = "value1"
        transaction.commit()
        root["key2"] = "value2"
        transaction.commit()
        conn.close()
        db.close()

        # Copy to destination (preserves source TIDs via restore)
        source = ZODB.FileStorage.FileStorage(src_path, blob_dir=src_blobs)
        dest = ZODB.FileStorage.FileStorage(dst_path, blob_dir=dst_blobs)
        copy_transactions(source, dest)

        # Remember the last real (restored) TID
        real_last_tid = u64(dest.lastTransaction())

        # Simulate ZODB.DB init pollution: set _ltid to a far-future value.
        # This is what happens when ZODB.DB creates a root object with a
        # wall-clock TID that's beyond the source data's TID range.
        future_ts = TimeStamp(2099, 1, 1, 0, 0, 0)
        dest._ltid = future_ts.raw()

        # Now dest.lastTransaction() returns the far-future TID
        assert u64(dest.lastTransaction()) > u64(source.lastTransaction())

        # get_incremental_start_tid should detect the mismatch and scan
        start_tid = get_incremental_start_tid(source, dest)
        # Should resume from after the last real source TID, not the future
        assert start_tid is not None
        assert u64(start_tid) == real_last_tid + 1

        source.close()
        dest.close()


class TestParallelDelegation:
    """Test _try_parallel_delegation and workers path in copy_transactions."""

    def test_delegation_success(self):
        """Destination supports copyTransactionsFrom(workers=N)."""
        source = MagicMock()
        dest = MagicMock()
        dest.copyTransactionsFrom = MagicMock()

        result = _try_parallel_delegation(source, dest, workers=4)
        assert result == (None, None, None)
        dest.copyTransactionsFrom.assert_called_once_with(source, workers=4)

    def test_delegation_no_method(self):
        """Destination has no copyTransactionsFrom at all."""
        source = MagicMock()
        dest = MagicMock(spec=[])  # no attributes

        result = _try_parallel_delegation(source, dest, workers=4)
        assert result is None

    def test_delegation_no_workers_support(self):
        """Destination's copyTransactionsFrom doesn't accept workers kwarg."""
        source = MagicMock()
        dest = MagicMock()
        dest.copyTransactionsFrom = MagicMock(side_effect=TypeError("unexpected kwarg"))

        result = _try_parallel_delegation(source, dest, workers=2)
        assert result is None

    def test_copy_transactions_delegates_when_workers_gt_1(
        self, populated_source, dest_filestorage
    ):
        """copy_transactions with workers>1 tries delegation, falls back on TypeError."""
        # FileStorage.copyTransactionsFrom doesn't accept workers → TypeError fallback
        txn_count, obj_count, _blob_count = copy_transactions(
            populated_source, dest_filestorage, workers=2
        )
        # Should have fallen back to sequential copy and succeeded
        assert txn_count == 4  # initial root + 3 explicit
        assert obj_count > 0

    def test_copy_transactions_skips_delegation_on_dry_run(
        self, populated_source, dest_filestorage
    ):
        """workers>1 is ignored when dry_run=True."""
        txn_count, obj_count, _blob_count = copy_transactions(
            populated_source, dest_filestorage, dry_run=True, workers=4
        )
        assert txn_count == 4
        assert obj_count > 0
        # Destination should still be empty (dry run)
        assert storage_has_data(dest_filestorage) is False

    def test_copy_transactions_skips_delegation_on_incremental(
        self, populated_source, dest_filestorage
    ):
        """workers>1 is ignored when start_tid is set."""
        from ZODB.utils import p64

        txn_count, obj_count, _blob_count = copy_transactions(
            populated_source, dest_filestorage, start_tid=p64(0), workers=4
        )
        assert txn_count == 4
        assert obj_count > 0
