"""Turning runs into rows and back.

Pure functions, so this is testable exhaustively without a database — which
matters, because this is where the expensive bug lives. A field added to the
dataclass and forgotten in the mapping loses data silently, and only after a
restart, by which point the run it lost is a client's document that nobody
filed.

The introspection tests below exist for exactly that: they fail when a field is
added to the dataclass and not to the mapping, rather than waiting for somebody
to notice a value went missing.
"""

from __future__ import annotations

from dataclasses import fields
from datetime import UTC, datetime

import pytest

from app.workflow.serialisation import (
    row_to_run,
    row_to_step,
    run_to_row,
    step_to_row,
)
from app.workflow.state import RunState, StepRecord, StepState, WorkflowRun


def full_step() -> StepRecord:
    """A step with every field set to something distinguishable."""
    return StepRecord(
        name="file",
        tool="submit_filing_proposal",
        state=StepState.AWAITING_APPROVAL,
        attempts=2,
        output={"proposal_id": 901, "status": "pending"},
        error="a previous attempt failed",
        confidence=0.9375,
        duration_ms=1234,
        started_at=datetime(2026, 7, 28, 10, 0, tzinfo=UTC),
        finished_at=datetime(2026, 7, 28, 10, 0, 3, tzinfo=UTC),
        proposed={"client_id": 42},
        approved=True,
        approved_by="hina@firm.test",
        amendments={"client_id": 77, "document_type": "salary_slip"},
    )


def full_run() -> WorkflowRun:
    return WorkflowRun(
        workflow="document_intake",
        context={"path": "/tmp/x.jpg", "attempt": 2, "identify": {"matches": [{"id": 42}]}},
        steps=[StepRecord(name="read", tool="ocr_document", state=StepState.SUCCEEDED), full_step()],
        state=RunState.AWAITING_APPROVAL,
        error=None,
        version=7,
        created_at=datetime(2026, 7, 28, 9, 0, tzinfo=UTC),
        updated_at=datetime(2026, 7, 28, 10, 0, tzinfo=UTC),
    )


class TestNothingIsForgotten:
    """The guard against a field added later and mapped never."""

    def test_every_step_field_is_written(self):
        row = step_to_row("run-1", 0, full_step())

        # Introspected rather than listed, so adding a field to StepRecord
        # without adding it here fails immediately instead of silently.
        for field in fields(StepRecord):
            assert field.name in row, f"StepRecord.{field.name} is not written to its row"

    def test_every_run_field_is_written_or_deliberately_not(self):
        row = run_to_row(full_run())

        # `steps` lives in its own table; everything else must be a column.
        expected_elsewhere = {"steps"}

        for field in fields(WorkflowRun):
            if field.name in expected_elsewhere:
                continue

            assert field.name in row, f"WorkflowRun.{field.name} is not written to its row"


class TestRoundTrip:
    def test_a_step_survives_intact(self):
        original = full_step()
        restored = row_to_step(step_to_row("run-1", 0, original))

        assert restored == original

    def test_a_run_survives_intact(self):
        original = full_run()
        row = run_to_row(original)
        step_rows = [step_to_row(original.id, i, s) for i, s in enumerate(original.steps)]

        restored = row_to_run(row, step_rows)

        assert restored == original

    def test_steps_are_restored_in_position_order(self):
        original = full_run()
        row = run_to_row(original)
        step_rows = [step_to_row(original.id, i, s) for i, s in enumerate(original.steps)]

        # A query without ORDER BY, or a driver that does not preserve order.
        # "Resume from the first step not done" depends on this, so it is
        # restored by position rather than by arrival.
        restored = row_to_run(row, list(reversed(step_rows)))

        assert [s.name for s in restored.steps] == ["read", "file"]

    def test_nested_context_is_not_flattened(self):
        original = full_run()
        restored = row_to_run(run_to_row(original), [])

        assert restored.context["identify"]["matches"][0]["id"] == 42
        assert restored.context["attempt"] == 2


class TestDriverTolerance:
    def test_iso_strings_are_accepted_as_timestamps(self):
        """psycopg returns datetimes; a fixture or another driver returns strings."""
        row = run_to_row(full_run())
        row["created_at"] = "2026-07-28T09:00:00+00:00"
        row["updated_at"] = "2026-07-28T10:00:00+00:00"

        restored = row_to_run(row, [])

        assert restored.created_at == datetime(2026, 7, 28, 9, 0, tzinfo=UTC)

    def test_null_json_columns_become_empty_dicts(self):
        """A row written before a column had a default, or by hand."""
        row = run_to_row(full_run())
        row["context"] = None

        assert row_to_run(row, []).context == {}

    def test_a_missing_archived_at_is_not_an_error(self):
        row = run_to_row(full_run())
        row.pop("archived_at")

        assert row_to_run(row, []).archived_at is None

    def test_states_are_restored_as_enums_not_strings(self):
        restored = row_to_run(run_to_row(full_run()), [step_to_row("r", 0, full_step())])

        # A string here compares equal to the enum's value and fails every
        # `is` check in the engine — which is how a run silently stops resuming.
        assert restored.state is RunState.AWAITING_APPROVAL
        assert restored.steps[0].state is StepState.AWAITING_APPROVAL

    def test_an_unknown_state_is_refused_rather_than_guessed(self):
        row = run_to_row(full_run())
        row["state"] = "something_new"

        # A newer version wrote a state this build does not know. Failing loudly
        # beats resuming a run whose state was quietly reinterpreted.
        with pytest.raises(ValueError):
            row_to_run(row, [])
