"""Tests for bulk data processor."""

from __future__ import annotations

from orxaq.orchestration.bulk import BulkProcessor, BulkProgress, BulkResult


def _sample_records(n: int = 10) -> list[dict]:
    return [{"id": i, "value": i * 10} for i in range(n)]


def _double_handler(record: dict) -> dict:
    return {"id": record["id"], "doubled": record["value"] * 2}


def _fail_on_5(record: dict) -> dict:
    if record["id"] == 5:
        msg = "record 5 bad"
        raise ValueError(msg)
    return {"id": record["id"], "ok": True}


def _skip_even(record: dict) -> dict | None:
    if record["id"] % 2 == 0:
        return None
    return record


# ── BulkProgress ────────────────────────────────────────


class TestBulkProgress:
    def test_defaults(self):
        p = BulkProgress()
        assert p.total == 0
        assert p.processed == 0
        assert p.percent == 0.0

    def test_percent(self):
        p = BulkProgress(total=100, processed=25)
        assert p.percent == 25.0

    def test_to_dict(self):
        p = BulkProgress(total=10, processed=5, succeeded=4, failed=1)
        d = p.to_dict()
        assert d["total"] == 10
        assert d["processed"] == 5
        assert d["succeeded"] == 4
        assert d["failed"] == 1

    def test_eta(self):
        p = BulkProgress(total=100, processed=0)
        assert p.eta_s == 0.0


# ── BulkResult ──────────────────────────────────────────


class TestBulkResult:
    def test_empty(self):
        r = BulkResult()
        assert r.success_rate == 0.0
        d = r.to_dict()
        assert d["result_count"] == 0
        assert d["error_count"] == 0

    def test_with_data(self):
        p = BulkProgress(total=10, processed=10, succeeded=8, failed=2)
        r = BulkResult(
            results=[{"x": 1}] * 8,
            errors=[{"e": 1}] * 2,
            progress=p,
        )
        assert r.success_rate == 0.8


# ── BulkProcessor.run ──────────────────────────────────


class TestBulkProcessorRun:
    def test_basic_processing(self):
        proc = BulkProcessor()
        result = proc.run(_sample_records(5), _double_handler)
        assert len(result.results) == 5
        assert result.results[0]["doubled"] == 0
        assert result.results[4]["doubled"] == 80
        assert result.progress.succeeded == 5
        assert result.progress.failed == 0

    def test_with_errors(self):
        proc = BulkProcessor()
        result = proc.run(_sample_records(10), _fail_on_5)
        assert result.progress.succeeded == 9
        assert result.progress.failed == 1
        assert len(result.errors) == 1
        assert result.errors[0]["index"] == 5

    def test_skip_none(self):
        proc = BulkProcessor()
        result = proc.run(_sample_records(6), _skip_even)
        assert len(result.results) == 3  # ids 1, 3, 5
        assert result.progress.succeeded == 6  # all "succeeded"

    def test_progress_callback(self):
        progress_log: list[int] = []
        proc = BulkProcessor()
        proc.run(
            _sample_records(3),
            _double_handler,
            on_progress=lambda p: progress_log.append(p.processed),
        )
        assert progress_log == [1, 2, 3]

    def test_chunk_callback(self):
        chunks: list[list[dict]] = []
        proc = BulkProcessor(chunk_size=3)
        proc.run(
            _sample_records(7),
            _double_handler,
            on_chunk=lambda c: chunks.append(list(c)),
        )
        assert len(chunks) == 3  # 3 + 3 + 1

    def test_max_errors_stops_early(self):
        def always_fail(record: dict) -> dict:
            msg = "fail"
            raise ValueError(msg)

        proc = BulkProcessor(max_errors=3)
        result = proc.run(_sample_records(10), always_fail)
        assert result.progress.failed == 3
        assert result.progress.processed == 3

    def test_empty_records(self):
        proc = BulkProcessor()
        result = proc.run([], _double_handler)
        assert len(result.results) == 0
        assert result.progress.processed == 0

    def test_iterator_input(self):
        def gen():
            for i in range(3):
                yield {"id": i, "value": i}

        proc = BulkProcessor()
        result = proc.run(gen(), _double_handler)
        assert len(result.results) == 3


# ── BulkProcessor.run_threaded ─────────────────────────


class TestBulkProcessorThreaded:
    def test_threaded_basic(self):
        proc = BulkProcessor()
        result = proc.run_threaded(_sample_records(20), _double_handler, n_workers=4)
        assert result.progress.succeeded == 20
        assert len(result.results) == 20

    def test_threaded_with_errors(self):
        proc = BulkProcessor()
        result = proc.run_threaded(_sample_records(10), _fail_on_5, n_workers=2)
        assert result.progress.failed == 1
        assert result.progress.succeeded == 9

    def test_threaded_progress(self):
        progress_log: list[int] = []
        proc = BulkProcessor()
        proc.run_threaded(
            _sample_records(10),
            _double_handler,
            n_workers=2,
            on_progress=lambda p: progress_log.append(p.processed),
        )
        assert len(progress_log) == 10


# ── BulkProcessor.iter_results ─────────────────────────


class TestBulkProcessorIter:
    def test_iter_results(self):
        proc = BulkProcessor()
        results = list(proc.iter_results(_sample_records(5), _double_handler))
        assert len(results) == 5

    def test_iter_skips_errors(self):
        proc = BulkProcessor()
        results = list(proc.iter_results(_sample_records(10), _fail_on_5))
        assert len(results) == 9

    def test_iter_skips_none(self):
        proc = BulkProcessor()
        results = list(proc.iter_results(_sample_records(6), _skip_even))
        assert len(results) == 3


# ── BulkProcessor.dry_run ──────────────────────────────


class TestBulkProcessorDryRun:
    def test_dry_run_success(self):
        proc = BulkProcessor()
        report = proc.dry_run(_sample_records(100), _double_handler, sample_size=10)
        assert report["sample_size"] == 10
        assert report["total_records"] == 100
        assert report["sample_successes"] == 10
        assert report["sample_errors"] == 0
        assert report["estimated_error_rate"] == 0.0
        assert report["avg_time_per_record_ms"] >= 0

    def test_dry_run_with_errors(self):
        proc = BulkProcessor()
        report = proc.dry_run(_sample_records(100), _fail_on_5, sample_size=10)
        assert report["sample_errors"] == 1
        assert report["estimated_error_rate"] == 0.1

    def test_dry_run_empty(self):
        proc = BulkProcessor()
        report = proc.dry_run([], _double_handler)
        assert report["sample_size"] == 0
        assert report["estimated_error_rate"] == 0.0


# ── Targeted coverage (line 161: max_errors with on_progress callback) ──


class TestMaxErrorsWithProgress:
    """Cover line 160-161: on_progress called when max_errors exceeded."""

    def test_progress_called_on_max_errors(self):
        progress_log = []
        proc = BulkProcessor(max_errors=1)

        # Handler that always fails
        def _always_fail(record: dict) -> dict:
            msg = "boom"
            raise ValueError(msg)

        proc.run(
            _sample_records(5),
            _always_fail,
            on_progress=lambda p: progress_log.append(p.failed),
        )
        # on_progress should have been called at the break point
        assert len(progress_log) >= 1
        assert progress_log[-1] >= 1


# ── Targeted coverage (line 41) ──────────────────────────


class TestBulkProgressZeroElapsed:
    """Cover line 41: rate returns 0.0 when elapsed <= 0."""

    def test_rate_zero_when_future_start(self):
        import time

        p = BulkProgress(total=10, processed=5, started_at=time.time() + 1000)
        assert p.rate == 0.0
