"""Unit tests for scripts/migrate_dynamodb_data.py (REQ-163, P4).

Tests the pure item-mapping logic + table-pair resolution. The AWS I/O
(scan_all/copy_items) is thin boto3 glue, not unit-tested here (covered
by the dry-run path + the runbook's live verification).
"""
import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))

import migrate_dynamodb_data as mig


class TestMapItem:
    def test_map_item_preserves_typed_attributes(self):
        item = {
            "consumerRepo": {"S": "acdl/consumer-a"},
            "contractId#submittedAt": {"S": "c-1#2026-01-01T00:00:00Z"},
            "contract": {"S": "name: foo\n"},
            "count": {"N": "42"},
        }
        result = mig.map_item(item)
        assert result == item

    def test_map_item_returns_independent_copy(self):
        """The mapped item must not alias the scanned item (callers may mutate)."""
        item = {"k": {"S": "v"}}
        result = mig.map_item(item)
        result["k"]["S"] = "mutated"
        assert item["k"]["S"] == "v", "map_item returned an alias, not a copy"

    def test_map_item_empty(self):
        assert mig.map_item({}) == {}

    def test_map_item_preserves_binary_and_nested(self):
        item = {
            "pk": {"B": b"\x01\x02"},
            "nested": {"M": {"a": {"S": "x"}}},
            "list": {"L": [{"S": "1"}, {"S": "2"}]},
        }
        assert mig.map_item(item) == item


class TestTablePair:
    def test_contracts_alias(self):
        assert mig.table_pair_for("contracts") == ("acdl-contracts", "nova-contracts")

    def test_change_requests_alias(self):
        assert mig.table_pair_for("change-requests") == (
            "acdl-change-requests", "nova-change-requests"
        )

    def test_literal_source_name(self):
        assert mig.table_pair_for("acdl-contracts") == ("acdl-contracts", "nova-contracts")

    def test_literal_dest_name(self):
        assert mig.table_pair_for("nova-contracts") == ("acdl-contracts", "nova-contracts")

    def test_unknown_name_raises(self):
        with pytest.raises(ValueError, match="unknown table"):
            mig.table_pair_for("nope")

    def test_custom_pairs(self):
        pairs = [("old-x", "new-x")]
        assert mig.table_pair_for("old-x", pairs=pairs) == ("old-x", "new-x")


class TestDefaultPairs:
    def test_default_pairs_cover_both_tables(self):
        sources = [s for s, _ in mig.DEFAULT_TABLE_PAIRS]
        dests = [d for _, d in mig.DEFAULT_TABLE_PAIRS]
        assert sources == ["acdl-contracts", "acdl-change-requests"]
        assert dests == ["nova-contracts", "nova-change-requests"]


class TestArgparser:
    def test_dry_run_default(self):
        args = mig.build_parser().parse_args([])
        assert args.apply is False
        assert args.region == "us-east-1"
        assert args.table is None

    def test_apply_flag(self):
        args = mig.build_parser().parse_args(["--apply"])
        assert args.apply is True

    def test_table_filter(self):
        args = mig.build_parser().parse_args(["--table", "contracts"])
        assert args.table == "contracts"

    def test_source_dest_override(self):
        args = mig.build_parser().parse_args(["--source", "old", "--dest", "new"])
        assert args.source == "old"
        assert args.dest == "new"


class TestRunDryRun:
    """The dry-run path exercises the table-pair resolution + describes both
    tables without writing. We stub the boto3 client so no AWS access occurs."""

    def _fake_client(self, describable=True):
        client = type("FakeClient", (), {})()
        def describe_table(TableName):
            if not describable:
                raise Exception("ResourceNotFoundException")
            return {"Table": {"ItemCount": 0}}
        client.describe_table = describe_table
        client.scan = lambda **k: {"Items": []}
        client.put_item = lambda **k: None
        return client

    def test_run_dry_run_reports_planned_copy(self, monkeypatch, capsys):
        # Build args with both default pairs.
        args = mig.build_parser().parse_args([])
        # Stub the client constructor so no real boto3 client is built.
        monkeypatch.setattr(mig.boto3, "client", lambda *a, **k: self._fake_client())
        rc = mig.run(args)
        out = capsys.readouterr().out
        assert rc == 0
        assert "DRY-RUN" in out
        assert "acdl-contracts" in out and "nova-contracts" in out
        assert "acdl-change-requests" in out and "nova-change-requests" in out
        assert "would PutItem" in out
        assert "NOT deleted" in out

    def test_run_source_table_not_describable_fails(self, monkeypatch, capsys):
        args = mig.build_parser().parse_args([])
        # First describe_table (source) raises, second (dest) is fine — emulate by
        # raising on the first call only.
        calls = {"n": 0}
        client = type("FakeClient", (), {})()
        def describe_table(TableName):
            calls["n"] += 1
            if calls["n"] % 2 == 1:  # source (odd calls)
                raise Exception("ResourceNotFoundException")
            return {"Table": {"ItemCount": 0}}
        client.describe_table = describe_table
        client.scan = lambda **k: {"Items": []}
        client.put_item = lambda **k: None
        monkeypatch.setattr(mig.boto3, "client", lambda *a, **k: client)
        rc = mig.run(args)
        err = capsys.readouterr().err
        assert rc == 1
        assert "not describable" in err