from __future__ import annotations

from dataclasses import replace

import pytest

from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
    MODELING_STATE_BASE,
    OPENAPI_SUCCESS,
    SYSTEM_ERROR_STATE_BASE,
    CommandHeaderIdentity,
    ModelingAction,
    ModelingCommandRejected,
    ModelingEncodeError,
    ModelingProtocolError,
    ModelingResponseCorrelationError,
    MountType,
    ObservedHeader,
    RecordMode,
    ScanMode,
    SessionState,
    correlate_modeling_response,
    decode_device_status_report,
    decode_modeling_response,
    decode_system_error_report,
    encode_modeling_start,
    encode_modeling_stop,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_state import (
    AcquisitionPhase,
    DeviceAcquisitionStateMachine,
    SaveEvidence,
)
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields


def _varint(value: int) -> bytes:
    encoded = bytearray()
    while value > 0x7F:
        encoded.append((value & 0x7F) | 0x80)
        value >>= 7
    encoded.append(value)
    return bytes(encoded)


def _uint(number: int, value: int) -> bytes:
    return _varint(number << 3) + _varint(value)


def _bytes(number: int, value: bytes) -> bytes:
    return _varint((number << 3) | 2) + _varint(len(value)) + value


def _text(number: int, value: str) -> bytes:
    return _bytes(number, value.encode())


def _identity(**changes: str) -> CommandHeaderIdentity:
    values = {
        "device_id": "synthetic-device",
        "openapi_key": "synthetic-explicit-key",
    }
    values.update(changes)
    return CommandHeaderIdentity(**values)


def _header(
    identity: CommandHeaderIdentity,
    *,
    session_id: str | None = None,
) -> bytes:
    return b"".join(
        (
            _text(4, identity.device_id),
            _text(5, identity.session_id if session_id is None else session_id),
            _text(6, identity.openapi_key),
        )
    )


def _response(
    identity: CommandHeaderIdentity,
    action: ModelingAction,
    *,
    code: int = OPENAPI_SUCCESS,
    description: str = "description-is-not-the-success-gate",
    session_id: str | None = None,
) -> bytes:
    error = _uint(1, code) + _text(2, description)
    return (
        _bytes(1, _header(identity, session_id=session_id)) + _uint(2, action) + _bytes(15, error)
    )


def _device_status(
    state: SessionState | None,
    *,
    raw_code: int | None = None,
    init_ready: bool = False,
    project_id: str | None = None,
) -> bytes:
    if raw_code is None:
        assert state is not None
        raw_code = MODELING_STATE_BASE + state
    fields = [_uint(2, raw_code)]
    if project_id is not None:
        fields.append(_text(4, project_id))
    if init_ready:
        fields.append(_uint(6, 1))
    return b"".join(fields)


def test_command_identity_is_explicit_bounded_and_redacted() -> None:
    identity = _identity()
    assert "synthetic" not in repr(identity)
    assert identity.session_id == "synthetic-device:ModelingRequest"

    for field_name in ("device_id", "openapi_key"):
        with pytest.raises(ModelingEncodeError, match="non-empty"):
            _identity(**{field_name: ""})
    with pytest.raises(ModelingEncodeError, match="printable ASCII"):
        _identity(device_id="не-ascii")
    with pytest.raises(ModelingEncodeError, match="without spaces"):
        _identity(openapi_key="unsafe key")
    assert not identity.matches(
        ObservedHeader(
            device_id="не-ascii",
            session_id=identity.session_id,
            openapi_key=identity.openapi_key,
        )
    )


def test_start_encoder_matches_recovered_field_shape() -> None:
    identity = _identity()
    command = encode_modeling_start(
        identity,
        project_name="synthetic-project",
        record_mode=RecordMode.RECORD_AND_CALCULATE,
        scan_mode=ScanMode.LCC,
        mount_type=MountType.HANDHELD,
    )

    fields = list(iter_fields(command.payload, max_fields=16))
    assert command.action is ModelingAction.START
    # HANDHELD is proto3 zero and is therefore canonically absent.
    assert [field.number for field in fields] == [1, 2, 3, 4, 5]
    assert fields[1].value == ModelingAction.START
    assert fields[2].value == b"synthetic-project"
    assert fields[3].value == RecordMode.RECORD_AND_CALCULATE
    assert fields[4].value == ScanMode.LCC

    header_fields = list(iter_fields(fields[0].value, max_fields=8))  # type: ignore[arg-type]
    assert [field.number for field in header_fields] == [4, 5, 6]
    assert [field.value for field in header_fields] == [
        b"synthetic-device",
        b"synthetic-device:ModelingRequest",
        b"synthetic-explicit-key",
    ]
    assert "synthetic" not in repr(command)


def test_start_requires_explicit_enums_and_encodes_optional_pre_project() -> None:
    identity = _identity()
    with pytest.raises(ModelingEncodeError, match="explicit RecordMode"):
        encode_modeling_start(
            identity,
            project_name="project",
            record_mode=2,  # type: ignore[arg-type]
            scan_mode=ScanMode.LCC,
            mount_type=MountType.HANDHELD,
        )

    command = encode_modeling_start(
        identity,
        project_name="project",
        record_mode=RecordMode.CALCULATE_ONLY,
        scan_mode=ScanMode.POINT_CLOUD,
        mount_type=MountType.UAV,
        pre_project_id="synthetic-pre-project",
    )
    fields = list(iter_fields(command.payload, max_fields=16))
    assert [field.number for field in fields] == [1, 2, 3, 6, 7]
    assert fields[-2].value == MountType.UAV
    assert fields[-1].value == b"synthetic-pre-project"


def test_stop_encoder_contains_only_header_and_action() -> None:
    command = encode_modeling_stop(_identity())
    fields = list(iter_fields(command.payload, max_fields=8))
    assert command.action is ModelingAction.STOP
    assert [field.number for field in fields] == [1, 2]
    assert fields[1].value == ModelingAction.STOP


@pytest.mark.parametrize("field_name", ["device_id", "openapi_key"])
def test_response_correlation_requires_every_exact_identity(field_name: str) -> None:
    identity = _identity()
    expected = encode_modeling_stop(identity)
    mismatched = replace(identity, **{field_name: f"different-{field_name}"})
    with pytest.raises(ModelingResponseCorrelationError, match="identity mismatch"):
        correlate_modeling_response(_response(mismatched, ModelingAction.STOP), expected)


def test_response_correlation_rejects_noncanonical_modeling_session() -> None:
    identity = _identity()
    expected = encode_modeling_stop(identity)

    with pytest.raises(ModelingResponseCorrelationError, match="identity mismatch"):
        correlate_modeling_response(
            _response(
                identity,
                ModelingAction.STOP,
                session_id="synthetic-device:DifferentRequest",
            ),
            expected,
        )

    with pytest.raises(ModelingProtocolError, match="printable ASCII"):
        correlate_modeling_response(
            _response(identity, ModelingAction.STOP, session_id="не-ascii"),
            expected,
        )


def test_response_correlation_requires_action_and_numeric_success() -> None:
    identity = _identity()
    expected = encode_modeling_stop(identity)

    response = correlate_modeling_response(
        _response(identity, ModelingAction.STOP, description="arbitrary text"), expected
    )
    assert response.error.code == OPENAPI_SUCCESS

    with pytest.raises(ModelingResponseCorrelationError, match="action mismatch"):
        correlate_modeling_response(_response(identity, ModelingAction.START), expected)
    with pytest.raises(ModelingCommandRejected) as rejected:
        correlate_modeling_response(
            _response(identity, ModelingAction.STOP, code=OPENAPI_SUCCESS + 99), expected
        )
    assert rejected.value.code == OPENAPI_SUCCESS + 99


def test_response_parser_fails_closed_on_incomplete_or_ambiguous_payload() -> None:
    identity = _identity()
    error = _uint(1, OPENAPI_SUCCESS)
    with pytest.raises(ModelingProtocolError, match="identity is incomplete"):
        decode_modeling_response(
            _bytes(1, _text(4, identity.device_id))
            + _uint(2, ModelingAction.START)
            + _bytes(15, error)
        )
    with pytest.raises(ModelingProtocolError, match="duplicated"):
        decode_modeling_response(
            _bytes(1, _header(identity))
            + _uint(2, ModelingAction.START)
            + _uint(2, ModelingAction.START)
            + _bytes(15, error)
        )
    with pytest.raises(ModelingProtocolError, match="not start or stop"):
        decode_modeling_response(_bytes(1, _header(identity)) + _uint(2, 99) + _bytes(15, error))


def test_device_status_maps_base_offset_states_and_preserves_unknown() -> None:
    report = decode_device_status_report(
        _device_status(SessionState.SCANNING, init_ready=True, project_id="synthetic-project-id")
    )
    assert report.modeling_state_code == MODELING_STATE_BASE + SessionState.SCANNING
    assert report.session_state is SessionState.SCANNING
    assert report.init_ready
    assert repr(report).find("synthetic-project-id") == -1

    unknown = decode_device_status_report(_device_status(None, raw_code=MODELING_STATE_BASE + 999))
    assert unknown.session_state is None

    with pytest.raises(ModelingProtocolError, match="no modeling_state"):
        decode_device_status_report(b"")
    with pytest.raises(ModelingProtocolError, match="integer range"):
        decode_device_status_report(_uint(6, 2) + _uint(2, MODELING_STATE_BASE))


def test_system_error_maps_observed_algorithm_error_without_exposing_identity() -> None:
    identity = _identity()
    payload = _bytes(1, _header(identity)) + _bytes(
        15,
        _uint(1, SYSTEM_ERROR_STATE_BASE + SessionState.ALGORITHM_ERROR),
    )

    report = decode_system_error_report(payload)

    assert report.error_code == 0x3204_0133
    assert report.session_state is SessionState.ALGORITHM_ERROR
    assert "synthetic" not in repr(report)
    with pytest.raises(ModelingProtocolError, match="no error code"):
        decode_system_error_report(_bytes(1, _header(identity)))


def test_state_machine_never_promotes_status_only_evidence_to_durable_save() -> None:
    machine = DeviceAcquisitionStateMachine()
    assert machine.snapshot.phase is AcquisitionPhase.UNOBSERVED
    assert not machine.snapshot.durable_save_complete

    sequence = (
        (SessionState.READY, AcquisitionPhase.READY),
        (SessionState.SCAN_STARTING, AcquisitionPhase.CALIBRATING),
        (SessionState.SCANNING, AcquisitionPhase.SCANNING),
        (SessionState.SCAN_STOPPING, AcquisitionPhase.STOPPING),
        (SessionState.SCAN_OVER, AcquisitionPhase.SCAN_OVER_UNVERIFIED),
    )
    for state, phase in sequence:
        snapshot = machine.observe(decode_device_status_report(_device_status(state)))
        assert snapshot.phase is phase
        assert not snapshot.durable_save_complete

    assert machine.snapshot.save_evidence is SaveEvidence.SCAN_OVER_OBSERVED
    ready = machine.observe(decode_device_status_report(_device_status(SessionState.READY)))
    assert ready.save_evidence is SaveEvidence.SCAN_OVER_THEN_READY_OBSERVED
    assert not ready.durable_save_complete

    next_start = machine.observe(
        decode_device_status_report(_device_status(SessionState.SCAN_STARTING))
    )
    assert next_start.save_evidence is SaveEvidence.NONE


def test_state_machine_surfaces_faults_without_claiming_save() -> None:
    machine = DeviceAcquisitionStateMachine()
    for state in (
        SessionState.DISK_ERROR,
        SessionState.SAVE_ERROR,
        SessionState.CAMERA_ERROR,
        SessionState.MEMORY_NOT_ENOUGH,
        SessionState.LIDAR_DATA_ERROR,
        SessionState.MAPPING_ERROR,
    ):
        snapshot = machine.observe(decode_device_status_report(_device_status(state)))
        assert snapshot.phase is AcquisitionPhase.FAULT
        assert snapshot.save_evidence is SaveEvidence.FAULT_BEFORE_DURABLE_CONFIRMATION
        assert not snapshot.durable_save_complete

    unknown = machine.observe(
        decode_device_status_report(_device_status(None, raw_code=MODELING_STATE_BASE + 999))
    )
    assert unknown.phase is AcquisitionPhase.UNKNOWN
    assert not unknown.durable_save_complete
