"""
Tests for atomingest.pipeline.dlq — Dead Letter Queue module.

Covers:
- DLQRecord creation, serialisation, and properties
- DLQStore CRUD and filtering
- DLQRetryRunner success, failure, and exhaustion paths
- DLQExporter CSV and JSON output
- DLQManager facade
- DeadLetterQueueHook integration (metric wiring + DLQManager path)
"""

import csv
import json
from unittest.mock import MagicMock, patch


from atomingest.pipeline.dlq import (
    DLQExporter,
    DLQManager,
    DLQRecord,
    DLQRetryRunner,
    DLQStatus,
    DLQStore,
)
from atomingest.pipeline.hooks import (
    DeadLetterQueueHook,
    HookContext,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

ROW = {"id": 1, "name": "Alice", "amount": 99.5}
ERROR = ValueError("constraint violation")
TABLE = "orders"
SOURCE = "orders.csv"
RUN_ID = "run-abc"


def make_record(**kwargs) -> DLQRecord:
    defaults = dict(
        row_data=ROW,
        error=ERROR,
        target_table=TABLE,
        source=SOURCE,
        run_id=RUN_ID,
        max_retries=3,
    )
    defaults.update(kwargs)
    return DLQRecord.create(**defaults)


# ---------------------------------------------------------------------------
# DLQRecord
# ---------------------------------------------------------------------------


class TestDLQRecord:
    def test_create_sets_defaults(self):
        r = make_record()
        assert r.status == DLQStatus.PENDING
        assert r.retry_count == 0
        assert r.retry_errors == []
        assert r.resolved_at is None
        assert r.last_attempted_at is None
        assert r.error_message == str(ERROR)
        assert r.error_type == "ValueError"
        assert r.target_table == TABLE
        assert r.source == SOURCE
        assert r.run_id == RUN_ID
        assert r.max_retries == 3

    def test_create_generates_unique_ids(self):
        ids = {make_record().id for _ in range(10)}
        assert len(ids) == 10

    def test_to_dict_status_is_string(self):
        r = make_record()
        d = r.to_dict()
        assert d["status"] == "pending"
        assert isinstance(d["status"], str)

    def test_roundtrip_serialisation(self):
        r = make_record()
        r2 = DLQRecord.from_dict(r.to_dict())
        assert r2.id == r.id
        assert r2.row_data == r.row_data
        assert r2.status == DLQStatus.PENDING
        assert r2.error_type == "ValueError"

    def test_is_retryable_true_when_pending_and_under_limit(self):
        r = make_record(max_retries=3)
        r.retry_count = 2
        assert r.is_retryable is True

    def test_is_retryable_false_when_exhausted(self):
        r = make_record(max_retries=3)
        r.retry_count = 3
        assert r.is_retryable is False

    def test_is_retryable_false_when_resolved(self):
        r = make_record()
        r.status = DLQStatus.RESOLVED
        assert r.is_retryable is False

    def test_from_dict_restores_enum(self):
        d = make_record().to_dict()
        d["status"] = "exhausted"
        r = DLQRecord.from_dict(d)
        assert r.status == DLQStatus.EXHAUSTED


# ---------------------------------------------------------------------------
# DLQStore
# ---------------------------------------------------------------------------


class TestDLQStore:
    def test_append_and_all(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r = make_record()
        store.append(r)
        records = store.all()
        assert len(records) == 1
        assert records[0].id == r.id

    def test_multiple_appends(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        for _ in range(5):
            store.append(make_record())
        assert len(store.all()) == 5

    def test_update_modifies_record(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r = make_record()
        store.append(r)

        r.status = DLQStatus.RESOLVED
        r.retry_count = 1
        store.update(r)

        records = store.all()
        assert len(records) == 1
        assert records[0].status == DLQStatus.RESOLVED
        assert records[0].retry_count == 1

    def test_update_only_changes_target_record(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r1 = make_record()
        r2 = make_record()
        store.append(r1)
        store.append(r2)

        r1.status = DLQStatus.EXHAUSTED
        store.update(r1)

        records = {r.id: r for r in store.all()}
        assert records[r1.id].status == DLQStatus.EXHAUSTED
        assert records[r2.id].status == DLQStatus.PENDING

    def test_filter_by_status(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        pending = make_record()
        resolved = make_record()
        resolved.status = DLQStatus.RESOLVED
        store.append(pending)
        store.append(resolved)

        assert len(store.filter(status=DLQStatus.PENDING)) == 1
        assert len(store.filter(status=DLQStatus.RESOLVED)) == 1
        assert len(store.filter(status=DLQStatus.EXHAUSTED)) == 0

    def test_filter_by_table(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r1 = make_record(target_table="orders")
        r2 = make_record(target_table="customers")
        store.append(r1)
        store.append(r2)

        assert len(store.filter(table="orders")) == 1
        assert store.filter(table="orders")[0].target_table == "orders"

    def test_filter_combined(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r1 = make_record(target_table="orders")
        r2 = make_record(target_table="orders")
        r2.status = DLQStatus.RESOLVED
        r3 = make_record(target_table="customers")
        store.append(r1)
        store.append(r2)
        store.append(r3)

        result = store.filter(status=DLQStatus.PENDING, table="orders")
        assert len(result) == 1
        assert result[0].id == r1.id

    def test_get_by_id(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r = make_record()
        store.append(r)
        found = store.get(r.id)
        assert found is not None
        assert found.id == r.id

    def test_get_missing_returns_none(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        assert store.get("nonexistent-id") is None

    def test_count_all(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        for _ in range(4):
            store.append(make_record())
        assert store.count() == 4

    def test_count_by_status(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        r = make_record()
        r.status = DLQStatus.EXHAUSTED
        store.append(r)
        assert store.count(DLQStatus.PENDING) == 1
        assert store.count(DLQStatus.EXHAUSTED) == 1

    def test_summary(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        r = make_record()
        r.status = DLQStatus.RESOLVED
        store.append(r)
        s = store.summary()
        assert s["pending"] == 1
        assert s["resolved"] == 1
        assert s["exhausted"] == 0
        assert s["retrying"] == 0

    def test_creates_parent_directories(self, tmp_path):
        DLQStore(tmp_path / "nested" / "dir" / "dlq.ndjson")
        assert (tmp_path / "nested" / "dir").exists()

    def test_empty_store_all_returns_empty(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        assert store.all() == []


# ---------------------------------------------------------------------------
# DLQRetryRunner
# ---------------------------------------------------------------------------


class TestDLQRetryRunner:
    def test_retry_success_marks_resolved(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        runner = DLQRetryRunner(store)

        processor = MagicMock()
        result = runner.retry_pending(processor)

        assert result == {"resolved": 1, "exhausted": 0}
        assert store.all()[0].status == DLQStatus.RESOLVED
        processor.assert_called_once_with(ROW)

    def test_retry_failure_increments_retry_count(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record(max_retries=3))
        runner = DLQRetryRunner(store)

        processor = MagicMock(side_effect=RuntimeError("db down"))
        result = runner.retry_pending(processor)

        assert result == {"resolved": 0, "exhausted": 0}
        r = store.all()[0]
        assert r.retry_count == 1
        assert r.status == DLQStatus.PENDING
        assert "db down" in r.retry_errors[0]

    def test_retry_exhausts_after_max_retries(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r = make_record(max_retries=2)
        r.retry_count = 1
        store.append(r)
        runner = DLQRetryRunner(store)

        processor = MagicMock(side_effect=RuntimeError("still failing"))
        result = runner.retry_pending(processor)

        assert result == {"resolved": 0, "exhausted": 1}
        assert store.all()[0].status == DLQStatus.EXHAUSTED

    def test_retry_skips_resolved_records(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r = make_record()
        r.status = DLQStatus.RESOLVED
        store.append(r)
        runner = DLQRetryRunner(store)

        processor = MagicMock()
        runner.retry_pending(processor)
        processor.assert_not_called()

    def test_retry_skips_exhausted_records(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        r = make_record(max_retries=1)
        r.retry_count = 1
        r.status = DLQStatus.EXHAUSTED
        store.append(r)
        runner = DLQRetryRunner(store)

        processor = MagicMock()
        runner.retry_pending(processor)
        processor.assert_not_called()

    def test_retry_filters_by_table(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record(target_table="orders"))
        store.append(make_record(target_table="customers"))
        runner = DLQRetryRunner(store)

        processor = MagicMock()
        runner.retry_pending(processor, table="orders")
        assert processor.call_count == 1

    def test_retry_accumulates_error_history(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record(max_retries=3))
        runner = DLQRetryRunner(store)

        for msg in ["err1", "err2"]:
            runner.retry_pending(MagicMock(side_effect=RuntimeError(msg)))

        r = store.all()[0]
        assert len(r.retry_errors) == 2
        assert "err1" in r.retry_errors[0]
        assert "err2" in r.retry_errors[1]

    def test_retry_sets_last_attempted_at(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        runner = DLQRetryRunner(store)

        runner.retry_pending(MagicMock())
        assert store.all()[0].last_attempted_at is not None

    def test_retry_sets_resolved_at_on_success(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        runner = DLQRetryRunner(store)

        runner.retry_pending(MagicMock())
        assert store.all()[0].resolved_at is not None

    def test_retry_mixed_batch(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        for _ in range(3):
            store.append(make_record(max_retries=2))

        call_count = 0

        def processor(row):
            nonlocal call_count
            call_count += 1
            if call_count == 2:
                raise RuntimeError("middle one fails")

        runner = DLQRetryRunner(store)
        result = runner.retry_pending(processor)

        assert result["resolved"] == 2
        statuses = {r.status for r in store.all()}
        assert DLQStatus.RESOLVED in statuses
        assert DLQStatus.PENDING in statuses


# ---------------------------------------------------------------------------
# DLQExporter
# ---------------------------------------------------------------------------


class TestDLQExporter:
    def test_to_json_creates_file(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        exporter = DLQExporter(store)

        out = tmp_path / "export.json"
        count = exporter.to_json(out)

        assert count == 1
        assert out.exists()
        data = json.loads(out.read_text())
        assert len(data) == 1
        assert data[0]["target_table"] == TABLE

    def test_to_json_filters_by_status(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        r = make_record()
        r.status = DLQStatus.EXHAUSTED
        store.append(r)

        exporter = DLQExporter(store)
        out = tmp_path / "export.json"
        count = exporter.to_json(out, status=DLQStatus.EXHAUSTED)

        assert count == 1
        data = json.loads(out.read_text())
        assert data[0]["status"] == "exhausted"

    def test_to_csv_creates_file(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        exporter = DLQExporter(store)

        out = tmp_path / "export.csv"
        count = exporter.to_csv(out)

        assert count == 1
        assert out.exists()
        rows = list(csv.DictReader(out.open()))
        assert len(rows) == 1
        assert rows[0]["target_table"] == TABLE

    def test_to_csv_row_data_is_json_encoded(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        exporter = DLQExporter(store)

        out = tmp_path / "export.csv"
        exporter.to_csv(out)

        rows = list(csv.DictReader(out.open()))
        # row_data column should be parseable JSON
        parsed = json.loads(rows[0]["row_data"])
        assert parsed == ROW

    def test_to_csv_empty_store_returns_zero(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        exporter = DLQExporter(store)
        out = tmp_path / "export.csv"
        assert exporter.to_csv(out) == 0

    def test_to_json_pretty_by_default(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        exporter = DLQExporter(store)

        out = tmp_path / "export.json"
        exporter.to_json(out, pretty=True)

        content = out.read_text()
        assert "\n" in content  # indented

    def test_to_json_compact_when_not_pretty(self, tmp_path):
        store = DLQStore(tmp_path / "dlq.ndjson")
        store.append(make_record())
        exporter = DLQExporter(store)

        out = tmp_path / "export.json"
        exporter.to_json(out, pretty=False)

        content = out.read_text().strip()
        assert content.startswith("[{")  # compact single line


# ---------------------------------------------------------------------------
# DLQManager
# ---------------------------------------------------------------------------


class TestDLQManager:
    def test_record_failure_persists(self, tmp_path):
        dlq = DLQManager(path=tmp_path / "dlq.ndjson")
        record = dlq.record_failure(ROW, ERROR, TABLE, SOURCE, RUN_ID)

        assert record.status == DLQStatus.PENDING
        assert dlq.pending_count() == 1

    def test_retry_all_success(self, tmp_path):
        dlq = DLQManager(path=tmp_path / "dlq.ndjson")
        dlq.record_failure(ROW, ERROR, TABLE, SOURCE, RUN_ID)

        result = dlq.retry_all(processor=MagicMock())
        assert result == {"resolved": 1, "exhausted": 0}
        assert dlq.pending_count() == 0

    def test_summary_reflects_state(self, tmp_path):
        dlq = DLQManager(path=tmp_path / "dlq.ndjson", max_retries=1)
        dlq.record_failure(ROW, ERROR, TABLE, SOURCE, RUN_ID)
        dlq.retry_all(processor=MagicMock(side_effect=RuntimeError("fail")))

        s = dlq.summary()
        assert s["exhausted"] == 1
        assert dlq.exhausted_count() == 1

    def test_export_json(self, tmp_path):
        dlq = DLQManager(path=tmp_path / "dlq.ndjson")
        dlq.record_failure(ROW, ERROR, TABLE, SOURCE, RUN_ID)

        out = tmp_path / "out.json"
        count = dlq.export_json(out)
        assert count == 1
        data = json.loads(out.read_text())
        assert data[0]["error_type"] == "ValueError"

    def test_export_csv(self, tmp_path):
        dlq = DLQManager(path=tmp_path / "dlq.ndjson")
        dlq.record_failure(ROW, ERROR, TABLE, SOURCE, RUN_ID)

        out = tmp_path / "out.csv"
        count = dlq.export_csv(out)
        assert count == 1

    def test_default_max_retries_applied_to_records(self, tmp_path):
        dlq = DLQManager(path=tmp_path / "dlq.ndjson", max_retries=7)
        record = dlq.record_failure(ROW, ERROR, TABLE, SOURCE, RUN_ID)
        assert record.max_retries == 7


# ---------------------------------------------------------------------------
# DeadLetterQueueHook integration
# ---------------------------------------------------------------------------


class TestDeadLetterQueueHookIntegration:
    def _make_context(self, **meta_overrides) -> HookContext:
        meta = {
            "table": TABLE,
            "source": SOURCE,
            "run_id": RUN_ID,
        }
        meta.update(meta_overrides)
        return HookContext(
            row_data=ROW,
            error=ERROR,
            metadata=meta,
        )

    @patch("atomingest.pipeline.hooks.record_dlq_entry")
    def test_metric_always_recorded(self, mock_metric):
        hook = DeadLetterQueueHook()
        ctx = self._make_context()  # no db, no dlq_manager
        hook.execute(ctx)
        mock_metric.assert_called_once_with(table_name=TABLE, error_type="ValueError")

    @patch("atomingest.pipeline.hooks.record_dlq_entry")
    def test_metric_recorded_even_without_db(self, mock_metric):
        hook = DeadLetterQueueHook()
        ctx = self._make_context()
        hook.execute(ctx)
        assert mock_metric.call_count == 1

    @patch("atomingest.pipeline.hooks.record_dlq_entry")
    def test_db_persistence_called_when_db_present(self, _mock_metric):
        mock_db = MagicMock()
        mock_model = MagicMock()
        mock_db.register.return_value = None
        mock_model.save.return_value = None

        hook = DeadLetterQueueHook()
        ctx = self._make_context(db=mock_db)

        with patch(
            "atomingest.pipeline.hooks.DLQFailureModel", return_value=mock_model
        ):
            hook.execute(ctx)

        mock_db.register.assert_called_once()

    @patch("atomingest.pipeline.hooks.record_dlq_entry")
    def test_dlq_manager_called_when_present(self, _mock_metric, tmp_path):
        mock_dlq = MagicMock()
        hook = DeadLetterQueueHook()
        ctx = self._make_context(dlq_manager=mock_dlq)
        hook.execute(ctx)

        mock_dlq.record_failure.assert_called_once_with(
            row_data=ROW,
            error=ERROR,
            target_table=TABLE,
            source=SOURCE,
            run_id=RUN_ID,
        )

    @patch("atomingest.pipeline.hooks.record_dlq_entry")
    def test_returns_context_unchanged(self, _mock_metric):
        hook = DeadLetterQueueHook()
        ctx = self._make_context()
        result = hook.execute(ctx)
        assert result is ctx

    @patch("atomingest.pipeline.hooks.record_dlq_entry")
    def test_dlq_manager_error_does_not_raise(self, _mock_metric):
        mock_dlq = MagicMock()
        mock_dlq.record_failure.side_effect = RuntimeError("disk full")
        hook = DeadLetterQueueHook()
        ctx = self._make_context(dlq_manager=mock_dlq)
        # Should not raise — errors in the hook are swallowed and logged
        hook.execute(ctx)

    @patch("atomingest.pipeline.hooks.record_dlq_entry")
    def test_none_error_uses_unknown_error_type(self, mock_metric):
        hook = DeadLetterQueueHook()
        ctx = HookContext(row_data=ROW, error=None, metadata={"table": TABLE})
        hook.execute(ctx)
        mock_metric.assert_called_once_with(table_name=TABLE, error_type="unknown")
