"""Tests for batch processing job queue."""

from __future__ import annotations

import json
import threading

from orxaq.orchestration.batch import (
    BatchQueue,
    Job,
    JobResult,
    JobStatus,
    save_results,
)


def _echo_handler(**kwargs):
    """Return kwargs as-is for testing."""
    return kwargs


def _fail_handler(**kwargs):
    """Always raises RuntimeError."""
    msg = "deliberate failure"
    raise RuntimeError(msg)


# ── JobStatus enum ───────────────────────────────────────


class TestJobStatus:
    def test_values(self):
        assert JobStatus.PENDING.value == "pending"
        assert JobStatus.RUNNING.value == "running"
        assert JobStatus.COMPLETED.value == "completed"
        assert JobStatus.FAILED.value == "failed"
        assert JobStatus.CANCELLED.value == "cancelled"

    def test_members_count(self):
        assert len(JobStatus) == 5


# ── Job dataclass ────────────────────────────────────────


class TestJob:
    def test_defaults(self):
        job = Job(job_id="abc", job_type="echo")
        assert job.status == JobStatus.PENDING
        assert job.result is None
        assert job.params == {}

    def test_to_dict_minimal(self):
        job = Job(job_id="x1", job_type="echo")
        d = job.to_dict()
        assert d["job_id"] == "x1"
        assert d["status"] == "pending"
        assert "started_at" not in d
        assert "finished_at" not in d

    def test_to_dict_with_result(self):
        job = Job(
            job_id="x2",
            job_type="echo",
            status=JobStatus.COMPLETED,
            started_at=1.0,
            finished_at=2.0,
            result=JobResult(data={"k": "v"}, duration_ms=42.0),
        )
        d = job.to_dict()
        assert d["started_at"] == 1.0
        assert d["finished_at"] == 2.0
        assert d["result"] == {"k": "v"}
        assert d["duration_ms"] == 42.0

    def test_to_dict_with_error(self):
        job = Job(
            job_id="x3",
            job_type="fail",
            status=JobStatus.FAILED,
            error="boom",
            result=JobResult(error="boom", duration_ms=1.0),
        )
        d = job.to_dict()
        assert d["error"] == "boom"
        assert d["status"] == "failed"


# ── BatchQueue ───────────────────────────────────────────


class TestBatchQueueSubmit:
    def test_submit_returns_id(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        jid = q.submit("echo", {"a": 1})
        assert isinstance(jid, str)
        assert len(jid) == 12

    def test_submit_unknown_type_raises(self):
        q = BatchQueue()
        try:
            q.submit("unknown")
            assert False, "Expected ValueError"
        except ValueError as e:
            assert "Unknown job type" in str(e)

    def test_submit_increments_pending(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        assert q.pending_count() == 0
        q.submit("echo")
        assert q.pending_count() == 1
        q.submit("echo")
        assert q.pending_count() == 2


class TestBatchQueueProcess:
    def test_process_one_success(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        jid = q.submit("echo", {"x": 42})
        result_id = q.process_one()
        assert result_id == jid
        job = q.get(jid)
        assert job.status == JobStatus.COMPLETED
        assert job.result.data == {"x": 42}
        assert job.result.duration_ms >= 0

    def test_process_one_failure(self):
        q = BatchQueue()
        q.register("fail", _fail_handler)
        jid = q.submit("fail")
        q.process_one()
        job = q.get(jid)
        assert job.status == JobStatus.FAILED
        assert "deliberate failure" in job.error
        assert job.result.error is not None

    def test_process_one_empty_returns_none(self):
        q = BatchQueue()
        assert q.process_one() is None

    def test_process_all(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        ids = [q.submit("echo", {"i": i}) for i in range(5)]
        processed = q.process_all()
        assert processed == ids
        assert q.pending_count() == 0
        for jid in ids:
            job = q.get(jid)
            assert job.status == JobStatus.COMPLETED

    def test_fifo_ordering(self):
        q = BatchQueue()
        results = []

        def recorder(**kw):
            results.append(kw["seq"])
            return kw

        q.register("rec", recorder)
        for i in range(4):
            q.submit("rec", {"seq": i})
        q.process_all()
        assert results == [0, 1, 2, 3]


class TestBatchQueueCancel:
    def test_cancel_pending(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        jid = q.submit("echo")
        assert q.cancel(jid) is True
        job = q.get(jid)
        assert job.status == JobStatus.CANCELLED
        assert q.pending_count() == 0

    def test_cancel_nonexistent(self):
        q = BatchQueue()
        assert q.cancel("nope") is False

    def test_cancel_completed_returns_false(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        jid = q.submit("echo")
        q.process_one()
        assert q.cancel(jid) is False


class TestBatchQueueList:
    def test_list_all(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        q.submit("echo")
        q.submit("echo")
        jobs = q.list_jobs()
        assert len(jobs) == 2

    def test_list_by_status(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        q.submit("echo")
        q.submit("echo")
        q.process_one()
        pending = q.list_jobs(status=JobStatus.PENDING)
        completed = q.list_jobs(status=JobStatus.COMPLETED)
        assert len(pending) == 1
        assert len(completed) == 1

    def test_list_sorted_newest_first(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        id1 = q.submit("echo")
        id2 = q.submit("echo")
        jobs = q.list_jobs()
        assert jobs[0].job_id == id2
        assert jobs[1].job_id == id1


class TestBatchQueueStats:
    def test_stats_empty(self):
        q = BatchQueue()
        s = q.stats()
        assert s["total_jobs"] == 0
        assert s["by_status"] == {}
        assert s["avg_duration_ms"] == 0.0

    def test_stats_with_jobs(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        q.submit("echo", {"a": 1})
        q.process_all()
        s = q.stats()
        assert s["total_jobs"] == 1
        assert s["by_status"]["completed"] == 1
        assert s["avg_duration_ms"] >= 0
        assert "echo" in s["handler_types"]


class TestBatchQueueClear:
    def test_clear_removes_terminal(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        q.register("fail", _fail_handler)
        q.submit("echo")
        q.submit("fail")
        jid3 = q.submit("echo")
        q.process_one()  # completes first echo
        q.process_one()  # fails fail
        # jid3 still pending
        removed = q.clear()
        assert removed == 2
        assert q.get(jid3) is not None
        assert q.get(jid3).status == JobStatus.PENDING


class TestBatchQueueEviction:
    def test_eviction_removes_oldest(self):
        q = BatchQueue(max_history=2)
        q.register("echo", _echo_handler)
        ids = []
        for i in range(4):
            ids.append(q.submit("echo", {"i": i}))
        q.process_all()
        # max_history=2: oldest 2 evicted
        remaining = q.list_jobs()
        assert len(remaining) == 2
        remaining_ids = {j.job_id for j in remaining}
        assert ids[0] not in remaining_ids
        assert ids[1] not in remaining_ids


class TestBatchQueueThreadSafety:
    def test_concurrent_submit(self):
        q = BatchQueue()
        q.register("echo", _echo_handler)
        errors = []

        def submitter():
            try:
                for _ in range(50):
                    q.submit("echo", {"t": 1})
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=submitter) for _ in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert not errors
        assert q.pending_count() == 200


# ── save_results ─────────────────────────────────────────


class TestSaveResults:
    def test_save_completed_only(self, tmp_path):
        jobs = [
            Job(
                job_id="c1",
                job_type="echo",
                status=JobStatus.COMPLETED,
                result=JobResult(data={"ok": True}),
            ),
            Job(
                job_id="f1",
                job_type="fail",
                status=JobStatus.FAILED,
            ),
        ]
        p = save_results(jobs, tmp_path / "out.json")
        assert p.exists()
        records = json.loads(p.read_text())
        assert len(records) == 1
        assert records[0]["job_id"] == "c1"

    def test_save_creates_parent_dirs(self, tmp_path):
        deep = tmp_path / "a" / "b" / "results.json"
        save_results([], deep)
        assert deep.exists()
        assert json.loads(deep.read_text()) == []
