"""Conversion from Python IR dataclasses to lqp 0.4.0 protobuf messages.

Provides:
  - ir_to_proto(node)            → protobuf message for any IR node
  - construct_configure(cfg, meta) → Configure (Python IR)
"""
from __future__ import annotations

from typing import Any

from lqp.proto.v1 import logic_pb2, fragments_pb2, transactions_pb2

from relationalai.semantics.lqp.lqp_bundle.ir import (
    Algorithm,
    Assign,
    Atom,
    Abstraction,
    Attribute,
    BooleanValue,
    Break,
    Cast,
    Configure,
    Conjunction,
    DateTimeValue,
    DateValue,
    DecimalValue,
    Def,
    DebugInfo,
    Define,
    Disjunction,
    Epoch,
    Exists,
    Export,
    ExportCSVConfig,
    FFI,
    Fragment,
    FragmentId,
    FunctionalDependency,
    IVMConfig,
    Int128Value,
    Loop,
    MaintenanceLevel,
    MaxMonoid,
    MinMonoid,
    MonoidDef,
    MonusDef,
    Not,
    OrMonoid,
    Output,
    Pragma,
    Primitive,
    Read,
    Reduce,
    RelAtom,
    RelationId,
    Script,
    SpecializedValue,
    SumMonoid,
    Sync,
    Transaction,
    Type,
    TypeName,
    Upsert,
    UInt128Value,
    Value,
    Var,
    Write,
    Undefine,
    GNFColumn,
    CSVData,
)

_MASK64 = (1 << 64) - 1

# Mapping from IR MaintenanceLevel to proto MaintenanceLevel.
_MAINTENANCE_LEVEL_TO_PROTO = {
    MaintenanceLevel.UNSPECIFIED: transactions_pb2.MaintenanceLevel.MAINTENANCE_LEVEL_UNSPECIFIED,
    MaintenanceLevel.OFF: transactions_pb2.MaintenanceLevel.MAINTENANCE_LEVEL_OFF,
    MaintenanceLevel.AUTO: transactions_pb2.MaintenanceLevel.MAINTENANCE_LEVEL_AUTO,
    MaintenanceLevel.ALL: transactions_pb2.MaintenanceLevel.MAINTENANCE_LEVEL_ALL,
}


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def ir_to_proto(node: Any) -> Any:
    """Convert any IR node to its protobuf representation.

    Supports Epoch as well as individual nodes (Def, Var, Formula, etc.).
    """
    if isinstance(node, Epoch):
        return _epoch_to_proto(node)
    elif isinstance(node, (Def, FunctionalDependency, Algorithm, CSVData)):
        return _declaration_to_proto(node)
    elif isinstance(node, (Conjunction, Disjunction, Not, Exists, Reduce,
                            FFI, Atom, Pragma, Primitive, RelAtom, Cast)):
        return _formula_to_proto(node)
    elif isinstance(node, Var):
        return _var_to_proto(node)
    elif isinstance(node, Abstraction):
        return _abstraction_to_proto(node)
    elif isinstance(node, Fragment):
        return _fragment_to_proto(node)
    elif isinstance(node, Transaction):
        return _transaction_to_proto(node)
    else:
        raise ValueError(f"ir_to_proto: unsupported IR node type {type(node)!r}")


def construct_configure(config_dict: dict, meta: Any) -> Configure:
    """Build a Configure IR object from a dict of {key: Value} entries.

    Recognised keys:
      'semantics_version'       → Configure.semantics_version (int)
      'ivm.maintenance_level'   → Configure.ivm_config.level
    """
    semantics_version = 0
    # Default to "off" to match the old lqp.parser.construct_configure default behaviour.
    ivm_level = MaintenanceLevel.OFF

    _level_map = {
        "all": MaintenanceLevel.ALL,
        "off": MaintenanceLevel.OFF,
        "auto": MaintenanceLevel.AUTO,
    }

    for key, val in config_dict.items():
        raw = val.value if isinstance(val, Value) else val
        if key == "semantics_version":
            semantics_version = int(raw)
        elif key == "ivm.maintenance_level":
            mapped = _level_map.get(str(raw).lower())
            if mapped is not None:
                ivm_level = mapped

    ivm_config = IVMConfig(level=ivm_level)
    return Configure(
        semantics_version=semantics_version,
        ivm_config=ivm_config,
        meta=meta,
    )


# ---------------------------------------------------------------------------
# Primitive conversions
# ---------------------------------------------------------------------------

def _rid_to_proto(rid: RelationId) -> logic_pb2.RelationId:
    id_low = rid.id & _MASK64
    id_high = (rid.id >> 64) & _MASK64
    return logic_pb2.RelationId(id_low=id_low, id_high=id_high)


def _var_to_proto(var: Var) -> logic_pb2.Var:
    return logic_pb2.Var(name=var.name)


def _type_to_proto(typ: Type) -> logic_pb2.Type:
    tn = typ.type_name
    if tn == TypeName.INT:
        return logic_pb2.Type(int_type=logic_pb2.IntType())
    elif tn == TypeName.INT128:
        return logic_pb2.Type(int128_type=logic_pb2.Int128Type())
    elif tn == TypeName.UINT128:
        return logic_pb2.Type(uint128_type=logic_pb2.UInt128Type())
    elif tn == TypeName.FLOAT:
        return logic_pb2.Type(float_type=logic_pb2.FloatType())
    elif tn == TypeName.STRING:
        return logic_pb2.Type(string_type=logic_pb2.StringType())
    elif tn == TypeName.DATE:
        return logic_pb2.Type(date_type=logic_pb2.DateType())
    elif tn == TypeName.DATETIME:
        return logic_pb2.Type(datetime_type=logic_pb2.DateTimeType())
    elif tn == TypeName.BOOLEAN:
        return logic_pb2.Type(boolean_type=logic_pb2.BooleanType())
    elif tn == TypeName.MISSING:
        return logic_pb2.Type(missing_type=logic_pb2.MissingType())
    elif tn == TypeName.DECIMAL:
        if len(typ.parameters) != 2:
            raise ValueError(f"DECIMAL type requires exactly 2 parameters (precision, scale), got {len(typ.parameters)}")
        precision = int(typ.parameters[0].value)
        scale = int(typ.parameters[1].value)
        if precision < 0 or precision > 38:
            raise ValueError(f"DECIMAL precision must be in [0, 38], got {precision}")
        if scale < 0 or scale > precision:
            raise ValueError(f"DECIMAL scale must be in [0, precision={precision}], got {scale}")
        return logic_pb2.Type(decimal_type=logic_pb2.DecimalType(precision=precision, scale=scale))
    else:
        raise ValueError(f"Unknown TypeName: {tn}")


def _int128_to_proto(val: Int128Value) -> logic_pb2.Int128Value:
    v = val.value
    if v < 0:
        v += (1 << 128)  # two's complement
    return logic_pb2.Int128Value(low=v & _MASK64, high=(v >> 64) & _MASK64)


def _uint128_to_proto(val: UInt128Value) -> logic_pb2.UInt128Value:
    return logic_pb2.UInt128Value(low=val.value & _MASK64, high=(val.value >> 64) & _MASK64)


def _decimal_value_to_proto(val: DecimalValue) -> logic_pb2.DecimalValue:
    from decimal import Decimal as PyDecimal
    from functools import reduce as _reduce

    d = PyDecimal(val.value) if not isinstance(val.value, PyDecimal) else val.value

    # Guard: reject special values (NaN, Infinity, -Infinity)
    if d.is_nan() or d.is_infinite():
        raise ValueError(f"Cannot convert special decimal value {d!r} to DecimalValue proto")

    # Guard: validate precision and scale bounds
    if val.precision < 0 or val.precision > 38:
        raise ValueError(f"DECIMAL precision must be in [0, 38], got {val.precision}")
    if val.scale < 0 or val.scale > val.precision:
        raise ValueError(f"DECIMAL scale must be in [0, precision={val.precision}], got {val.scale}")

    # Decompose using as_tuple() to stay in the integer realm and avoid
    # floating-point rounding when multiplying by 10**scale.
    sign, digits, exponent = d.as_tuple()
    assert isinstance(exponent, int), f"Expected integer exponent, got {exponent!r}"

    # Reconstruct the integer from the digit tuple.
    int_val = _reduce(lambda acc, dig: acc * 10 + dig, digits, 0)

    # Adjust by the difference between the target scale and the exponent.
    # Python decimal: value = (-1)^sign * digits * 10^exponent
    # We need: physical_value = value * 10^scale  (as an integer)
    modifier = val.scale + exponent
    if modifier >= 0:
        int_val *= 10 ** modifier
    else:
        int_val //= 10 ** (-modifier)

    if sign == 1:
        int_val = -int_val

    # Two's complement for negative values in 128-bit representation
    if int_val < 0:
        int_val += (1 << 128)

    int128_proto = logic_pb2.Int128Value(
        low=int_val & _MASK64,
        high=(int_val >> 64) & _MASK64,
    )
    return logic_pb2.DecimalValue(
        precision=val.precision,
        scale=val.scale,
        value=int128_proto,
    )


def _date_value_to_proto(val: DateValue) -> logic_pb2.DateValue:
    d = val.value
    return logic_pb2.DateValue(year=d.year, month=d.month, day=d.day)


def _datetime_value_to_proto(val: DateTimeValue) -> logic_pb2.DateTimeValue:
    dt = val.value
    return logic_pb2.DateTimeValue(
        year=dt.year,
        month=dt.month,
        day=dt.day,
        hour=dt.hour,
        minute=dt.minute,
        second=dt.second,
        microsecond=dt.microsecond,
    )


def _value_to_proto(val: Value) -> logic_pb2.Value:
    """Convert an IR Value to a proto Value."""
    v = val.value
    if isinstance(v, bool):
        return logic_pb2.Value(boolean_value=v)
    elif isinstance(v, int):
        return logic_pb2.Value(int_value=v)
    elif isinstance(v, float):
        return logic_pb2.Value(float_value=v)
    elif isinstance(v, str):
        return logic_pb2.Value(string_value=v)
    elif isinstance(v, Int128Value):
        return logic_pb2.Value(int128_value=_int128_to_proto(v))
    elif isinstance(v, UInt128Value):
        return logic_pb2.Value(uint128_value=_uint128_to_proto(v))
    elif isinstance(v, DecimalValue):
        return logic_pb2.Value(decimal_value=_decimal_value_to_proto(v))
    elif isinstance(v, DateValue):
        return logic_pb2.Value(date_value=_date_value_to_proto(v))
    elif isinstance(v, DateTimeValue):
        return logic_pb2.Value(datetime_value=_datetime_value_to_proto(v))
    elif isinstance(v, BooleanValue):
        return logic_pb2.Value(boolean_value=v.value)
    else:
        raise ValueError(f"Cannot convert Value with inner type {type(v)!r}: {v!r}")


def _term_to_proto(term: Any) -> logic_pb2.Term:
    """Convert an IR Term (Var or Value) to a proto Term."""
    if isinstance(term, Var):
        return logic_pb2.Term(var=_var_to_proto(term))
    elif isinstance(term, Value):
        return logic_pb2.Term(constant=_value_to_proto(term))
    else:
        raise ValueError(f"Unknown Term type: {type(term)!r}")


def _relterm_to_proto(relterm: Any) -> logic_pb2.RelTerm:
    """Convert an IR RelTerm (Var, Value, or SpecializedValue) to a proto RelTerm."""
    if isinstance(relterm, SpecializedValue):
        return logic_pb2.RelTerm(specialized_value=_value_to_proto(relterm.value))
    elif isinstance(relterm, Var):
        return logic_pb2.RelTerm(term=logic_pb2.Term(var=_var_to_proto(relterm)))
    elif isinstance(relterm, Value):
        return logic_pb2.RelTerm(term=logic_pb2.Term(constant=_value_to_proto(relterm)))
    else:
        raise ValueError(f"Unknown RelTerm type: {type(relterm)!r}")


def _attribute_to_proto(attr: Attribute) -> logic_pb2.Attribute:
    return logic_pb2.Attribute(
        name=attr.name,
        args=[_value_to_proto(a) for a in attr.args],
    )


def _abstraction_to_proto(abstr: Abstraction) -> logic_pb2.Abstraction:
    bindings = [
        logic_pb2.Binding(var=_var_to_proto(var), type=_type_to_proto(typ))
        for (var, typ) in abstr.vars
    ]
    return logic_pb2.Abstraction(
        vars=bindings,
        value=_formula_to_proto(abstr.value),
    )


def _formula_to_proto(formula: Any) -> logic_pb2.Formula:
    if isinstance(formula, Conjunction):
        return logic_pb2.Formula(conjunction=logic_pb2.Conjunction(
            args=[_formula_to_proto(a) for a in formula.args],
        ))
    elif isinstance(formula, Disjunction):
        return logic_pb2.Formula(disjunction=logic_pb2.Disjunction(
            args=[_formula_to_proto(a) for a in formula.args],
        ))
    elif isinstance(formula, Not):
        return logic_pb2.Formula(**{"not": logic_pb2.Not(arg=_formula_to_proto(formula.arg))})  # type: ignore[call-arg]
    elif isinstance(formula, Exists):
        return logic_pb2.Formula(exists=logic_pb2.Exists(
            body=_abstraction_to_proto(formula.body),
        ))
    elif isinstance(formula, Reduce):
        return logic_pb2.Formula(reduce=logic_pb2.Reduce(
            op=_abstraction_to_proto(formula.op),
            body=_abstraction_to_proto(formula.body),
            terms=[_term_to_proto(t) for t in formula.terms],
        ))
    elif isinstance(formula, FFI):
        return logic_pb2.Formula(ffi=logic_pb2.FFI(
            name=formula.name,
            args=[_abstraction_to_proto(a) for a in formula.args],
            terms=[_term_to_proto(t) for t in formula.terms],
        ))
    elif isinstance(formula, Atom):
        return logic_pb2.Formula(atom=logic_pb2.Atom(
            name=_rid_to_proto(formula.name),
            terms=[_term_to_proto(t) for t in formula.terms],
        ))
    elif isinstance(formula, Pragma):
        return logic_pb2.Formula(pragma=logic_pb2.Pragma(
            name=formula.name,
            terms=[_term_to_proto(t) for t in formula.terms],
        ))
    elif isinstance(formula, Primitive):
        return logic_pb2.Formula(primitive=logic_pb2.Primitive(
            name=formula.name,
            terms=[_relterm_to_proto(t) for t in formula.terms],
        ))
    elif isinstance(formula, RelAtom):
        return logic_pb2.Formula(rel_atom=logic_pb2.RelAtom(
            name=formula.name,
            terms=[_relterm_to_proto(t) for t in formula.terms],
        ))
    elif isinstance(formula, Cast):
        return logic_pb2.Formula(cast=logic_pb2.Cast(
            input=_term_to_proto(formula.input),
            result=_term_to_proto(formula.result),
        ))
    else:
        raise ValueError(f"Unknown Formula type: {type(formula)!r}")


# ---------------------------------------------------------------------------
# Declaration / Algorithm conversions
# ---------------------------------------------------------------------------

def _monoid_to_proto(monoid: Any) -> logic_pb2.Monoid:
    if isinstance(monoid, OrMonoid):
        return logic_pb2.Monoid(or_monoid=logic_pb2.OrMonoid())
    elif isinstance(monoid, SumMonoid):
        kwargs = {}
        if monoid.type is not None:
            kwargs["type"] = _type_to_proto(monoid.type)
        return logic_pb2.Monoid(sum_monoid=logic_pb2.SumMonoid(**kwargs))
    elif isinstance(monoid, MinMonoid):
        kwargs = {}
        if monoid.type is not None:
            kwargs["type"] = _type_to_proto(monoid.type)
        return logic_pb2.Monoid(min_monoid=logic_pb2.MinMonoid(**kwargs))
    elif isinstance(monoid, MaxMonoid):
        kwargs = {}
        if monoid.type is not None:
            kwargs["type"] = _type_to_proto(monoid.type)
        return logic_pb2.Monoid(max_monoid=logic_pb2.MaxMonoid(**kwargs))
    else:
        raise ValueError(f"Unknown Monoid type: {type(monoid)!r}")


def _instruction_to_proto(instr: Any) -> logic_pb2.Construct:
    """Convert an Instruction to a Construct wrapping an Instruction proto."""
    if isinstance(instr, Assign):
        proto_instr = logic_pb2.Instruction(assign=logic_pb2.Assign(
            name=_rid_to_proto(instr.name),
            body=_abstraction_to_proto(instr.body),
            attrs=[_attribute_to_proto(a) for a in instr.attrs],
        ))
    elif isinstance(instr, Upsert):
        proto_instr = logic_pb2.Instruction(upsert=logic_pb2.Upsert(
            name=_rid_to_proto(instr.name),
            body=_abstraction_to_proto(instr.body),
            attrs=[_attribute_to_proto(a) for a in instr.attrs],
            value_arity=instr.value_arity,
        ))
    elif isinstance(instr, MonoidDef):
        proto_instr = logic_pb2.Instruction(monoid_def=logic_pb2.MonoidDef(
            monoid=_monoid_to_proto(instr.monoid),
            name=_rid_to_proto(instr.name),
            body=_abstraction_to_proto(instr.body),
            attrs=[_attribute_to_proto(a) for a in instr.attrs],
            value_arity=instr.value_arity,
        ))
    elif isinstance(instr, MonusDef):
        proto_instr = logic_pb2.Instruction(monus_def=logic_pb2.MonusDef(
            monoid=_monoid_to_proto(instr.monoid),
            name=_rid_to_proto(instr.name),
            body=_abstraction_to_proto(instr.body),
            attrs=[_attribute_to_proto(a) for a in instr.attrs],
            value_arity=instr.value_arity,
        ))
    elif isinstance(instr, Break):
        proto_instr = logic_pb2.Instruction(**{"break": logic_pb2.Break(  # type: ignore[call-arg]
            name=_rid_to_proto(instr.name),
            body=_abstraction_to_proto(instr.body),
            attrs=[_attribute_to_proto(a) for a in instr.attrs],
        )})
    else:
        raise ValueError(f"Unknown Instruction type: {type(instr)!r}")
    return logic_pb2.Construct(instruction=proto_instr)


def _loop_to_proto(loop: Loop) -> logic_pb2.Construct:
    init_instrs = []
    for instr in loop.init:
        c = _instruction_to_proto(instr)
        assert c.HasField("instruction")
        init_instrs.append(c.instruction)
    return logic_pb2.Construct(loop=logic_pb2.Loop(
        init=init_instrs,
        body=_script_to_proto(loop.body),
    ))


def _construct_to_proto(construct: Any) -> logic_pb2.Construct:
    if isinstance(construct, Loop):
        return _loop_to_proto(construct)
    elif isinstance(construct, (Break, Assign, Upsert, MonoidDef, MonusDef)):
        return _instruction_to_proto(construct)
    else:
        raise ValueError(f"Unknown Construct type: {type(construct)!r}")


def _script_to_proto(script: Script) -> logic_pb2.Script:
    return logic_pb2.Script(
        constructs=[_construct_to_proto(c) for c in script.constructs],
    )


def _def_to_proto(decl: Def) -> logic_pb2.Declaration:
    return logic_pb2.Declaration(**{"def": logic_pb2.Def(  # type: ignore[call-arg]
        name=_rid_to_proto(decl.name),
        body=_abstraction_to_proto(decl.body),
        attrs=[_attribute_to_proto(a) for a in decl.attrs],
    )})


def _fd_to_proto(decl: FunctionalDependency) -> logic_pb2.Declaration:
    return logic_pb2.Declaration(constraint=logic_pb2.Constraint(
        name=_rid_to_proto(decl.name),
        functional_dependency=logic_pb2.FunctionalDependency(
            guard=_abstraction_to_proto(decl.guard),
            keys=[_var_to_proto(k) for k in decl.keys],
            values=[_var_to_proto(v) for v in decl.values],
        ),
    ))


def _alg_to_proto(decl: Algorithm) -> logic_pb2.Declaration:
    return logic_pb2.Declaration(algorithm=logic_pb2.Algorithm(**{
        "global": [_rid_to_proto(r) for r in decl.global_],
        "body": _script_to_proto(decl.body),
    }))


def _gnf_column_to_proto(col: GNFColumn) -> logic_pb2.GNFColumn:
    return logic_pb2.GNFColumn(
        column_path=[col.column_name],
        target_id=_rid_to_proto(col.target_id),
        types=[_type_to_proto(t) for t in col.types],
    )


def _csv_data_to_proto(data: CSVData) -> logic_pb2.Declaration:
    locator = logic_pb2.CSVLocator(
        paths=data.locator.paths,
        inline_data=data.locator.inline_data,
    )
    config = logic_pb2.CSVConfig(
        header_row=data.config.header_row,
        skip=data.config.skip,
        new_line=data.config.new_line,
        delimiter=data.config.delimiter,
        quotechar=data.config.quotechar,
        escapechar=data.config.escapechar,
        comment=data.config.comment,
        missing_strings=data.config.missing_strings,
        decimal_separator=data.config.decimal_separator,
        encoding=data.config.encoding,
        compression=data.config.compression,
    )
    csv_data = logic_pb2.CSVData(
        locator=locator,
        config=config,
        columns=[_gnf_column_to_proto(c) for c in data.columns],
        asof=data.asof,
    )
    return logic_pb2.Declaration(data=logic_pb2.Data(csv_data=csv_data))


def _declaration_to_proto(decl: Any) -> logic_pb2.Declaration:
    if isinstance(decl, Def):
        return _def_to_proto(decl)
    elif isinstance(decl, FunctionalDependency):
        return _fd_to_proto(decl)
    elif isinstance(decl, Algorithm):
        return _alg_to_proto(decl)
    elif isinstance(decl, CSVData):
        return _csv_data_to_proto(decl)
    else:
        raise ValueError(f"Unknown Declaration type: {type(decl)!r}")


# ---------------------------------------------------------------------------
# Fragment / DebugInfo conversions
# ---------------------------------------------------------------------------

def _fragment_id_to_proto(fid: FragmentId) -> fragments_pb2.FragmentId:
    return fragments_pb2.FragmentId(id=fid.id)


def _debug_info_to_proto(di: DebugInfo) -> fragments_pb2.DebugInfo:
    ids = []
    orig_names = []
    for rid, name in di.id_to_orig_name.items():
        ids.append(_rid_to_proto(rid))
        orig_names.append(name)
    return fragments_pb2.DebugInfo(ids=ids, orig_names=orig_names)


def _fragment_to_proto(fragment: Fragment) -> fragments_pb2.Fragment:
    return fragments_pb2.Fragment(
        id=_fragment_id_to_proto(fragment.id),
        declarations=[_declaration_to_proto(d) for d in fragment.declarations],
        debug_info=_debug_info_to_proto(fragment.debug_info),
    )


# ---------------------------------------------------------------------------
# Transaction conversions
# ---------------------------------------------------------------------------

def _write_to_proto(write: Write) -> transactions_pb2.Write:
    wt = write.write_type
    if isinstance(wt, Define):
        return transactions_pb2.Write(define=transactions_pb2.Define(
            fragment=_fragment_to_proto(wt.fragment),
        ))
    elif isinstance(wt, Undefine):
        return transactions_pb2.Write(undefine=transactions_pb2.Undefine(
            fragment_id=_fragment_id_to_proto(wt.fragment_id),
        ))
    else:
        raise ValueError(f"Unknown write_type: {type(wt)!r}")


def _export_csv_config_to_proto(cfg: ExportCSVConfig) -> transactions_pb2.ExportCSVConfig:
    cols = [
        transactions_pb2.ExportCSVColumn(
            column_name=col.column_name,
            column_data=_rid_to_proto(col.column_data),
        )
        for col in cfg.data_columns
    ]
    kwargs: dict[str, Any] = {
        "path": cfg.path,
        "data_columns": cols,
    }
    if cfg.compression:
        kwargs["compression"] = cfg.compression
    if cfg.partition_size:
        kwargs["partition_size"] = cfg.partition_size
    if cfg.syntax_header_row:
        kwargs["syntax_header_row"] = cfg.syntax_header_row
    if cfg.syntax_missing_string:
        kwargs["syntax_missing_string"] = cfg.syntax_missing_string
    if cfg.syntax_delim:
        kwargs["syntax_delim"] = cfg.syntax_delim
    if cfg.syntax_quotechar:
        kwargs["syntax_quotechar"] = cfg.syntax_quotechar
    if cfg.syntax_escapechar:
        kwargs["syntax_escapechar"] = cfg.syntax_escapechar
    return transactions_pb2.ExportCSVConfig(**kwargs)


def _read_to_proto(read: Read) -> transactions_pb2.Read:
    rt = read.read_type
    if isinstance(rt, Output):
        return transactions_pb2.Read(output=transactions_pb2.Output(
            name=rt.name,
            relation_id=_rid_to_proto(rt.relation_id),
        ))
    elif isinstance(rt, Export):
        return transactions_pb2.Read(export=transactions_pb2.Export(
            csv_config=_export_csv_config_to_proto(rt.config),
        ))
    else:
        raise ValueError(f"Unknown read_type: {type(rt)!r}")


def _epoch_to_proto(epoch: Epoch) -> transactions_pb2.Epoch:
    return transactions_pb2.Epoch(
        writes=[_write_to_proto(w) for w in epoch.writes],
        reads=[_read_to_proto(r) for r in epoch.reads],
    )


def _sync_to_proto(sync: Sync) -> transactions_pb2.Sync:
    return transactions_pb2.Sync(
        fragments=[_fragment_id_to_proto(f) for f in sync.fragments],
    )


def _configure_to_proto(cfg: Configure) -> transactions_pb2.Configure:
    kwargs: dict[str, Any] = {}
    if cfg.semantics_version:
        kwargs["semantics_version"] = cfg.semantics_version
    if cfg.ivm_config is not None:
        ivm = cfg.ivm_config
        if ivm.level is not None:
            proto_level = _MAINTENANCE_LEVEL_TO_PROTO[ivm.level]
            kwargs["ivm_config"] = transactions_pb2.IVMConfig(level=proto_level)
    return transactions_pb2.Configure(**kwargs)


def _transaction_to_proto(txn: Transaction) -> transactions_pb2.Transaction:
    """Convert an IR Transaction to a serialisable protobuf Transaction."""
    epochs = [_epoch_to_proto(e) for e in txn.epochs]
    kwargs: dict[str, Any] = {"epochs": epochs}
    if txn.configure is not None:
        kwargs["configure"] = _configure_to_proto(txn.configure)
    if txn.sync is not None:
        kwargs["sync"] = _sync_to_proto(txn.sync)
    return transactions_pb2.Transaction(**kwargs)
