// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

use paimon::spec::{DataField, DataType, Datum, DecimalType, Predicate, PredicateBuilder};
use pyo3::exceptions::{PyNotImplementedError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyTzInfoAccess;
use pyo3::types::{PyBool, PyDateTime, PyDict, PyInt, PyList, PyString, PyTime, PyTzInfo};

/// Convert a single Python literal into a typed [`Datum`] driven by the target
/// [`DataType`].
///
/// Conversion is strictly DataType-driven (never inferred from the Python type):
/// the field's declared type decides how the literal is interpreted and validated.
///
/// Rules:
/// - `Boolean` accepts only a Python `bool`.
/// - Integer types (`TinyInt`/`SmallInt`/`Int`/`BigInt`) reject Python `bool`
///   (which is an `int` subclass) and enforce the target range.
/// - `Float`/`Double` accept Python `int` or `float` but reject `bool`.
/// - `Char`/`VarChar` accept only a Python `str` (no implicit stringification).
/// - `Date` accepts only a `datetime.date` that is not a `datetime.datetime`
///   (accepting the subclass would silently drop the time-of-day part).
/// - `Time` accepts only a naive `datetime.time` with whole-millisecond
///   microseconds (`Datum::Time` carries millis-of-day; sub-millisecond values
///   would be silently truncated).
/// - `Timestamp` accepts only a naive `datetime.datetime` (the type is
///   timezone-less); `LocalZonedTimestamp` accepts only a timezone-aware one
///   (converted to UTC). This mirrors the DataFusion pushdown path, which
///   refuses zoned literals for TIMESTAMP and naive ones for TIMESTAMP_LTZ.
/// - `Decimal` accepts a `decimal.Decimal` or an `int`, rescaled losslessly to
///   the column's scale; anything needing rounding, exceeding the column's
///   precision, non-finite, or a binary `float` is rejected.
/// - All other types (Bytes/complex) are not supported yet and raise
///   `NotImplementedError`.
///
/// Errors:
/// - `ValueError` for type mismatches, out-of-range integers, zoned/naive
///   timestamp mismatches, and lossy decimal/time conversions.
/// - `NotImplementedError` for unsupported field types (message names the type).
pub(crate) fn py_to_datum(value: &Bound<'_, PyAny>, data_type: &DataType) -> PyResult<Datum> {
    match data_type {
        DataType::Boolean(_) => {
            let b = value
                .cast::<PyBool>()
                .map_err(|_| PyValueError::new_err("expected a bool literal for Boolean field"))?;
            Ok(Datum::Bool(b.is_true()))
        }
        DataType::TinyInt(_) => int_datum(value, i8::MIN as i64, i8::MAX as i64, |v| {
            Datum::TinyInt(v as i8)
        }),
        DataType::SmallInt(_) => int_datum(value, i16::MIN as i64, i16::MAX as i64, |v| {
            Datum::SmallInt(v as i16)
        }),
        DataType::Int(_) => int_datum(value, i32::MIN as i64, i32::MAX as i64, |v| {
            Datum::Int(v as i32)
        }),
        DataType::BigInt(_) => int_datum(value, i64::MIN, i64::MAX, Datum::Long),
        DataType::Float(_) => Ok(Datum::Float(float_val(value)? as f32)),
        DataType::Double(_) => Ok(Datum::Double(float_val(value)?)),
        DataType::Char(_) | DataType::VarChar(_) => {
            let s = value
                .cast::<PyString>()
                .map_err(|_| PyValueError::new_err("expected a str literal for String field"))?;
            Ok(Datum::String(s.to_str()?.to_string()))
        }
        DataType::Date(_) => date_datum(value),
        DataType::Time(_) => time_datum(value),
        DataType::Timestamp(_) => timestamp_datum(value),
        DataType::LocalZonedTimestamp(_) => local_zoned_timestamp_datum(value),
        DataType::Decimal(dec) => decimal_datum(value, dec),
        other => Err(PyNotImplementedError::new_err(format!(
            "literal conversion for type {other:?} is not supported yet"
        ))),
    }
}

/// Extract an integer literal, rejecting Python `bool` (an `int` subclass) and
/// enforcing the inclusive `[lo, hi]` range before building the `Datum`.
fn int_datum(
    value: &Bound<'_, PyAny>,
    lo: i64,
    hi: i64,
    make: impl Fn(i64) -> Datum,
) -> PyResult<Datum> {
    if value.is_instance_of::<PyBool>() {
        return Err(PyValueError::new_err("bool is not a valid integer literal"));
    }
    let v: i64 = value
        .extract()
        .map_err(|_| PyValueError::new_err("expected an int literal"))?;
    if v < lo || v > hi {
        return Err(PyValueError::new_err(format!(
            "integer literal {v} out of range [{lo}, {hi}]"
        )));
    }
    Ok(make(v))
}

/// Extract a floating-point literal from a Python `int` or `float`, rejecting
/// `bool`.
fn float_val(value: &Bound<'_, PyAny>) -> PyResult<f64> {
    if value.is_instance_of::<PyBool>() {
        return Err(PyValueError::new_err("bool is not a valid float literal"));
    }
    value
        .extract::<f64>()
        .map_err(|_| PyValueError::new_err("expected a numeric literal"))
}

/// Convert a `datetime.date` into `Datum::Date` (epoch days).
///
/// `datetime.datetime` is a `date` subclass but is rejected: accepting it would
/// silently drop the time-of-day part.
fn date_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
    if value.is_instance_of::<PyDateTime>() {
        return Err(PyValueError::new_err(
            "expected a datetime.date literal for Date field, got datetime.datetime \
             (the time-of-day part would be dropped)",
        ));
    }
    let date: chrono::NaiveDate = value
        .extract()
        .map_err(|_| PyValueError::new_err("expected a datetime.date literal for Date field"))?;
    let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
    let days = date.signed_duration_since(epoch).num_days();
    let days = i32::try_from(days)
        .map_err(|_| PyValueError::new_err(format!("date literal {date} out of range")))?;
    Ok(Datum::Date(days))
}

/// Convert a naive `datetime.time` into `Datum::Time` (millis of day).
///
/// Timezone-aware times are rejected (TIME has no timezone), as are
/// sub-millisecond microseconds (`Datum::Time` carries millis; truncating
/// would silently change comparison semantics).
fn time_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
    let time = value
        .cast::<PyTime>()
        .map_err(|_| PyValueError::new_err("expected a datetime.time literal for Time field"))?;
    if time.get_tzinfo().is_some() {
        return Err(PyValueError::new_err(
            "expected a naive datetime.time literal for Time field (tzinfo must be None)",
        ));
    }
    let time: chrono::NaiveTime = value
        .extract()
        .map_err(|_| PyValueError::new_err("expected a datetime.time literal for Time field"))?;
    use chrono::Timelike;
    if !time.nanosecond().is_multiple_of(1_000_000) {
        return Err(PyValueError::new_err(
            "time literal has sub-millisecond microseconds, which TIME comparisons \
             cannot represent without truncation",
        ));
    }
    let millis = time.num_seconds_from_midnight() * 1_000 + time.nanosecond() / 1_000_000;
    Ok(Datum::Time(millis as i32))
}

/// Split epoch microseconds into `(millis, nanos-of-milli)` with a Euclidean
/// split so pre-epoch instants keep a non-negative nano remainder, matching
/// `BinaryRow::get_timestamp_raw` and the DataFusion pushdown conversion.
fn micros_to_millis_nanos(micros: i64) -> (i64, i32) {
    (
        micros.div_euclid(1_000),
        (micros.rem_euclid(1_000) * 1_000) as i32,
    )
}

/// Convert a naive `datetime.datetime` into `Datum::Timestamp`.
///
/// TIMESTAMP (without time zone) is timezone-less, so timezone-aware datetimes
/// are rejected rather than silently reinterpreted — parity with the DataFusion
/// pushdown path, which refuses zoned literals for this type.
fn timestamp_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
    let dt = value.cast::<PyDateTime>().map_err(|_| {
        PyValueError::new_err("expected a datetime.datetime literal for Timestamp field")
    })?;
    if dt.get_tzinfo().is_some() {
        return Err(PyValueError::new_err(
            "expected a naive datetime.datetime literal for Timestamp field (tzinfo must \
             be None); TIMESTAMP has no timezone — use a TIMESTAMP WITH LOCAL TIME ZONE \
             column for zoned instants",
        ));
    }
    let naive: chrono::NaiveDateTime = value.extract().map_err(|_| {
        PyValueError::new_err("expected a datetime.datetime literal for Timestamp field")
    })?;
    let (millis, nanos) = micros_to_millis_nanos(naive.and_utc().timestamp_micros());
    Ok(Datum::Timestamp { millis, nanos })
}

/// Convert a timezone-aware `datetime.datetime` into `Datum::LocalZonedTimestamp`
/// (the UTC instant).
///
/// Aware follows Python's definition: `tzinfo` is set AND `utcoffset()` is not
/// `None`. A tzinfo whose `utcoffset()` returns `None` is naive — passing it to
/// `astimezone` would interpret the value as process-local time, normalizing
/// the same literal differently per machine. Naive datetimes are rejected
/// rather than assumed to be in any timezone — parity with the DataFusion
/// pushdown path, which refuses naive literals for TIMESTAMP WITH LOCAL TIME
/// ZONE.
fn local_zoned_timestamp_datum(value: &Bound<'_, PyAny>) -> PyResult<Datum> {
    let py = value.py();
    let dt = value.cast::<PyDateTime>().map_err(|_| {
        PyValueError::new_err("expected a datetime.datetime literal for LocalZonedTimestamp field")
    })?;
    // Python's aware/naive rule (datetime docs): aware iff tzinfo is not None
    // and utcoffset() does not return None.
    let aware =
        dt.get_tzinfo().is_some() && !dt.call_method0(pyo3::intern!(py, "utcoffset"))?.is_none();
    if !aware {
        return Err(PyValueError::new_err(
            "expected a timezone-aware datetime.datetime literal for LocalZonedTimestamp \
             field (naive datetimes, including tzinfo with utcoffset() = None, are \
             ambiguous)",
        ));
    }
    // Normalize through `astimezone(utc)` so any tzinfo implementation
    // (zoneinfo, pytz, fixed offsets) is handled by Python itself.
    let utc = PyTzInfo::utc(py)?;
    let as_utc = dt.call_method1(pyo3::intern!(py, "astimezone"), (utc,))?;
    let instant: chrono::DateTime<chrono::Utc> = as_utc.extract()?;
    let (millis, nanos) = micros_to_millis_nanos(instant.timestamp_micros());
    Ok(Datum::LocalZonedTimestamp { millis, nanos })
}

/// Convert a `decimal.Decimal` or `int` literal into `Datum::Decimal` at the
/// column's precision and scale.
///
/// The conversion is exact: values that would need rounding at the column's
/// scale, or whose unscaled magnitude exceeds the column's precision, are
/// rejected. Binary `float`s are rejected outright (they are not exact decimal
/// values), as are non-finite decimals (NaN/Infinity).
fn decimal_datum(value: &Bound<'_, PyAny>, dec: &DecimalType) -> PyResult<Datum> {
    let precision = dec.precision();
    let scale = dec.scale();
    if value.is_instance_of::<PyBool>() {
        return Err(PyValueError::new_err("bool is not a valid decimal literal"));
    }

    let unscaled = if value.is_instance_of::<PyInt>() {
        let v: i128 = value.extract().map_err(|_| {
            PyValueError::new_err("int literal out of supported range for Decimal field")
        })?;
        v.checked_mul(10i128.pow(scale)).ok_or_else(|| {
            PyValueError::new_err("int literal out of supported range for Decimal field")
        })?
    } else {
        let decimal_cls = value.py().import("decimal")?.getattr("Decimal")?;
        if !value.is_instance(&decimal_cls)? {
            return Err(PyValueError::new_err(
                "expected a decimal.Decimal or int literal for Decimal field (float is \
                 not an exact decimal value)",
            ));
        }
        decimal_to_unscaled(value, scale)?
    };

    // 10^precision fits i128 for the supported precision range (<= 38).
    if unscaled.unsigned_abs() >= 10u128.pow(precision) {
        return Err(PyValueError::new_err(format!(
            "decimal literal does not fit DECIMAL({precision}, {scale})"
        )));
    }
    Ok(Datum::Decimal {
        unscaled,
        precision,
        scale,
    })
}

/// Rescale a finite `decimal.Decimal` to `scale` exactly via `as_tuple()`
/// (sign, digits, exponent), avoiding Decimal arithmetic whose context
/// precision could silently round.
fn decimal_to_unscaled(value: &Bound<'_, PyAny>, scale: u32) -> PyResult<i128> {
    let tuple = value.call_method0("as_tuple")?;
    let sign: u8 = tuple.getattr("sign")?.extract()?;
    // For NaN/Infinity the exponent is a string ('n'/'N'/'F') and extraction fails.
    let exponent: i64 = tuple.getattr("exponent")?.extract().map_err(|_| {
        PyValueError::new_err("non-finite Decimal (NaN/Infinity) is not a valid literal")
    })?;
    let digits: Vec<u32> = tuple.getattr("digits")?.extract()?;

    let overflow =
        || PyValueError::new_err("decimal literal out of supported range for Decimal field");
    let mut magnitude: i128 = 0;
    for d in digits {
        magnitude = magnitude
            .checked_mul(10)
            .and_then(|m| m.checked_add(d as i128))
            .ok_or_else(overflow)?;
    }

    // value = ±magnitude * 10^exponent; unscaled = ±magnitude * 10^(exponent + scale).
    let shift = exponent + scale as i64;
    let magnitude = if shift >= 0 {
        u32::try_from(shift)
            .ok()
            .filter(|s| *s <= 38)
            .and_then(|s| magnitude.checked_mul(10i128.pow(s)))
            .ok_or_else(overflow)?
    } else if magnitude == 0 {
        0
    } else {
        let down = u32::try_from(-shift).ok().filter(|s| *s <= 38).ok_or_else(
            // More than 38 digits would have to be truncated; magnitude != 0
            // means that is always lossy.
            || rounding_error(scale),
        )?;
        let divisor = 10i128.pow(down);
        if magnitude % divisor != 0 {
            return Err(rounding_error(scale));
        }
        magnitude / divisor
    };
    Ok(if sign == 1 { -magnitude } else { magnitude })
}

fn rounding_error(scale: u32) -> PyErr {
    PyValueError::new_err(format!(
        "decimal literal cannot be represented at scale {scale} without rounding"
    ))
}

/// Operators recognized by the lightweight dict format but not translatable to a
/// Rust [`Predicate`] for pushdown.
const METHOD_NOT_SUPPORTED: &[&str] = &["not"];

/// Recursively convert a lightweight dict predicate into a Rust [`Predicate`].
///
/// The dict shape mirrors the Python predicate tree:
/// - Leaf: `{"method": <op>, "field": <name>, "literals": [..]}`
/// - Compound: `{"method": "and"|"or", "children": [<dict>, ..]}`
///
/// Field types are resolved authoritatively from `fields` (the table schema); any
/// `index`/`data_type` present in the dict is ignored. Literal conversion is
/// delegated to [`py_to_datum`], driven by the resolved [`DataType`].
///
/// There is no partial pushdown: in `and`/`or`, every child is converted and any
/// failure propagates, failing the whole predicate.
///
/// Errors:
/// - `ValueError` for unknown fields, missing keys, wrong literal counts, `None`
///   literals, empty/missing `children`, non-dict children, or non-list
///   `literals`/`children`.
/// - `NotImplementedError` for unsupported operators or unsupported literal types.
pub(crate) fn dict_to_predicate(
    node: &Bound<'_, PyDict>,
    fields: &[DataField],
    case_sensitive: bool,
) -> PyResult<Predicate> {
    let method: String = node
        .get_item("method")?
        .ok_or_else(|| PyValueError::new_err("predicate dict missing 'method'"))?
        .extract()?;

    match method.as_str() {
        "and" | "or" => {
            let children = node
                .get_item("children")?
                .ok_or_else(|| PyValueError::new_err(format!("'{method}' requires 'children'")))?;
            let list = children
                .cast::<PyList>()
                .map_err(|_| PyValueError::new_err("'children' must be a list"))?;
            if list.is_empty() {
                return Err(PyValueError::new_err(format!(
                    "'{method}' requires non-empty 'children'"
                )));
            }
            let mut preds = Vec::with_capacity(list.len());
            for child in list.iter() {
                let child_dict = child
                    .cast::<PyDict>()
                    .map_err(|_| PyValueError::new_err("each child must be a dict"))?;
                // Unsupported child propagates → no partial pushdown.
                preds.push(dict_to_predicate(child_dict, fields, case_sensitive)?);
            }
            Ok(if method == "and" {
                Predicate::and(preds)
            } else {
                Predicate::or(preds)
            })
        }
        m if METHOD_NOT_SUPPORTED.contains(&m) => Err(PyNotImplementedError::new_err(format!(
            "predicate operator '{m}' is not supported for Rust pushdown"
        ))),
        _ => leaf_to_predicate(&method, node, fields, case_sensitive),
    }
}

/// Resolve a leaf's field name to its schema [`DataType`] under the given case
/// sensitivity.
///
/// Case-sensitive: exact match. Case-insensitive: ASCII-fold and require a
/// unique match. Absent → `ValueError("Column '{field}' not found in schema")`;
/// ambiguous (2+ fields collide under folding) → `ValueError` naming the clash.
fn resolve_leaf_field_type(
    fields: &[DataField],
    field: &str,
    case_sensitive: bool,
) -> PyResult<DataType> {
    let not_found = || PyValueError::new_err(format!("Column '{field}' not found in schema"));
    if case_sensitive {
        return fields
            .iter()
            .find(|f| f.name() == field)
            .map(|f| f.data_type().clone())
            .ok_or_else(not_found);
    }
    let mut matches = fields
        .iter()
        .filter(|f| f.name().eq_ignore_ascii_case(field));
    let first = matches.next().ok_or_else(not_found)?;
    if matches.next().is_some() {
        return Err(PyValueError::new_err(format!(
            "Ambiguous column '{field}': multiple schema fields match case-insensitively"
        )));
    }
    Ok(first.data_type().clone())
}

/// Convert a single leaf dict (already known not to be `and`/`or`) into a
/// [`Predicate`], resolving the field type from the schema.
fn leaf_to_predicate(
    method: &str,
    node: &Bound<'_, PyDict>,
    fields: &[DataField],
    case_sensitive: bool,
) -> PyResult<Predicate> {
    let field: String = node
        .get_item("field")?
        .ok_or_else(|| PyValueError::new_err(format!("'{method}' leaf requires 'field'")))?
        .extract()?;

    // Resolve field DataType from schema (authoritative) for literal conversion.
    let data_type = resolve_leaf_field_type(fields, &field, case_sensitive)?;

    let literals_obj = node.get_item("literals")?;
    let pb = PredicateBuilder::new_with_case_sensitive(fields, case_sensitive);

    // Convert literals (DataType-driven), wrapping NotImplemented type messages
    // with field context.
    let to_datums = |obj: Option<Bound<'_, PyAny>>| -> PyResult<Vec<Datum>> {
        let mut out = Vec::new();
        if let Some(obj) = obj {
            let list = obj
                .cast::<PyList>()
                .map_err(|_| PyValueError::new_err("'literals' must be a list"))?;
            for item in list.iter() {
                if item.is_none() {
                    return Err(PyValueError::new_err(
                        "None is not a valid comparison literal; use isNull/isNotNull",
                    ));
                }
                out.push(
                    py_to_datum(&item, &data_type)
                        .map_err(|e| with_field_context(e, &field, &data_type))?,
                );
            }
        }
        Ok(out)
    };

    let result = match method {
        "equal" => pb.equal(&field, one(to_datums(literals_obj)?)?),
        "notEqual" => pb.not_equal(&field, one(to_datums(literals_obj)?)?),
        "lessThan" => pb.less_than(&field, one(to_datums(literals_obj)?)?),
        "lessOrEqual" => pb.less_or_equal(&field, one(to_datums(literals_obj)?)?),
        "greaterThan" => pb.greater_than(&field, one(to_datums(literals_obj)?)?),
        "greaterOrEqual" => pb.greater_or_equal(&field, one(to_datums(literals_obj)?)?),
        "isNull" => {
            ensure_no_literals(method, literals_obj)?;
            pb.is_null(&field)
        }
        "isNotNull" => {
            ensure_no_literals(method, literals_obj)?;
            pb.is_not_null(&field)
        }
        "in" => {
            let ds = to_datums(literals_obj)?;
            if ds.is_empty() {
                return Err(PyValueError::new_err("'in' requires at least 1 literal"));
            }
            pb.is_in(&field, ds)
        }
        "notIn" => {
            let ds = to_datums(literals_obj)?;
            if ds.is_empty() {
                return Err(PyValueError::new_err("'notIn' requires at least 1 literal"));
            }
            pb.is_not_in(&field, ds)
        }
        "startsWith" => pb.starts_with(&field, one(to_datums(literals_obj)?)?),
        "endsWith" => pb.ends_with(&field, one(to_datums(literals_obj)?)?),
        "contains" => pb.contains(&field, one(to_datums(literals_obj)?)?),
        "like" => {
            // 1 literal: pattern with the default '\' escape.
            // 2 literals: [pattern, escape] where escape is a single character
            // (SQL `LIKE .. ESCAPE ..`).
            let mut ds = to_datums(literals_obj)?;
            let escape = match ds.len() {
                1 => None,
                2 => Some(escape_char(ds.pop().unwrap())?),
                n => {
                    return Err(PyValueError::new_err(format!(
                        "'like' expects 1 or 2 literals (pattern[, escape]), got {n}"
                    )));
                }
            };
            pb.like(&field, ds.pop().unwrap(), escape)
        }
        "between" => {
            let mut ds = to_datums(literals_obj)?;
            if ds.len() != 2 {
                return Err(PyValueError::new_err(format!(
                    "'between' expects exactly 2 literals (low, high), got {}",
                    ds.len()
                )));
            }
            let high = ds.pop().unwrap();
            let low = ds.pop().unwrap();
            pb.between(&field, low, high)
        }
        "notBetween" => {
            let mut ds = to_datums(literals_obj)?;
            if ds.len() != 2 {
                return Err(PyValueError::new_err(format!(
                    "'notBetween' expects exactly 2 literals (low, high), got {}",
                    ds.len()
                )));
            }
            let high = ds.pop().unwrap();
            let low = ds.pop().unwrap();
            pb.not_between(&field, low, high)
        }
        other => {
            return Err(PyNotImplementedError::new_err(format!(
                "unknown or unsupported predicate operator '{other}'"
            )));
        }
    };
    result.map_err(|e| PyValueError::new_err(e.to_string()))
}

/// Extract exactly one literal for comparison/equality operators.
fn one(mut ds: Vec<Datum>) -> PyResult<Datum> {
    if ds.len() != 1 {
        return Err(PyValueError::new_err(format!(
            "expected exactly 1 literal, got {}",
            ds.len()
        )));
    }
    Ok(ds.pop().unwrap())
}

/// Validate that a null-check operator (`isNull`/`isNotNull`) carries exactly 0
/// literals. A missing `literals` key, or one present as an empty list `[]`, is
/// accepted; a non-empty list raises `ValueError` (count mismatch); a present
/// non-list value raises `ValueError` (mirroring how comparison ops reject a
/// non-list `literals`).
fn ensure_no_literals(method: &str, obj: Option<Bound<'_, PyAny>>) -> PyResult<()> {
    if let Some(obj) = obj {
        let list = obj
            .cast::<PyList>()
            .map_err(|_| PyValueError::new_err("'literals' must be a list"))?;
        if !list.is_empty() {
            return Err(PyValueError::new_err(format!(
                "{method} expects 0 literals, got {}",
                list.len()
            )));
        }
    }
    Ok(())
}

/// Re-wrap an unsupported-literal-type `NotImplementedError` from [`py_to_datum`]
/// with field-name context; pass other errors through unchanged.
fn with_field_context(err: PyErr, field: &str, data_type: &DataType) -> PyErr {
    Python::attach(|py| {
        if err.is_instance_of::<PyNotImplementedError>(py) {
            PyNotImplementedError::new_err(format!(
                "literal conversion for field '{field}' of type {data_type:?} is not supported yet"
            ))
        } else {
            err
        }
    })
}

/// Extract a single-character `like` ESCAPE literal from an already-converted
/// string `Datum`.
fn escape_char(datum: Datum) -> PyResult<char> {
    let Datum::String(s) = datum else {
        return Err(PyValueError::new_err("'like' escape must be a str literal"));
    };
    let mut chars = s.chars();
    match (chars.next(), chars.next()) {
        (Some(c), None) => Ok(c),
        _ => Err(PyValueError::new_err(format!(
            "'like' escape must be a single character, got {s:?}"
        ))),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use paimon::spec::{
        DataField, DataType, Datum, IntType, Predicate, PredicateOperator, VarCharType,
    };
    use pyo3::IntoPyObject;
    use pyo3::Python;

    fn test_fields() -> Vec<DataField> {
        vec![
            DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
            DataField::new(
                1,
                "name".to_string(),
                DataType::VarChar(VarCharType::default()),
            ),
        ]
    }

    /// Build a leaf predicate dict: {"method": .., "field": .., "literals": [..]}.
    fn leaf_dict<'py>(
        py: Python<'py>,
        method: &str,
        field: &str,
        literals: &[i64],
    ) -> Bound<'py, PyDict> {
        let d = PyDict::new(py);
        d.set_item("method", method).unwrap();
        d.set_item("field", field).unwrap();
        let lits = PyList::empty(py);
        for v in literals {
            lits.append(*v).unwrap();
        }
        d.set_item("literals", lits).unwrap();
        d
    }

    #[test]
    fn equal_leaf_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = leaf_dict(py, "equal", "id", &[1]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            match &pred {
                Predicate::Leaf {
                    column,
                    op,
                    literals,
                    ..
                } => {
                    assert_eq!(column, "id");
                    assert_eq!(*op, PredicateOperator::Eq);
                    assert_eq!(literals, &[Datum::Int(1)]);
                }
                other => panic!("expected Leaf, got {other:?}"),
            }
        });
    }

    #[test]
    fn unsupported_operator_not_raises_not_implemented() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = leaf_dict(py, "not", "id", &[1]);
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyNotImplementedError>(py));
        });
    }

    // ---- string operators ----

    /// Build a leaf dict with string literals.
    fn str_leaf_dict<'py>(
        py: Python<'py>,
        method: &str,
        field: &str,
        literals: &[&str],
    ) -> Bound<'py, PyDict> {
        let d = PyDict::new(py);
        d.set_item("method", method).unwrap();
        d.set_item("field", field).unwrap();
        let lits = PyList::empty(py);
        for v in literals {
            lits.append(*v).unwrap();
        }
        d.set_item("literals", lits).unwrap();
        d
    }

    fn expect_leaf_op(pred: &Predicate, expected: PredicateOperator) {
        match pred {
            Predicate::Leaf { op, .. } => assert_eq!(*op, expected),
            other => panic!("expected Leaf, got {other:?}"),
        }
    }

    #[test]
    fn starts_with_leaf_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "startsWith", "name", &["ab"]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            expect_leaf_op(&pred, PredicateOperator::StartsWith);
        });
    }

    #[test]
    fn ends_with_leaf_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "endsWith", "name", &["ab"]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            expect_leaf_op(&pred, PredicateOperator::EndsWith);
        });
    }

    #[test]
    fn contains_leaf_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "contains", "name", &["ab"]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            expect_leaf_op(&pred, PredicateOperator::Contains);
        });
    }

    /// Fields whose string column is spelled `Name` (mixed case).
    fn mixed_case_fields() -> Vec<DataField> {
        vec![
            DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
            DataField::new(
                1,
                "Name".to_string(),
                DataType::VarChar(VarCharType::default()),
            ),
        ]
    }

    #[test]
    fn case_insensitive_leaf_resolves_differently_cased_field() {
        Python::attach(|py| {
            let fields = mixed_case_fields();
            // Request uses `name`; the schema field is `Name`.
            let dict = str_leaf_dict(py, "equal", "name", &["bob"]);

            // Case-sensitive (default): `name` != `Name` → column not found.
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));

            // Case-insensitive: resolves to the canonical `Name`.
            let pred = dict_to_predicate(&dict, &fields, false).unwrap();
            match &pred {
                Predicate::Leaf { column, op, .. } => {
                    assert_eq!(
                        column, "Name",
                        "canonical schema name is stored in the leaf"
                    );
                    assert_eq!(*op, PredicateOperator::Eq);
                }
                other => panic!("expected Leaf, got {other:?}"),
            }
        });
    }

    #[test]
    fn case_insensitive_ambiguous_field_raises_value_error() {
        Python::attach(|py| {
            let fields = vec![
                DataField::new(0, "Col".to_string(), DataType::Int(IntType::new())),
                DataField::new(1, "col".to_string(), DataType::Int(IntType::new())),
            ];
            let dict = leaf_dict(py, "equal", "COL", &[1]);
            let err = dict_to_predicate(&dict, &fields, false).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn like_prefix_pattern_optimizes_to_starts_with() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "like", "name", &["ab%"]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            expect_leaf_op(&pred, PredicateOperator::StartsWith);
        });
    }

    #[test]
    fn like_residual_pattern_stays_like() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "like", "name", &["a%b%c"]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            expect_leaf_op(&pred, PredicateOperator::Like);
        });
    }

    #[test]
    fn like_accepts_backslash_escape_literal() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "like", "name", &["100\\%%", "\\"]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            // Escaped-wildcard patterns are not rewritten by the core's LIKE
            // optimization; they stay as a residual Like leaf.
            expect_leaf_op(&pred, PredicateOperator::Like);
        });
    }

    #[test]
    fn like_rejects_non_backslash_escape() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "like", "name", &["100!%%", "!"]);
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn like_rejects_multi_char_escape() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "like", "name", &["a%", "ab"]);
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn like_rejects_three_literals() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "like", "name", &["a%", "\\", "x"]);
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn string_op_empty_pattern_folds_to_is_not_null() {
        Python::attach(|py| {
            let fields = test_fields();
            for method in ["startsWith", "endsWith", "contains"] {
                let dict = str_leaf_dict(py, method, "name", &[""]);
                let pred = dict_to_predicate(&dict, &fields, true).unwrap();
                expect_leaf_op(&pred, PredicateOperator::IsNotNull);
            }
        });
    }

    #[test]
    fn string_op_on_non_string_column_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            for method in ["startsWith", "endsWith", "contains", "like"] {
                let dict = str_leaf_dict(py, method, "id", &["a"]);
                let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
                assert!(err.is_instance_of::<PyValueError>(py), "{method}");
            }
        });
    }

    #[test]
    fn string_op_wrong_literal_count_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            for method in ["startsWith", "endsWith", "contains", "like"] {
                let dict = str_leaf_dict(py, method, "name", &[]);
                let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
                assert!(err.is_instance_of::<PyValueError>(py), "{method} zero");
            }
            for method in ["startsWith", "endsWith", "contains"] {
                let dict = str_leaf_dict(py, method, "name", &["a", "b"]);
                let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
                assert!(err.is_instance_of::<PyValueError>(py), "{method} two");
            }
        });
    }

    #[test]
    fn string_op_unknown_field_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            // Now that string operators are supported, they follow the normal
            // leaf path: field resolution happens first, so an unknown field is
            // a ValueError (not NotImplementedError as before).
            let dict = str_leaf_dict(py, "like", "nope", &["x"]);
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn unsupported_operator_not_without_field_raises_not_implemented() {
        Python::attach(|py| {
            let fields = test_fields();
            // 'not' with no 'field', only empty 'children': operator support is
            // decided before any shape validation.
            let dict = PyDict::new(py);
            dict.set_item("method", "not").unwrap();
            dict.set_item("children", PyList::empty(py)).unwrap();
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyNotImplementedError>(py));
        });
    }

    #[test]
    fn unknown_field_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = leaf_dict(py, "equal", "nope", &[1]);
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn empty_children_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = PyDict::new(py);
            dict.set_item("method", "and").unwrap();
            dict.set_item("children", PyList::empty(py)).unwrap();
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn compound_with_unsupported_child_fails() {
        Python::attach(|py| {
            let fields = test_fields();
            let ok = leaf_dict(py, "equal", "id", &[1]);
            let bad = leaf_dict(py, "not", "name", &[]);
            let children = PyList::empty(py);
            children.append(ok).unwrap();
            children.append(bad).unwrap();
            let dict = PyDict::new(py);
            dict.set_item("method", "and").unwrap();
            dict.set_item("children", children).unwrap();
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            // No partial pushdown: the unsupported child propagates.
            assert!(err.is_instance_of::<PyNotImplementedError>(py));
        });
    }

    #[test]
    fn and_compound_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let c1 = leaf_dict(py, "equal", "id", &[1]);
            let c2 = leaf_dict(py, "greaterThan", "id", &[0]);
            let children = PyList::empty(py);
            children.append(c1).unwrap();
            children.append(c2).unwrap();
            let dict = PyDict::new(py);
            dict.set_item("method", "and").unwrap();
            dict.set_item("children", children).unwrap();
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            match &pred {
                Predicate::And(ch) => assert_eq!(ch.len(), 2),
                other => panic!("expected And, got {other:?}"),
            }
        });
    }

    #[test]
    fn or_compound_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let c1 = leaf_dict(py, "equal", "id", &[1]);
            let c2 = leaf_dict(py, "equal", "id", &[2]);
            let children = PyList::empty(py);
            children.append(c1).unwrap();
            children.append(c2).unwrap();
            let dict = PyDict::new(py);
            dict.set_item("method", "or").unwrap();
            dict.set_item("children", children).unwrap();
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            match &pred {
                Predicate::Or(ch) => assert_eq!(ch.len(), 2),
                other => panic!("expected Or, got {other:?}"),
            }
        });
    }

    #[test]
    fn none_literal_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            let d = PyDict::new(py);
            d.set_item("method", "equal").unwrap();
            d.set_item("field", "id").unwrap();
            let lits = PyList::empty(py);
            lits.append(py.None()).unwrap();
            d.set_item("literals", lits).unwrap();
            let err = dict_to_predicate(&d, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn wrong_literal_count_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = leaf_dict(py, "equal", "id", &[1, 2]);
            let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn null_check_with_literals_raises_value_error() {
        Python::attach(|py| {
            let fields = test_fields();
            for method in ["isNull", "isNotNull"] {
                let dict = leaf_dict(py, method, "name", &[1]);
                let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
                assert!(err.is_instance_of::<PyValueError>(py));
            }
        });
    }

    #[test]
    fn null_check_accepts_empty_and_missing_literals() {
        Python::attach(|py| {
            let fields = test_fields();
            // Empty literals list is accepted.
            let with_empty = leaf_dict(py, "isNull", "name", &[]);
            assert!(dict_to_predicate(&with_empty, &fields, true).is_ok());
            // Missing literals key is accepted.
            let no_lits = PyDict::new(py);
            no_lits.set_item("method", "isNotNull").unwrap();
            no_lits.set_item("field", "name").unwrap();
            assert!(dict_to_predicate(&no_lits, &fields, true).is_ok());
        });
    }

    #[test]
    fn int_field_accepts_in_range_int() {
        Python::attach(|py| {
            let v = 42i64.into_pyobject(py).unwrap();
            let d = py_to_datum(&v, &DataType::Int(Default::default())).unwrap();
            assert_eq!(d, Datum::Int(42));
        });
    }

    #[test]
    fn int_field_rejects_out_of_range() {
        Python::attach(|py| {
            let v = 9_999_999_999i64.into_pyobject(py).unwrap();
            assert!(py_to_datum(&v, &DataType::Int(Default::default())).is_err());
        });
    }

    #[test]
    fn int_field_rejects_bool() {
        Python::attach(|py| {
            let v = true.into_pyobject(py).unwrap();
            assert!(py_to_datum(v.as_any(), &DataType::Int(Default::default())).is_err());
        });
    }

    #[test]
    fn boolean_field_accepts_bool() {
        Python::attach(|py| {
            let v = true.into_pyobject(py).unwrap();
            let d = py_to_datum(v.as_any(), &DataType::Boolean(Default::default())).unwrap();
            assert_eq!(d, Datum::Bool(true));
        });
    }

    #[test]
    fn boolean_field_rejects_non_bool() {
        Python::attach(|py| {
            let v = 1i64.into_pyobject(py).unwrap();
            assert!(py_to_datum(&v, &DataType::Boolean(Default::default())).is_err());
        });
    }

    #[test]
    fn tinyint_range_enforced() {
        Python::attach(|py| {
            let ok = 127i64.into_pyobject(py).unwrap();
            assert_eq!(
                py_to_datum(&ok, &DataType::TinyInt(Default::default())).unwrap(),
                Datum::TinyInt(127)
            );
            let bad = 128i64.into_pyobject(py).unwrap();
            assert!(py_to_datum(&bad, &DataType::TinyInt(Default::default())).is_err());
        });
    }

    #[test]
    fn smallint_range_enforced() {
        Python::attach(|py| {
            let ok = (-32768i64).into_pyobject(py).unwrap();
            assert_eq!(
                py_to_datum(&ok, &DataType::SmallInt(Default::default())).unwrap(),
                Datum::SmallInt(-32768)
            );
            let bad = 32768i64.into_pyobject(py).unwrap();
            assert!(py_to_datum(&bad, &DataType::SmallInt(Default::default())).is_err());
        });
    }

    #[test]
    fn bigint_accepts_long() {
        Python::attach(|py| {
            let v = 9_999_999_999i64.into_pyobject(py).unwrap();
            assert_eq!(
                py_to_datum(&v, &DataType::BigInt(Default::default())).unwrap(),
                Datum::Long(9_999_999_999)
            );
        });
    }

    #[test]
    fn float_accepts_int_and_float_rejects_bool() {
        Python::attach(|py| {
            let from_int = 3i64.into_pyobject(py).unwrap();
            assert_eq!(
                py_to_datum(&from_int, &DataType::Float(Default::default())).unwrap(),
                Datum::Float(3.0)
            );
            let from_float = 2.5f64.into_pyobject(py).unwrap();
            assert_eq!(
                py_to_datum(&from_float, &DataType::Double(Default::default())).unwrap(),
                Datum::Double(2.5)
            );
            let b = true.into_pyobject(py).unwrap();
            assert!(py_to_datum(b.as_any(), &DataType::Double(Default::default())).is_err());
        });
    }

    #[test]
    fn string_field_accepts_str() {
        Python::attach(|py| {
            let v = "hello".into_pyobject(py).unwrap();
            assert_eq!(
                py_to_datum(v.as_any(), &DataType::VarChar(Default::default())).unwrap(),
                Datum::String("hello".to_string())
            );
        });
    }

    #[test]
    fn string_field_rejects_non_str() {
        Python::attach(|py| {
            let v = 5i64.into_pyobject(py).unwrap();
            assert!(py_to_datum(&v, &DataType::VarChar(Default::default())).is_err());
        });
    }

    #[test]
    fn timestamp_field_rejects_non_datetime() {
        Python::attach(|py| {
            let v = 0i64.into_pyobject(py).unwrap();
            let err = py_to_datum(&v, &DataType::Timestamp(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<pyo3::exceptions::PyValueError>(py));
        });
    }

    // ---- temporal literals ----

    /// `datetime.date(2024, 1, 1)` epoch days (1970-01-01 = 0).
    const D_2024_01_01: i32 = 19723;

    fn py_date<'py>(py: Python<'py>, y: i32, m: u8, d: u8) -> Bound<'py, PyAny> {
        pyo3::types::PyDate::new(py, y, m, d).unwrap().into_any()
    }

    fn py_time<'py>(
        py: Python<'py>,
        h: u8,
        min: u8,
        s: u8,
        micro: u32,
        tz: Option<&Bound<'py, pyo3::types::PyTzInfo>>,
    ) -> Bound<'py, PyAny> {
        pyo3::types::PyTime::new(py, h, min, s, micro, tz)
            .unwrap()
            .into_any()
    }

    #[allow(clippy::too_many_arguments)]
    fn py_datetime<'py>(
        py: Python<'py>,
        y: i32,
        mo: u8,
        d: u8,
        h: u8,
        mi: u8,
        s: u8,
        micro: u32,
        tz: Option<&Bound<'py, pyo3::types::PyTzInfo>>,
    ) -> Bound<'py, PyAny> {
        pyo3::types::PyDateTime::new(py, y, mo, d, h, mi, s, micro, tz)
            .unwrap()
            .into_any()
    }

    fn utc(py: Python<'_>) -> Bound<'_, pyo3::types::PyTzInfo> {
        pyo3::types::PyTzInfo::utc(py).unwrap().to_owned()
    }

    fn fixed_offset(py: Python<'_>, seconds: i32) -> Bound<'_, pyo3::types::PyTzInfo> {
        let delta = pyo3::types::PyDelta::new(py, 0, seconds, 0, false).unwrap();
        pyo3::types::PyTzInfo::fixed_offset(py, &delta).unwrap()
    }

    #[test]
    fn date_field_accepts_date() {
        Python::attach(|py| {
            let v = py_date(py, 2024, 1, 1);
            assert_eq!(
                py_to_datum(&v, &DataType::Date(Default::default())).unwrap(),
                Datum::Date(D_2024_01_01)
            );
        });
    }

    #[test]
    fn date_field_accepts_pre_epoch_date() {
        Python::attach(|py| {
            let v = py_date(py, 1969, 12, 31);
            assert_eq!(
                py_to_datum(&v, &DataType::Date(Default::default())).unwrap(),
                Datum::Date(-1)
            );
        });
    }

    #[test]
    fn date_field_rejects_datetime() {
        Python::attach(|py| {
            // datetime is a date subclass; accepting it would silently drop the
            // time-of-day part.
            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 0, None);
            let err = py_to_datum(&v, &DataType::Date(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn date_field_rejects_str() {
        Python::attach(|py| {
            let v = "2024-01-01".into_pyobject(py).unwrap();
            let err = py_to_datum(v.as_any(), &DataType::Date(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn time_field_accepts_naive_time() {
        Python::attach(|py| {
            let v = py_time(py, 12, 34, 56, 789_000, None);
            assert_eq!(
                py_to_datum(&v, &DataType::Time(Default::default())).unwrap(),
                Datum::Time(45_296_789)
            );
        });
    }

    #[test]
    fn time_field_rejects_sub_millisecond() {
        Python::attach(|py| {
            // Datum::Time carries millis-of-day; silently truncating micros
            // would change equality semantics.
            let v = py_time(py, 0, 0, 0, 500, None);
            let err = py_to_datum(&v, &DataType::Time(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn time_field_rejects_aware_time() {
        Python::attach(|py| {
            let tz = utc(py);
            let v = py_time(py, 1, 2, 3, 0, Some(&tz));
            let err = py_to_datum(&v, &DataType::Time(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn timestamp_field_accepts_naive_datetime() {
        Python::attach(|py| {
            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 123_456, None);
            assert_eq!(
                py_to_datum(&v, &DataType::Timestamp(Default::default())).unwrap(),
                Datum::Timestamp {
                    millis: 1_704_067_200_123,
                    nanos: 456_000,
                }
            );
        });
    }

    #[test]
    fn timestamp_field_pre_epoch_uses_euclidean_split() {
        Python::attach(|py| {
            // 1969-12-31 23:59:59.999999 = -1 µs from epoch
            // → floor millis -1, non-negative nanos remainder 999_000.
            let v = py_datetime(py, 1969, 12, 31, 23, 59, 59, 999_999, None);
            assert_eq!(
                py_to_datum(&v, &DataType::Timestamp(Default::default())).unwrap(),
                Datum::Timestamp {
                    millis: -1,
                    nanos: 999_000,
                }
            );
        });
    }

    #[test]
    fn timestamp_field_rejects_aware_datetime() {
        Python::attach(|py| {
            // TIMESTAMP is timezone-less; parity with the DataFusion path which
            // refuses zoned literals for it.
            let tz = utc(py);
            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 0, Some(&tz));
            let err = py_to_datum(&v, &DataType::Timestamp(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn timestamp_field_rejects_date() {
        Python::attach(|py| {
            let v = py_date(py, 2024, 1, 1);
            let err = py_to_datum(&v, &DataType::Timestamp(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn local_zoned_timestamp_field_accepts_aware_datetime() {
        Python::attach(|py| {
            // 2024-01-01T08:00:00+08:00 == 2024-01-01T00:00:00Z.
            let tz = fixed_offset(py, 8 * 3600);
            let v = py_datetime(py, 2024, 1, 1, 8, 0, 0, 0, Some(&tz));
            assert_eq!(
                py_to_datum(&v, &DataType::LocalZonedTimestamp(Default::default())).unwrap(),
                Datum::LocalZonedTimestamp {
                    millis: 1_704_067_200_000,
                    nanos: 0,
                }
            );
        });
    }

    #[test]
    fn local_zoned_timestamp_field_rejects_naive_datetime() {
        Python::attach(|py| {
            let v = py_datetime(py, 2024, 1, 1, 0, 0, 0, 0, None);
            let err =
                py_to_datum(&v, &DataType::LocalZonedTimestamp(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    /// Build a datetime whose tzinfo subclass returns None from utcoffset() —
    /// naive by Python's definition (docs: "aware if tzinfo is not None AND
    /// utcoffset() does not return None") despite tzinfo being set.
    fn pseudo_naive_datetime(py: Python<'_>) -> Bound<'_, PyAny> {
        let ns = PyDict::new(py);
        py.run(
            c"import datetime
class FloatingTz(datetime.tzinfo):
    def utcoffset(self, dt): return None
    def dst(self, dt): return None
    def tzname(self, dt): return 'FLOATING'
value = datetime.datetime(2024, 1, 1, tzinfo=FloatingTz())",
            None,
            Some(&ns),
        )
        .unwrap();
        ns.get_item("value").unwrap().unwrap()
    }

    #[test]
    fn local_zoned_timestamp_field_rejects_pseudo_naive_tzinfo() {
        Python::attach(|py| {
            // tzinfo is set but utcoffset() is None: astimezone(utc) would
            // interpret the value as process-local time, so the same literal
            // would normalize differently per machine. Must be rejected.
            let v = pseudo_naive_datetime(py);
            let err =
                py_to_datum(&v, &DataType::LocalZonedTimestamp(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn timestamp_field_rejects_pseudo_naive_tzinfo() {
        Python::attach(|py| {
            // Timestamp deliberately rejects ANY tzinfo, even one whose
            // utcoffset() is None: stricter than Python's naive/aware
            // definition, but explicit — the caller strips tzinfo to state
            // wall-clock intent.
            let v = pseudo_naive_datetime(py);
            let err = py_to_datum(&v, &DataType::Timestamp(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    // ---- decimal literals ----

    fn py_decimal<'py>(py: Python<'py>, repr: &str) -> Bound<'py, PyAny> {
        py.import("decimal")
            .unwrap()
            .getattr("Decimal")
            .unwrap()
            .call1((repr,))
            .unwrap()
    }

    fn decimal_type(precision: u32, scale: u32) -> DataType {
        DataType::Decimal(paimon::spec::DecimalType::new(precision, scale).unwrap())
    }

    #[test]
    fn decimal_field_accepts_exact_scale() {
        Python::attach(|py| {
            let v = py_decimal(py, "12.34");
            assert_eq!(
                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
                Datum::Decimal {
                    unscaled: 1234,
                    precision: 10,
                    scale: 2,
                }
            );
        });
    }

    #[test]
    fn decimal_field_rescales_losslessly() {
        Python::attach(|py| {
            let v = py_decimal(py, "1.5");
            assert_eq!(
                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
                Datum::Decimal {
                    unscaled: 150,
                    precision: 10,
                    scale: 2,
                }
            );
        });
    }

    #[test]
    fn decimal_field_accepts_scientific_notation() {
        Python::attach(|py| {
            let v = py_decimal(py, "1E+2");
            assert_eq!(
                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
                Datum::Decimal {
                    unscaled: 10_000,
                    precision: 10,
                    scale: 2,
                }
            );
        });
    }

    #[test]
    fn decimal_field_negative_value() {
        Python::attach(|py| {
            let v = py_decimal(py, "-12.34");
            assert_eq!(
                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
                Datum::Decimal {
                    unscaled: -1234,
                    precision: 10,
                    scale: 2,
                }
            );
        });
    }

    #[test]
    fn decimal_field_accepts_int_literal() {
        Python::attach(|py| {
            let v = 7i64.into_pyobject(py).unwrap();
            assert_eq!(
                py_to_datum(&v, &decimal_type(10, 2)).unwrap(),
                Datum::Decimal {
                    unscaled: 700,
                    precision: 10,
                    scale: 2,
                }
            );
        });
    }

    #[test]
    fn decimal_field_rejects_rounding() {
        Python::attach(|py| {
            // 0.005 cannot be represented at scale 2 without rounding.
            let v = py_decimal(py, "0.005");
            let err = py_to_datum(&v, &decimal_type(10, 2)).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn decimal_field_rejects_precision_overflow() {
        Python::attach(|py| {
            let v = py_decimal(py, "123.45");
            let err = py_to_datum(&v, &decimal_type(4, 2)).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn decimal_field_rejects_non_finite() {
        Python::attach(|py| {
            for repr in ["NaN", "Infinity", "-Infinity"] {
                let v = py_decimal(py, repr);
                let err = py_to_datum(&v, &decimal_type(10, 2)).unwrap_err();
                assert!(err.is_instance_of::<PyValueError>(py), "{repr}");
            }
        });
    }

    #[test]
    fn decimal_field_rejects_float() {
        Python::attach(|py| {
            // Binary floats are not exact decimal values; requiring
            // decimal.Decimal keeps the conversion lossless.
            let v = 1.5f64.into_pyobject(py).unwrap();
            let err = py_to_datum(&v, &decimal_type(10, 2)).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn decimal_field_rejects_bool() {
        Python::attach(|py| {
            let v = true.into_pyobject(py).unwrap();
            let err = py_to_datum(v.as_any(), &decimal_type(10, 2)).unwrap_err();
            assert!(err.is_instance_of::<PyValueError>(py));
        });
    }

    #[test]
    fn unsupported_field_type_still_not_implemented() {
        Python::attach(|py| {
            // Bytes/complex types remain out of scope for literal conversion.
            let v = 0i64.into_pyobject(py).unwrap();
            for dt in [
                DataType::Binary(Default::default()),
                DataType::Array(paimon::spec::ArrayType::new(DataType::Int(IntType::new()))),
            ] {
                let err = py_to_datum(&v, &dt).unwrap_err();
                assert!(err.is_instance_of::<pyo3::exceptions::PyNotImplementedError>(py));
            }
        });
    }

    #[test]
    fn value_errors_use_pyvalueerror() {
        Python::attach(|py| {
            let v = 9_999_999_999i64.into_pyobject(py).unwrap();
            let err = py_to_datum(&v, &DataType::Int(Default::default())).unwrap_err();
            assert!(err.is_instance_of::<pyo3::exceptions::PyValueError>(py));
        });
    }

    // ---- between / notBetween ----

    #[test]
    fn between_leaf_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = leaf_dict(py, "between", "id", &[10, 20]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            match &pred {
                Predicate::Leaf { op, literals, .. } => {
                    assert_eq!(*op, PredicateOperator::Between);
                    assert_eq!(literals, &[Datum::Int(10), Datum::Int(20)]);
                }
                other => panic!("expected Leaf, got {other:?}"),
            }
        });
    }

    #[test]
    fn not_between_leaf_converts() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = leaf_dict(py, "notBetween", "id", &[10, 20]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            match &pred {
                Predicate::Leaf { op, literals, .. } => {
                    assert_eq!(*op, PredicateOperator::NotBetween);
                    assert_eq!(literals, &[Datum::Int(10), Datum::Int(20)]);
                }
                other => panic!("expected Leaf, got {other:?}"),
            }
        });
    }

    #[test]
    fn between_rejects_wrong_literal_count() {
        Python::attach(|py| {
            let fields = test_fields();
            for n in [0, 1, 3] {
                let lits: Vec<i64> = (0..n).collect();
                let dict = leaf_dict(py, "between", "id", &lits);
                let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
                assert!(
                    err.is_instance_of::<PyValueError>(py),
                    "between with {n} lits"
                );
            }
        });
    }

    #[test]
    fn not_between_rejects_wrong_literal_count() {
        Python::attach(|py| {
            let fields = test_fields();
            for n in [0, 1, 3] {
                let lits: Vec<i64> = (0..n).collect();
                let dict = leaf_dict(py, "notBetween", "id", &lits);
                let err = dict_to_predicate(&dict, &fields, true).unwrap_err();
                assert!(
                    err.is_instance_of::<PyValueError>(py),
                    "notBetween with {n} lits"
                );
            }
        });
    }

    #[test]
    fn between_with_string_field_accepts_string_literals() {
        Python::attach(|py| {
            let fields = test_fields();
            let dict = str_leaf_dict(py, "between", "name", &["alpha", "omega"]);
            let pred = dict_to_predicate(&dict, &fields, true).unwrap();
            match &pred {
                Predicate::Leaf { op, literals, .. } => {
                    assert_eq!(*op, PredicateOperator::Between);
                    assert_eq!(
                        literals,
                        &[Datum::String("alpha".into()), Datum::String("omega".into())]
                    );
                }
                other => panic!("expected Leaf, got {other:?}"),
            }
        });
    }
}
