// Binder: resolve column names against the FROM tables, type-check
// expressions, insert numeric promotions, and lower sugar (BETWEEN, IN).
// Three binding spaces:
//   Flat    - over the joined row: all FROM tables concatenated in FROM
//             order (WHERE, GROUP BY, aggregate inputs)
//   Slot k - over table k's own columns (build-side filters, right
//             join keys)
//   AggOut  - over the post-aggregation row: group keys first, then
//             aggregate results (SELECT items, HAVING, ORDER BY)
// The join plan is a left-deep chain in FROM order (reordering is out of
// v1 scope). Conjuncts from WHERE and ON are classified per step:
// single-table ones become build/probe filters, equalities spanning the
// accumulated row and the new table become hash keys, everything else
// spanning both sides is a per-pair residual.

///|
pub(all) enum PhysExpr {
  ColRef(Int32, @types.DataType)
  Const(@types.Scalar)
  Promote(PhysExpr, @types.DataType)
  ArithE(@types.ArithOp, PhysExpr, PhysExpr, @types.DataType)
  CmpE(@types.CmpOp, PhysExpr, PhysExpr)
  AndE(PhysExpr, PhysExpr)
  OrE(PhysExpr, PhysExpr)
  NotE(PhysExpr)
  LikeE(PhysExpr, String) // pattern folded at bind time
  IsNullE(PhysExpr, Bool) // IS NULL (false) / IS NOT NULL (true)
  ExtractE(PhysExpr, @sql.ExtractField)
  InSetE(PhysExpr, Map[String, Bool], Bool, Bool) // value, folded key set, negated, right side had NULL
  CaseE(Array[(PhysExpr, PhysExpr)], PhysExpr?, @types.DataType)
  FuncE(String, Array[PhysExpr], @types.DataType) // scalar function, name lowercased
  CastE(PhysExpr, @types.DataType) // CAST(expr AS type)
  // date +/- interval, quantity sign folded at bind (Sub negates)
  DateArithE(PhysExpr, Int64, @sql.IntervalUnit)
  // select-list window marker: reads the Nth window output column
  // appended after the pre-projection row (exec_windows resolves it to
  // a plain ColRef once that column exists)
  WinRef(Int32, @types.DataType)
} derive(Debug)

///|
pub extend PhysExpr with @debug.Debug::{to_repr}

///|
/// One resolved ORDER BY key, in written order. Output-column keys
/// (out_col = Some(i)) carry the projected expression too so the
/// executor can sort the pre-projection rows uniformly; `expr` is then
/// redundant with out_exprs[i] and only pruning touches it.
priv struct OrderSpec {
  expr : PhysExpr
  desc : Bool
  nulls_first : Bool?
  out_col : Int32?
} derive(Debug)

///|
priv enum AggKind {
  Sum
  Avg
  Count
  CountStar
  CountDistinct
  StringAgg
  SdSamp
  SdPop
  VarSamp
  VarPop
  Median
  Min
  Max
} derive(Debug)

///|
priv struct AggSpec {
  kind : AggKind
  input : PhysExpr? // None only for CountStar
  out_type : @types.DataType
  sep : String? // string_agg's literal separator
} derive(Debug)

///|
/// Which window computation fills the output column. The three ranking
/// kinds ignore `spec`.
priv enum WinKind {
  WRowNumber
  WRank
  WDenseRank
  WAgg
} derive(Debug)

///|
/// One bound window definition: the computation kind with its aggregate
/// spec, plus partition and order keys over the pre-projection row
/// (Flat for plain queries, AggOut for grouped ones).
priv struct BoundWin {
  kind : WinKind
  spec : AggSpec
  partition : Array[PhysExpr]
  order : Array[(PhysExpr, Bool, Bool?)]
} derive(Debug)

///|
fn BoundWin::out_type(self : BoundWin) -> @types.DataType {
  match self.kind {
    WRowNumber | WRank | WDenseRank => @types.Int64
    WAgg => self.spec.out_type
  }
}

///|
/// One hash-join step: table `table` joins the accumulated row. keys_l
/// are Flat refs, keys_r Slot refs into the joining table; both are
/// promoted to a common type so canonical keys match.
priv struct JoinStep {
  table : Int32
  kind : @sql.JoinKind
  keys_l : Array[PhysExpr]
  keys_r : Array[PhysExpr]
  build_filter : PhysExpr? // Slot refs, applied to the joining table
  probe_filter : PhysExpr? // Flat refs, applied to accumulated rows
  residual : PhysExpr? // Flat refs, applied per candidate pair
  right_offset : Int32 // flat offset of the joining table's first column
  right_width : Int32 // column count of the joining table
  out_dtypes : Array[@types.DataType] // combined row layout after the step
} derive(Debug)

///|
/// The bound query, ready for the executor.
pub struct BoundQuery {
  priv steps : Array[JoinStep]
  priv deferred : PhysExpr?
  priv aggregate : Bool // false: plain projection over the joined rows
  priv group_exprs : Array[PhysExpr]
  priv group_types : Array[@types.DataType]
  priv aggs : Array[AggSpec]
  priv having : PhysExpr? // over post-agg layout
  priv out_exprs : Array[PhysExpr] // over post-agg layout
  priv windows : Array[BoundWin] // window output columns, in marker order
  priv names : Array[String]
  priv out_types : Array[@types.DataType]
  priv order_by : Array[OrderSpec] // written-order sort keys
  priv limit : Int32?
  priv offset : Int32?
  priv distinct : Bool // SELECT DISTINCT: dedupe projected rows
} derive(Debug)

///|
pub extend BoundQuery with @debug.Debug::{to_repr}

///|
priv struct Binder {
  slots : Array[@catalog.TableSchema] // FROM order
  quals : Array[String] // alias (or table name) per slot, for ColQ lookup
  mut offsets : Array[Int32] // flat offset of each slot in JOIN order (set by plan_joins)
  runner : SubqueryRunner // executes non-correlated subqueries during binding
  group_l : Array[@sql.LExpr] // GROUP BY exprs as written, for structural match
  group_types : Array[@types.DataType]
  aggs : Array[AggSpec]
  agg_keys : Array[(@sql.AggFn, @sql.LExpr?)] // written shape of each spec, for dedup
  mut wins : Array[BoundWin] // bound window defs, set before items bind
}

///|
priv enum Space {
  Flat
  Slot(Int32)
  AggOut
}

///|
priv struct Conjunct {
  lexpr : @sql.LExpr
  origin : Int32? // Some(item index) for ON conjuncts, None for WHERE
}

///|
/// Executes non-correlated subqueries during binding. Everything in the
/// engine is eager, so folding them to constants / key sets is direct.
/// Correlation is not supported: an inner select can only see its own
/// FROM tables (an outer name resolves as an error or an inner column,
/// never as the outer row).
priv struct SubqueryRunner {
  catalog : @catalog.Catalog
  opts : ExecOptions
}

///|
fn SubqueryRunner::run_scalar(
  self : SubqueryRunner,
  sel : @sql.Select,
) -> (@types.Scalar, @types.DataType) raise @types.SqlError {
  let r = run_parsed(sel, self.catalog, [], self.opts)
  if r.names().length() != 1 {
    raise @types.SqlError::Bind(
      "scalar subquery must return exactly one column",
    )
  }
  // SQL semantics: a scalar subquery over zero rows yields NULL; only
  // more than one row is an error
  if r.row_count() > 1 {
    raise @types.SqlError::Bind(
      "scalar subquery must return at most one row, got \{r.row_count()}",
    )
  }
  if r.row_count() == 0 {
    return (@types.Null, r.types()[0])
  }
  (r.get(0, 0), r.types()[0])
}

///|
/// EXISTS folds to a boolean constant: true when the subquery returns
/// any row. Correlation is not supported (the inner select binds
/// against its own FROM only).
fn SubqueryRunner::run_exists(
  self : SubqueryRunner,
  sel : @sql.Select,
) -> Bool raise @types.SqlError {
  let r = run_parsed(sel, self.catalog, [], self.opts)
  r.row_count() > 0
}

///|
fn SubqueryRunner::run_key_set(
  self : SubqueryRunner,
  sel : @sql.Select,
) -> (Map[String, Bool], Bool, @types.DataType) raise @types.SqlError {
  let r = run_parsed(sel, self.catalog, [], self.opts)
  if r.names().length() != 1 {
    raise @types.SqlError::Bind("IN subquery must return exactly one column")
  }
  let set : Map[String, Bool] = Map([])
  let mut has_null = false
  for row in 0.. has_null = true
      v => set[scalar_key(v)] = true
    }
  }
  (set, has_null, r.types()[0])
}

///|
fn bind_select(
  sel : @sql.Select,
  schemas : Array[@catalog.TableSchema],
  runner : SubqueryRunner,
) -> BoundQuery raise @types.SqlError {
  if schemas.length() > 30 {
    raise @types.SqlError::Bind("too many tables in FROM (max 30)")
  }
  let quals : Array[String] = []
  for item in sel.from {
    quals.push(
      match item.tbl_alias {
        Some(a) => a
        None =>
          match item.table {
            Named(n) => n
            Sub(_) => "_subquery"
          }
      },
    )
  }
  // GROUP BY may reference select-list aliases (Q22's group by
  // cntrycode): substitute the aliased expression structurally
  let group_by : Array[@sql.LExpr] = []
  for g in sel.group_by {
    let g = match g {
      Lit(@types.Int32(k)) => {
        // GROUP BY ordinal: a 1-based reference into the select list
        if k < 1 || k > sel.items.length() {
          raise @types.SqlError::Bind(
            "GROUP BY position \{k} is out of range (1..\{sel.items.length()})",
          )
        }
        sel.items[k - 1].expr
      }
      Col(name) => {
        let mut hit : @sql.LExpr? = None
        for item in sel.items {
          match item.label {
            Some(l) => if l == name { hit = Some(item.expr) }
            None => ()
          }
        }
        match hit {
          Some(e) => e
          None => g
        }
      }
      other => other
    }
    group_by.push(g)
  }
  let b : Binder = {
    slots: schemas,
    quals,
    offsets: [],
    runner,
    group_l: group_by,
    group_types: [],
    aggs: [],
    agg_keys: [],
    wins: [],
  }
  // GROUP BY may reference select-list aliases (Q22's group by
  // cntrycode): substitute the aliased expression structurally
  let group_by : Array[@sql.LExpr] = []
  for g in sel.group_by {
    let g = match g {
      Lit(@types.Int32(k)) => {
        if k < 1 || k > sel.items.length() {
          raise @types.SqlError::Bind(
            "GROUP BY position \{k} is out of range (1..\{sel.items.length()})",
          )
        }
        sel.items[k - 1].expr
      }
      Col(name) => {
        let mut hit : @sql.LExpr? = None
        for item in sel.items {
          match item.label {
            Some(l) => if l == name { hit = Some(item.expr) }
            None => ()
          }
        }
        match hit {
          Some(e) => e
          None => g
        }
      }
      other => other
    }
    group_by.push(g)
  }
  let (steps, deferred) = b.plan_joins(sel)
  let group_exprs : Array[PhysExpr] = []
  for g in group_by {
    let (pe, dt) = b.bind(g, Flat)
    group_exprs.push(pe)
    b.group_types.push(dt)
  }
  // A query aggregates when it has GROUP BY, an aggregate anywhere in
  // the select list, or an aggregate in HAVING; otherwise it is a plain
  // projection over the joined rows.
  let mut agg_mode = sel.group_by.length() > 0
  if !agg_mode {
    for item in sel.items {
      if contains_agg(item.expr) {
        agg_mode = true
      }
    }
    if !agg_mode {
      match sel.having {
        Some(h) => if contains_agg(h) { agg_mode = true }
        None => ()
      }
    }
  }
  let item_space : Space = if agg_mode { AggOut } else { Flat }
  // window definitions bind against the item space before the items so
  // the markers can resolve (and a window's aggregate arguments extend
  // the aggregate list in AggOut space)
  let bwins : Array[BoundWin] = []
  for w in sel.windows {
    bwins.push(b.bind_window(w, item_space))
  }
  b.wins = bwins
  // expand a bare `*` select item into every FROM column, each table in
  // FROM order (qualified refs keep duplicate names resolvable); in
  // aggregate mode the plain columns then fail binding with the usual
  // "must appear in GROUP BY" error, which is the right answer there too
  let items : Array[@sql.SelectItem] = []
  for item in sel.items {
    if item.expr is Star {
      for k, schema in schemas {
        for col in schema.columns {
          items.push({ expr: ColQ(quals[k], col.name), label: None, })
        }
      }
    } else {
      items.push(item)
    }
  }
  let out_exprs : Array[PhysExpr] = []
  let names : Array[String] = []
  let out_types : Array[@types.DataType] = []
  for i, item in items {
    // an untyped NULL literal in the select list is INTEGER, matching
    // duckdb; elsewhere (comparisons, arithmetic) the live operand
    // still fixes the type
    let (pe, dt) = if item.expr is Lit(@types.Null) {
      (Const(@types.Null), @types.DataType::Int32)
    } else {
      b.bind(item.expr, item_space)
    }
    out_exprs.push(pe)
    names.push(
      match item.label {
        Some(a) => a
        None =>
          match win_marker_index(item.expr) {
            Some(k) => win_default_name(bwins[k], i)
            None => default_name(item.expr, i)
          }
      },
    )
    out_types.push(dt)
  }
  let having : PhysExpr? = match sel.having {
    Some(h) => {
      if !agg_mode {
        raise @types.SqlError::Bind(
          "HAVING requires GROUP BY or an aggregate (v1 subset)",
        )
      }
      match b.bind(h, AggOut) {
        (pe, @types.Bool) => Some(pe)
        (_, other) =>
          raise @types.SqlError::Bind(
            "HAVING must evaluate to bool, got \{other.to_string()}",
          )
      }
    }
    None => None
  }
  let order_by : Array[OrderSpec] = []
  for item in sel.order_by {
    // ORDER BY references output columns by name or ordinal; anything
    // else binds as an expression over the pre-projection row (Flat for
    // plain queries, AggOut once aggregates are involved), and sorting
    // happens before projection.
    let key_name : String? = match item.key {
      Col(name) => Some(name)
      ColQ(_, name) => Some(name)
      _ => None
    }
    let mut idx : Int32? = match key_name {
      Some(name) => {
        let mut found = None
        for i, name2 in names {
          if name2 == name {
            found = Some(i)
          }
        }
        found
      }
      None => None
    }
    if idx is None {
      match item.key {
        Lit(@types.Int32(v)) =>
          if v >= 1 && v <= names.length() {
            idx = Some(v - 1)
          } else {
            raise @types.SqlError::Bind(
              "ORDER BY position \{v} is out of range (1..\{names.length()})",
            )
          }
        _ => ()
      }
    }
    match idx {
      Some(i) =>
        order_by.push({
          expr: out_exprs[i],
          desc: item.desc,
          nulls_first: item.nulls_first,
          out_col: Some(i),
        })
      None => {
        let (pe, _) = b.bind(item.key, if agg_mode { AggOut } else { Flat })
        order_by.push({
          expr: pe,
          desc: item.desc,
          nulls_first: item.nulls_first,
          out_col: None,
        })
      }
    }
  }
  {
    steps,
    deferred,
    aggregate: agg_mode,
    group_exprs,
    group_types: b.group_types,
    aggs: b.aggs,
    having,
    out_exprs,
    windows: bwins,
    names,
    out_types,
    order_by,
    limit: sel.limit,
    offset: sel.offset,
    distinct: sel.distinct,
  }
}

///|
fn contains_agg(e : @sql.LExpr) -> Bool {
  match e {
    Col(_) | ColQ(_, _) | Lit(_) => ()
    Agg(_, _) => return true
    Func(_, args) =>
      for a in args {
        if contains_agg(a) {
          return true
        }
      }
    Cast(e, _) => if contains_agg(e) { return true }
    Exists(_, _) => ()
    Arith(_, l, r) | Cmp(_, l, r) | And(l, r) | Or(l, r) =>
      return contains_agg(l) || contains_agg(r)
    Not(x) => return contains_agg(x)
    Between(v, lo, hi) =>
      return contains_agg(v) || contains_agg(lo) || contains_agg(hi)
    In(v, items) | NotIn(v, items) => {
      let mut found = contains_agg(v)
      for item in items {
        if contains_agg(item) {
          found = true
        }
      }
      return found
    }
    Like(v, _) | NotLike(v, _) => return contains_agg(v)
    IsNull(v, _) => return contains_agg(v)
    Star => return false
    Extract(_, x) => return contains_agg(x)
    IntervalLit(_, _) => return false
    Case(whens, else_) => {
      let mut found = false
      for when in whens {
        if contains_agg(when.0) || contains_agg(when.1) {
          found = true
        }
      }
      match else_ {
        Some(e) => return found || contains_agg(e)
        None => return found
      }
    }
    ScalarSub(_) | InSelect(_, _, _) => return false // inner query aggregates are its own
  }
  false
}

///|
fn default_name(expr : @sql.LExpr, index : Int32) -> String {
  match expr {
    Col(name) => name
    ColQ(_, name) => name
    Agg(fn_, _) => {
      let label = agg_label(fn_)
      if index == 0 {
        label
      } else {
        "\{label}_\{index + 1}"
      }
    }
    _ => "col_\{index + 1}"
  }
}

///|
/// Column-name label of an aggregate function (DuckDB-style lowercase).
fn agg_label(fn_ : @sql.AggFn) -> String {
  match fn_ {
    Sum => "sum"
    Avg => "avg"
    Count | CountStar | CountDistinct => "count"
    StringAgg => "string_agg"
    SdSamp | VarSamp => "stddev"
    SdPop | VarPop => "stddev_pop"
    Median => "median"
    Min => "min"
    Max => "max"
  }
}

///|
/// The marker index of a select-list window item, None for anything else.
fn win_marker_index(e : @sql.LExpr) -> Int32? {
  match e {
    Func("__win", [Lit(@types.Int32(k))]) => Some(k)
    _ => None
  }
}

///|
/// Default output name of a window item (row_number, rank, ..., or the
/// aggregate label), index-suffixed like aggregates.
fn win_default_name(w : BoundWin, index : Int32) -> String {
  let label = match w.kind {
    WRowNumber => "row_number"
    WRank => "rank"
    WDenseRank => "dense_rank"
    WAgg => agg_label_of_kind(w.spec.kind)
  }
  if index == 0 {
    label
  } else {
    "\{label}_\{index + 1}"
  }
}

///|
fn agg_label_of_kind(kind : AggKind) -> String {
  match kind {
    Sum => "sum"
    Avg => "avg"
    Count | CountStar | CountDistinct => "count"
    StringAgg => "string_agg"
    SdSamp | VarSamp => "stddev"
    SdPop | VarPop => "stddev_pop"
    Median => "median"
    Min => "min"
    Max => "max"
  }
}

///|
/// Resolve a column name to its table slot; ambiguity and unknown names
/// are bind errors (TPC-H tables use globally unique column names).
fn Binder::lookup_slot(
  self : Binder,
  name : String,
) -> Int32 raise @types.SqlError {
  let mut hit : Int32? = None
  let mut dup = false
  for k, s in self.slots {
    if s.index_of(name) is Some(_) {
      match hit {
        Some(_) => dup = true
        None => hit = Some(k)
      }
    }
  }
  match (hit, dup) {
    (Some(k), false) => k
    (_, true) =>
      raise @types.SqlError::Bind("ambiguous column \"\{name}\" in FROM")
    (None, _) =>
      raise @types.SqlError::Bind("unknown column \"\{name}\" in FROM")
  }
}

///|
/// Resolve a FROM qualifier (alias or table name) to its slot.
fn Binder::lookup_slot_by_qual(
  self : Binder,
  q : String,
) -> Int32 raise @types.SqlError {
  for k, s in self.slots {
    if self.quals[k] == q || s.table == q {
      return k
    }
  }
  raise @types.SqlError::Bind("unknown table or alias \"\{q}\" in FROM")
}

///|
/// Table mask of every column an expression references, resolved before
/// binding (drives join conjunct classification).
fn Binder::col_mask(
  self : Binder,
  e : @sql.LExpr,
) -> Int32 raise @types.SqlError {
  match e {
    Col(name) => 1 << self.lookup_slot(name)
    ColQ(q, _) => 1 << self.lookup_slot_by_qual(q)
    Lit(_) => 0
    Arith(_, l, r) => self.col_mask(l) | self.col_mask(r)
    Cmp(_, l, r) => self.col_mask(l) | self.col_mask(r)
    And(l, r) => self.col_mask(l) | self.col_mask(r)
    Or(l, r) => self.col_mask(l) | self.col_mask(r)
    Not(x) => self.col_mask(x)
    Between(v, lo, hi) =>
      self.col_mask(v) | self.col_mask(lo) | self.col_mask(hi)
    In(v, items) | NotIn(v, items) => {
      let mut m = self.col_mask(v)
      for item in items {
        m = m | self.col_mask(item)
      }
      m
    }
    Like(v, _) | NotLike(v, _) => self.col_mask(v)
    IsNull(v, _) => self.col_mask(v)
    Star => raise @types.SqlError::Bind("'*' is only allowed as a select item")
    Extract(_, x) => self.col_mask(x)
    IntervalLit(_, _) => 0
    Case(whens, else_) => {
      let mut m = 0
      for when in whens {
        m = m | self.col_mask(when.0) | self.col_mask(when.1)
      }
      match else_ {
        Some(e) => m | self.col_mask(e)
        None => m
      }
    }
    // subqueries are self-contained: only the outer value expression counts
    ScalarSub(_) => 0
    InSelect(v, _, _) => self.col_mask(v)
    Exists(_, _) => 0
    Func(_, args) => {
      let mut m = 0
      for a in args {
        m = m | self.col_mask(a)
      }
      m
    }
    Cast(e, _) => self.col_mask(e)
    Agg(_, _) =>
      raise @types.SqlError::Bind("aggregate not allowed in a join condition")
  }
}

///|
fn split_and(e : @sql.LExpr, out : Array[Conjunct], origin : Int32?) -> Unit {
  match e {
    And(l, r) => {
      split_and(l, out, origin)
      split_and(r, out, origin)
    }
    other => out.push({ lexpr: other, origin, })
  }
}

///|
/// Top-level OR branches of a conjunct.
fn or_branches(e : @sql.LExpr, out : Array[@sql.LExpr]) -> Unit {
  match e {
    Or(l, r) => {
      or_branches(l, out)
      or_branches(r, out)
    }
    other => out.push(other)
  }
}

///|
/// Equality pairs inside a (possibly AND-chained) branch.
fn and_eqs(e : @sql.LExpr, out : Array[(@sql.LExpr, @sql.LExpr)]) -> Unit {
  match e {
    And(l, r) => {
      and_eqs(l, out)
      and_eqs(r, out)
    }
    Cmp(@types.Eq, l, r) => out.push((l, r))
    _ => ()
  }
}

///|
/// If every OR branch implies the same equi pair split across the
/// accumulated row and the joining table, return it (left, right).
fn Binder::implied_eq(
  self : Binder,
  e : @sql.LExpr,
  acc_mask : Int32,
  cur_mask : Int32,
) -> (@sql.LExpr, @sql.LExpr)? raise @types.SqlError {
  let disjuncts : Array[@sql.LExpr] = []
  or_branches(e, disjuncts)
  if disjuncts.length() < 2 {
    return None
  }
  let first : Array[(@sql.LExpr, @sql.LExpr)] = []
  and_eqs(disjuncts[0], first)
  let mut common : (@sql.LExpr, @sql.LExpr)? = None
  for cand in first {
    let mut all = true
    for k in 1.. {
      let lm = self.col_mask(l)
      let rm = self.col_mask(r)
      if (lm & acc_mask.lnot()) == 0 && lm != 0 && rm == cur_mask {
        Some((l, r))
      } else if (rm & acc_mask.lnot()) == 0 && rm != 0 && lm == cur_mask {
        Some((r, l))
      } else {
        None
      }
    }
    None => None
  }
}

///|
/// Plan the join chain and bind every classified conjunct. Chains with
/// any LEFT JOIN keep written order (LEFT fixes row preservation);
/// inner-only chains join greedily connected-first so a written order
/// like `part, supplier, lineitem, ...` does not cross-product early.
/// The flat column layout follows the JOIN order (that is how the
/// executor accumulates rows). WHERE conjuncts that span tables but
/// never become keys or residuals (possible at LEFT JOIN steps) come
/// back as a deferred post-join filter.
fn Binder::plan_joins(
  self : Binder,
  sel : @sql.Select,
) -> (Array[JoinStep], PhysExpr?) raise @types.SqlError {
  let pool : Array[Conjunct] = []
  match sel.filter {
    Some(f) => split_and(f, pool, None)
    None => ()
  }
  for i, item in sel.from {
    match item.on {
      Some(on) => split_and(on, pool, Some(i))
      None => ()
    }
  }
  let consumed : Array[Bool] = []
  for _ in 0.. 0 {
      // first remaining table with an equi conjunct into the accumulated
      // row; written order breaks ties; no connection -> cross with the
      // first remaining table
      let mut pick = remaining[0]
      let mut connected = false
      for t in remaining {
        if self.connects(t, acc_mask, pool, consumed) {
          pick = t
          connected = true
          break
        }
      }
      if !connected {
        pick = remaining[0]
      }
      order.push(pick)
      acc_mask = acc_mask | (1 << pick)
      let mut idx = 0
      for k, t in remaining {
        if t == pick {
          idx = k
        }
      }
      let _ = remaining.remove(idx)
    }
  }
  // flat layout follows the join order
  let offsets : Array[Int32] = []
  for _ in 0.. Some(pe)
      (_, other) =>
        raise @types.SqlError::Bind(
          "filter must evaluate to bool, got \{other.to_string()}",
        )
    }
  }
  (steps, deferred)
}

///|
/// Is there an unconsumed conjunct whose equality sides split exactly
/// across the accumulated row and table t?
fn Binder::connects(
  self : Binder,
  t : Int32,
  acc_mask : Int32,
  pool : Array[Conjunct],
  consumed : Array[Bool],
) -> Bool raise @types.SqlError {
  let cur = 1 << t
  for j, cj in pool {
    if consumed[j] {
      continue
    }
    match cj.origin {
      Some(o) => if o != t { continue }
      None => ()
    }
    match cj.lexpr {
      Cmp(@types.Eq, l, r) => {
        let lm = self.col_mask(l)
        let rm = self.col_mask(r)
        if (lm & acc_mask.lnot()) == 0 && lm != 0 && rm == cur {
          return true
        }
        if (rm & acc_mask.lnot()) == 0 && rm != 0 && lm == cur {
          return true
        }
      }
      _ => ()
    }
  }
  false
}

///|
fn Binder::plan_step(
  self : Binder,
  i : Int32,
  item : @sql.FromItem,
  acc_tables : Array[Int32], // slots joined so far, in join order
  acc_mask : Int32,
  pool : Array[Conjunct],
  consumed : Array[Bool],
) -> JoinStep raise @types.SqlError {
  let cur_mask = 1 << i
  let both_masks = acc_mask | cur_mask
  let build_l : Array[@sql.LExpr] = []
  let probe_l : Array[@sql.LExpr] = []
  let key_l : Array[@sql.LExpr] = []
  let key_r : Array[@sql.LExpr] = []
  let residual_l : Array[@sql.LExpr] = []
  for j, cj in pool {
    if consumed[j] {
      continue
    }
    // ON conjuncts belong to their own step only
    match cj.origin {
      Some(o) => if o != i { continue }
      None => ()
    }
    let m = self.col_mask(cj.lexpr)
    if m == cur_mask {
      // a WHERE conjunct on the joining table must not filter build rows
      // when the accumulated side is preserved (LEFT): removing build
      // rows changes which probe rows find a match, so the NULL-extended
      // output would lose the restriction (WHERE applies to the
      // post-join row, NULL side included). It stays unconsumed and
      // comes back as the deferred post-join filter. At a RIGHT step the
      // joining table is the preserved side, so the filter is sound
      // there; at FULL both sides are preserved, so it defers too.
      // ON conjuncts keep build placement: dropping candidates is exactly
      // their semantics, NULL-extension sees the same match set.
      if (item.join is Left || item.join is Full) && cj.origin is None {
        let _ = m
      } else {
        build_l.push(cj.lexpr)
        consumed[j] = true
      }
    } else if m == 0 {
      // constant predicate: probe side from step 1 on, build at step 0
      if i == 0 {
        build_l.push(cj.lexpr)
      } else {
        probe_l.push(cj.lexpr)
      }
      consumed[j] = true
    } else if (m & both_masks.lnot()) == 0 && (m & cur_mask) == 0 {
      // references only accumulated tables: a WHERE pushdown is sound
      // while the accumulated side is preserved (Inner/Left). At RIGHT
      // and FULL the accumulated side is NULL-extendable, so a WHERE
      // conjunct must stay a deferred post-join filter (ON conjuncts
      // keep candidate filtering — that is their semantics)
      if (item.join is Right || item.join is Full) && cj.origin is None {
        let _ = m // stay unconsumed; deferred below
      } else {
        probe_l.push(cj.lexpr)
        consumed[j] = true
      }
    } else if (m & both_masks.lnot()) == 0 && (m & cur_mask) != 0 {
      // spans the accumulated row and the joining table. At a step that
      // preserves a side (LEFT/RIGHT/FULL) a WHERE conjunct here must
      // stay deferred: keys and residuals only ever see matched
      // candidate pairs, so a NULL-extended row would escape the
      // predicate entirely (e.g. `... LEFT JOIN orders ON k = k WHERE
      // orders.amount = customers.cid * 10` would keep the unmatched
      // customers). ON conjuncts keep key/residual placement — that is
      // their semantics.
      if (item.join is Left || item.join is Right || item.join is Full) &&
        cj.origin is None {
        let _ = m // stay unconsumed; deferred below
      } else {
        let key = match cj.lexpr {
          Cmp(@types.Eq, l, r) => {
            let lm = self.col_mask(l)
            let rm = self.col_mask(r)
            if (lm & acc_mask.lnot()) == 0 && lm != 0 && rm == cur_mask {
              key_l.push(l)
              key_r.push(r)
              consumed[j] = true
              true
            } else if (rm & acc_mask.lnot()) == 0 && rm != 0 && lm == cur_mask {
              key_l.push(r)
              key_r.push(l)
              consumed[j] = true
              true
            } else {
              false
            }
          }
          _ => false
        }
        if !key && (m & both_masks.lnot()) == 0 {
          // a disjunction may imply an equality: Q19-style OR branches that
          // all contain the same equi pair (e.g. p_partkey = l_partkey)
          // yield a hash key while the full OR stays as the residual
          let implied : (@sql.LExpr, @sql.LExpr)? = match cj.lexpr {
            Or(_, _) => self.implied_eq(cj.lexpr, acc_mask, cur_mask)
            _ => None
          }
          match implied {
            Some((l, r)) => {
              key_l.push(l)
              key_r.push(r)
              residual_l.push(cj.lexpr)
              consumed[j] = true
            }
            None =>
              // residual per candidate pair; at a LEFT step only ON
              // conjuncts may act as residuals (WHERE keeps SQL semantics:
              // filter the join output, never change NULL-extension)
              if item.join is Left && cj.origin is None {
                let _ = m // stay unconsumed; deferred below
              } else {
                residual_l.push(cj.lexpr)
                consumed[j] = true
              }
          }
        } else if !key {
          let _ = m // conjunct references tables joined later
        }
      }
    }
    // else: references tables joined later; leave in the pool
  }
  let step = i
  let build_filter = self.conjoin_bind(build_l, Slot(step), "build filter")
  let probe_filter = self.conjoin_bind(probe_l, Flat, "probe filter")
  let residual = self.conjoin_bind(residual_l, Flat, "join residual")
  let keys_l : Array[PhysExpr] = []
  let keys_r : Array[PhysExpr] = []
  for k in 0.. {
        keys_l.push(promote_to(lpe, ldt, dt))
        keys_r.push(promote_to(rpe, rdt, dt))
      }
      None =>
        raise @types.SqlError::Bind(
          "join key type mismatch: \{ldt.to_string()} vs \{rdt.to_string()}",
        )
    }
  }
  let out_dtypes : Array[@types.DataType] = []
  for k in acc_tables {
    for c in self.slots[k].columns {
      out_dtypes.push(c.dtype)
    }
  }
  for c in self.slots[i].columns {
    out_dtypes.push(c.dtype)
  }
  {
    table: i,
    kind: item.join,
    keys_l,
    keys_r,
    build_filter,
    probe_filter,
    residual,
    right_offset: self.offsets[i],
    right_width: self.slots[i].columns.length(),
    out_dtypes,
  }
}

///|
fn Binder::conjoin_bind(
  self : Binder,
  lexprs : Array[@sql.LExpr],
  space : Space,
  what : String,
) -> PhysExpr? raise @types.SqlError {
  if lexprs.length() == 0 {
    return None
  }
  let mut acc : @sql.LExpr = lexprs[0]
  for j in 1.. Some(pe)
    (_, other) =>
      raise @types.SqlError::Bind(
        "\{what} must evaluate to bool, got \{other.to_string()}",
      )
  }
}

///|
/// Bind an aggregate reference in the output space: reuse an existing
/// spec with the same written shape or create one.
fn Binder::agg_ref(
  self : Binder,
  fn_ : @sql.AggFn,
  inner : @sql.LExpr?,
) -> (PhysExpr, @types.DataType) raise @types.SqlError {
  for k, key in self.agg_keys {
    let same_inner = match (key.1, inner) {
      (None, None) => true
      (Some(a), Some(c)) => @sql.LExpr::equal(a, c)
      _ => false
    }
    if key.0 == fn_ && same_inner {
      let dt = self.aggs[k].out_type
      let col = self.group_types.length() + k
      return (ColRef(col, dt), dt)
    }
  }
  let spec = self.bind_agg(fn_, inner, Flat)
  self.aggs.push(spec)
  self.agg_keys.push((fn_, inner))
  let col = self.group_types.length() + self.aggs.length() - 1
  (ColRef(col, spec.out_type), spec.out_type)
}

///|
fn Binder::bind_agg(
  self : Binder,
  fn_ : @sql.AggFn,
  inner : @sql.LExpr?,
  space : Space,
) -> AggSpec raise @types.SqlError {
  if fn_ is CountStar {
    return { kind: CountStar, input: None, out_type: @types.Int64, sep: None, }
  }
  let lexpr = match inner {
    Some(e) => e
    None =>
      raise @types.SqlError::Bind("aggregate requires an input expression")
  }
  if fn_ is StringAgg {
    // string_agg(value, sep): the parser wraps both args in a marker;
    // only a literal separator survives the fold
    match inner {
      Some(Func("__string_agg", [v, Lit(@types.Str(sep))])) => {
        let (pe, vdt) = self.bind(v, space)
        if vdt != @types.Str {
          raise @types.SqlError::Bind(
            "string_agg() value must be a string, got \{vdt.to_string()} (cast it explicitly)",
          )
        }
        return {
          kind: StringAgg,
          input: Some(pe),
          out_type: @types.Str,
          sep: Some(sep),
        }
      }
      _ =>
        raise @types.SqlError::Bind(
          "string_agg() separator must be a string literal",
        )
    }
  }
  if @sql.contains_win_marker(lexpr) {
    raise @types.SqlError::Bind(
      "window functions cannot appear inside aggregate arguments",
    )
  }
  let (pe, dt) = self.bind(lexpr, space) // no nested aggregates
  let out_type = match fn_ {
    Sum =>
      match dt {
        @types.Int32 | @types.Int64 => @types.DataType::Int64
        @types.Float64 => @types.DataType::Float64
        other =>
          raise @types.SqlError::Bind(
            "SUM does not support input type \{other.to_string()}",
          )
      }
    Avg =>
      match dt {
        @types.Int32 | @types.Int64 | @types.Float64 => @types.DataType::Float64
        other =>
          raise @types.SqlError::Bind(
            "AVG does not support input type \{other.to_string()}",
          )
      }
    Count | CountDistinct => @types.DataType::Int64
    StringAgg => @types.Str // unreachable: returned early with the separator
    SdSamp | SdPop | VarSamp | VarPop | Median =>
      match dt {
        @types.Int32 | @types.Int64 | @types.Float64 => @types.DataType::Float64
        other =>
          raise @types.SqlError::Bind(
            "aggregates do not support input type \{other.to_string()}",
          )
      }
    Min | Max =>
      match dt {
        @types.Bool =>
          raise @types.SqlError::Bind(
            "MIN/MAX does not support input type bool",
          )
        other => other
      }
    CountStar => @types.DataType::Int64 // unreachable: handled above
  }
  let kind : AggKind = match fn_ {
    Sum => Sum
    Avg => Avg
    Count => Count
    CountStar => CountStar
    CountDistinct => CountDistinct
    SdSamp => SdSamp
    SdPop => SdPop
    VarSamp => VarSamp
    VarPop => VarPop
    Median => Median
    Min => Min
    Max => Max
    StringAgg => StringAgg // unreachable: returned early
  }
  { kind, input: Some(pe), out_type, sep: None, }
}

///|
/// Bind one window definition. The function binds against the item
/// space: Flat for plain queries; AggOut lets a grouped query window
/// over its aggregate outputs (Postgres-style `sum(sum(x)) OVER ()`,
/// while a bare column argument still fails the GROUP BY check).
/// Partition and order keys bind in the same space.
fn Binder::bind_window(
  self : Binder,
  w : @sql.WindowSpec,
  space : Space,
) -> BoundWin raise @types.SqlError {
  let spec : AggSpec = match w.fn_ {
    RowNumber | Rank | DenseRank =>
      { kind: CountStar, input: None, out_type: @types.Int64, sep: None, }
    @sql.WinFn::Agg(f, inner) => self.bind_agg(f, inner, space)
  }
  fn check_no_nesting(e : @sql.LExpr) -> Unit raise @types.SqlError {
    if @sql.contains_win_marker(e) {
      raise @types.SqlError::Bind("nested window functions are not allowed")
    }
  }

  let partition : Array[PhysExpr] = []
  for p in w.partition {
    check_no_nesting(p)
    let (pe, _) = self.bind(p, space)
    partition.push(pe)
  }
  let order : Array[(PhysExpr, Bool, Bool?)] = []
  for o in w.order {
    check_no_nesting(o.0)
    let (pe, _) = self.bind(o.0, space)
    order.push((pe, o.1, o.2))
  }
  match w.fn_ {
    @sql.WinFn::Agg(_, Some(arg)) => check_no_nesting(arg)
    _ => ()
  }
  let kind : WinKind = match w.fn_ {
    RowNumber => WRowNumber
    Rank => WRank
    DenseRank => WDenseRank
    @sql.WinFn::Agg(_, _) => WAgg
  }
  { kind, spec, partition, order, }
}

///|
/// Bind a logical expression in the given space.
fn Binder::bind(
  self : Binder,
  e : @sql.LExpr,
  space : Space,
) -> (PhysExpr, @types.DataType) raise @types.SqlError {
  if space is AggOut {
    for i, g in self.group_l {
      if @sql.LExpr::equal(e, g) {
        let dt = self.group_types[i]
        return (ColRef(i, dt), dt)
      }
    }
  }
  match e {
    Col(name) =>
      match space {
        Flat => {
          let k = self.lookup_slot(name)
          let col_idx = self.slots[k].index_of(name).unwrap()
          let dt = self.slots[k].columns[col_idx].dtype
          (ColRef(self.offsets[k] + col_idx, dt), dt)
        }
        Slot(k) =>
          match self.slots[k].index_of(name) {
            Some(col_idx) => {
              let dt = self.slots[k].columns[col_idx].dtype
              (ColRef(col_idx, dt), dt)
            }
            None =>
              raise @types.SqlError::Bind(
                "unknown column \"\{name}\" in table \"\{self.slots[k].table}\"",
              )
          }
        AggOut =>
          raise @types.SqlError::Bind(
            "column \"\{name}\" must appear in GROUP BY or inside an aggregate",
          )
      }
    ColQ(q, name) =>
      match space {
        AggOut =>
          raise @types.SqlError::Bind(
            "column \"\{q}.\{name}\" must appear in GROUP BY or inside an aggregate",
          )
        _ => {
          let k = self.lookup_slot_by_qual(q)
          let col_idx : Int32? = self.slots[k].index_of(name)
          match col_idx {
            Some(col_idx) => {
              let dt = self.slots[k].columns[col_idx].dtype
              let base = match space {
                Flat => self.offsets[k]
                Slot(_) => 0 // local refs index the table itself
                AggOut => 0 // unreachable: handled above
              }
              (ColRef(base + col_idx, dt), dt)
            }
            None =>
              raise @types.SqlError::Bind(
                "unknown column \"\{name}\" in table \"\{self.slots[k].table}\"",
              )
          }
        }
      }
    Lit(s) =>
      match s {
        @types.Null =>
          raise @types.SqlError::Bind(
            "untyped NULL is only supported as a comparison or arithmetic operand (v1 subset)",
          )
        other => {
          let dt = scalar_type(other)
          (Const(other), dt)
        }
      }
    Agg(fn_, inner) =>
      match space {
        AggOut => self.agg_ref(fn_, inner)
        _ =>
          raise @types.SqlError::Bind(
            "aggregate is only allowed in SELECT, HAVING or ORDER BY",
          )
      }
    Arith(op, l, r) =>
      // date +/- interval lowers to a dedicated node; the interval
      // literal is invalid anywhere else
      if l is IntervalLit(_, _) || r is IntervalLit(_, _) {
        self.bind_date_arith(op, l, r, space)
      } else if l is Lit(@types.Null) || r is Lit(@types.Null) {
        // NULL literal operand: the live side fixes the arithmetic type and
        // the result is NULL for every row (Scalar::arith propagates it)
        if l is Lit(@types.Null) && r is Lit(@types.Null) {
          raise @types.SqlError::Bind(
            "type of NULL cannot be inferred from NULL",
          )
        }
        let (live_pe, live_dt) = self.bind(
          if l is Lit(@types.Null) {
            r
          } else {
            l
          },
          space,
        )
        if live_dt is @types.Bool {
          raise @types.SqlError::Bind("bool operand in arithmetic")
        }
        // `/` is true division per DuckDB even against NULL: int/int is DOUBLE
        let out = arith_output(op, live_dt)
        let null_pe : PhysExpr = Const(@types.Null)
        (
          if l is Lit(@types.Null) {
            ArithE(op, null_pe, live_pe, out)
          } else {
            ArithE(op, live_pe, null_pe, out)
          },
          out,
        )
      } else {
        let (lpe, ldt) = self.bind(l, space)
        let (rpe, rdt) = self.bind(r, space)
        match unify(ldt, rdt) {
          Some(dt) =>
            match dt {
              @types.Bool =>
                raise @types.SqlError::Bind("bool operand in arithmetic")
              _ => {
                // `/` is true division per DuckDB: int/int yields DOUBLE,
                // so both sides promote even when unify agreed on an integer
                let out = arith_output(op, dt)
                let lp = promote_to(lpe, ldt, out)
                let rp = promote_to(rpe, rdt, out)
                (ArithE(op, lp, rp, out), out)
              }
            }
          None =>
            raise @types.SqlError::Bind(
              "type mismatch in arithmetic: \{ldt.to_string()} vs \{rdt.to_string()}",
            )
        }
      }
    Cmp(op, l, r) =>
      // NULL literal operand: v  NULL is UNKNOWN for every row (the
      // evaluator turns a NULL side into a NULL ordering); the live side
      // still binds so unknown columns and type errors surface
      if l is Lit(@types.Null) || r is Lit(@types.Null) {
        if l is Lit(@types.Null) && r is Lit(@types.Null) {
          let null_pe : PhysExpr = Const(@types.Null)
          return (CmpE(op, null_pe, null_pe), @types.Bool)
        }
        let (live_pe, _) = self.bind(
          if l is Lit(@types.Null) {
            r
          } else {
            l
          },
          space,
        )
        let null_pe : PhysExpr = Const(@types.Null)
        (
          if l is Lit(@types.Null) {
            CmpE(op, null_pe, live_pe)
          } else {
            CmpE(op, live_pe, null_pe)
          },
          @types.Bool,
        )
      } else {
        let (lpe, ldt) = self.bind(l, space)
        let (rpe, rdt) = self.bind(r, space)
        match unify(ldt, rdt) {
          Some(dt) => {
            let lp = promote_to(lpe, ldt, dt)
            let rp = promote_to(rpe, rdt, dt)
            (CmpE(op, lp, rp), @types.Bool)
          }
          None =>
            raise @types.SqlError::Bind(
              "type mismatch in comparison: \{ldt.to_string()} vs \{rdt.to_string()}",
            )
        }
      }
    And(l, r) => {
      let (lpe, ldt) = self.bind(l, space)
      let (rpe, rdt) = self.bind(r, space)
      expect_bool(lpe, ldt, "AND")
      expect_bool(rpe, rdt, "AND")
      (AndE(lpe, rpe), @types.Bool)
    }
    Or(l, r) => {
      let (lpe, ldt) = self.bind(l, space)
      let (rpe, rdt) = self.bind(r, space)
      expect_bool(lpe, ldt, "OR")
      expect_bool(rpe, rdt, "OR")
      (OrE(lpe, rpe), @types.Bool)
    }
    Not(inner) => {
      let (pe, dt) = self.bind(inner, space)
      expect_bool(pe, dt, "NOT")
      (NotE(pe), @types.Bool)
    }
    Between(v, low, high) => {
      // desugar: v >= low AND v <= high
      let conj = @sql.And(
        @sql.Cmp(@types.Ge, v, low),
        @sql.Cmp(@types.Le, v, high),
      )
      self.bind(conj, space)
    }
    In(v, items) => {
      // desugar: v = item1 OR v = item2 ... (empty list: FALSE)
      let mut acc : @sql.LExpr = Lit(@types.Boolean(false))
      for item in items {
        acc = Or(Cmp(@types.Eq, v, item), acc)
      }
      self.bind(acc, space)
    }
    Like(v, pat) => {
      let (vpe, vdt) = self.bind(v, space)
      if vdt != @types.Str {
        raise @types.SqlError::Bind(
          "LIKE requires a string value, got \{vdt.to_string()}",
        )
      }
      let pattern : String = match pat {
        Lit(@types.Str(s)) => s
        _ =>
          raise @types.SqlError::Bind(
            "LIKE pattern must be a string literal (v1 subset)",
          )
      }
      (LikeE(vpe, pattern), @types.Bool)
    }
    NotLike(v, pat) => {
      // desugar: NOT (v LIKE pat) — three-valued NOT keeps NULL semantics
      let (pe, dt) = self.bind(Not(Like(v, pat)), space)
      (pe, dt)
    }
    NotIn(v, items) => {
      // desugar: NOT (v IN (item1, item2 ...))
      let (pe, dt) = self.bind(Not(In(v, items)), space)
      (pe, dt)
    }
    IsNull(v, negated) => {
      let (pe, _) = self.bind(v, space)
      (IsNullE(pe, negated), @types.Bool)
    }
    Star => raise @types.SqlError::Bind("'*' is only allowed as a select item")
    Extract(field, inner) => {
      let (pe, dt) = self.bind(inner, space)
      if dt != @types.Date {
        raise @types.SqlError::Bind(
          "EXTRACT requires a date value, got \{dt.to_string()}",
        )
      }
      (ExtractE(pe, field), @types.Int32)
    }
    ScalarSub(sub) => {
      let (value, dt) = self.runner.run_scalar(sub)
      (Const(value), dt)
    }
    InSelect(v, sub, negated) => {
      let (vpe, vdt) = self.bind(v, space)
      let (set, has_null, sub_dt) = self.runner.run_key_set(sub)
      if sub_dt != vdt {
        raise @types.SqlError::Bind(
          "IN subquery column type mismatch: \{vdt.to_string()} vs \{sub_dt.to_string()}",
        )
      }
      (InSetE(vpe, set, negated, has_null), @types.Bool)
    }
    Case(whens, else_) => {
      let bound : Array[(PhysExpr, @types.DataType)] = []
      let conds : Array[PhysExpr] = []
      let mut dt : @types.DataType? = None
      for when in whens {
        let (cond, cond_dt) = self.bind(when.0, space)
        expect_bool(cond, cond_dt, "CASE WHEN")
        let (res, res_dt) = self.bind(when.1, space)
        match dt {
          None => dt = Some(res_dt)
          Some(prev) =>
            dt = match unify(prev, res_dt) {
              Some(u) => Some(u)
              None =>
                raise @types.SqlError::Bind(
                  "CASE branches disagree: \{prev.to_string()} vs \{res_dt.to_string()}",
                )
            }
        }
        conds.push(cond)
        bound.push((res, res_dt))
      }
      if bound.length() == 0 {
        raise @types.SqlError::Bind("CASE requires a WHEN branch")
      }
      let out_dt = match dt {
        Some(d) => d
        None => raise @types.SqlError::Bind("CASE requires a WHEN branch")
      }
      let bound_else : (PhysExpr, @types.DataType)? = match else_ {
        Some(e) => {
          let (epe, edt) = self.bind(e, space)
          Some((epe, edt))
        }
        None => None
      }
      let out_dt = match bound_else {
        Some((_, edt)) =>
          match unify(out_dt, edt) {
            Some(u) => u
            None =>
              raise @types.SqlError::Bind(
                "CASE ELSE disagrees with branches: \{out_dt.to_string()} vs \{edt.to_string()}",
              )
          }
        None => out_dt
      }
      let whens_p : Array[(PhysExpr, PhysExpr)] = []
      for i in 0.. Some(promote_to(epe, edt, out_dt))
        None => None
      }
      (CaseE(whens_p, else_p, out_dt), out_dt)
    }
    Exists(sub, negated) => {
      let has_rows = self.runner.run_exists(sub)
      (
        Const(@types.Boolean(if negated { !has_rows } else { has_rows })),
        @types.Bool,
      )
    }
    Func("__win", [Lit(@types.Int32(k))]) =>
      // select-list window marker, valid anywhere in an item expression
      if k >= 0 && k < self.wins.length() {
        let wdt = self.wins[k].out_type()
        (WinRef(k, wdt), wdt)
      } else {
        raise @types.SqlError::Bind("internal: window marker \{k} out of range")
      }
    Func(name, args) => self.bind_func(name, args, space)
    Cast(e, dt) =>
      // CAST(NULL AS t) is a typed NULL like Cmp/Arith's NULL operands
      if e is Lit(@types.Null) {
        (Const(@types.Null), dt)
      } else {
        let (pe, src_dt) = self.bind(e, space)
        castable(src_dt, dt)
        (CastE(pe, dt), dt)
      }
    IntervalLit(_, _) =>
      raise @types.SqlError::Bind(
        "INTERVAL is only valid in date arithmetic (date +/- interval)",
      )
  }
}

///|
/// Lower `date +/- INTERVAL n unit` to DateArithE. The interval must
/// pair with exactly one date-typed operand; subtraction requires the
/// date on the left (`interval - date` has no meaning). The quantity's
/// sign folds here so the evaluator only ever adds.
fn Binder::bind_date_arith(
  self : Binder,
  op : @types.ArithOp,
  l : @sql.LExpr,
  r : @sql.LExpr,
  space : Space,
) -> (PhysExpr, @types.DataType) raise @types.SqlError {
  if op is @types.Mul || op is @types.Div || op is @types.Mod {
    raise @types.SqlError::Bind(
      "INTERVAL supports only addition and subtraction with dates",
    )
  }
  let (ivl, date_e) = match (l, r) {
    (IntervalLit(_, _), IntervalLit(_, _)) =>
      raise @types.SqlError::Bind("two INTERVALs cannot combine")
    (IntervalLit(_, _), other) => (l, other)
    (other, IntervalLit(_, _)) => (r, other)
    _ => (l, r) // unreachable: callers checked one side is IntervalLit
  }
  let (date_pe, date_dt) = self.bind(date_e, space)
  if !(date_dt is @types.Date) {
    raise @types.SqlError::Bind(
      "INTERVAL arithmetic needs a DATE operand, got \{date_dt.to_string()}",
    )
  }
  let (qty, unit) = match ivl {
    IntervalLit(q, u) => (q, u)
    _ => (0L, Days) // unreachable
  }
  let signed = if op is @types.Sub { -qty } else { qty }
  (DateArithE(date_pe, signed, unit), @types.Date)
}

///|
/// Type-check a scalar function call and lower it to FuncE. Operand
/// types must match the signature exactly (no implicit casts besides
/// the binder's numeric promotions, which do not apply to strings).
fn Binder::bind_func(
  self : Binder,
  name : String,
  args : Array[@sql.LExpr],
  space : Space,
) -> (PhysExpr, @types.DataType) raise @types.SqlError {
  let arity = args.length()
  fn want(
    e : @sql.LExpr,
    dt : @types.DataType,
    what : String,
  ) -> (PhysExpr, @types.DataType) raise @types.SqlError {
    let (pe, got) = self.bind(e, space)
    if got != dt {
      raise @types.SqlError::Bind(
        "\{name}() requires a \{what} argument, got \{got.to_string()}",
      )
    }
    (pe, got)
  }
  fn want_num(
    e : @sql.LExpr,
  ) -> (PhysExpr, @types.DataType) raise @types.SqlError {
    let (pe, got) = self.bind(e, space)
    match got {
      @types.Int32 | @types.Int64 | @types.Float64 => ()
      other =>
        raise @types.SqlError::Bind(
          "\{name}() requires a numeric argument, got \{other.to_string()}",
        )
    }
    (pe, got)
  }
  match name {
    "row_number" | "rank" | "dense_rank" =>
      // reachable only without OVER: the parser turns fn() OVER ()
      // into a window marker before binding
      raise @types.SqlError::Bind(
        "\{name}() requires an OVER clause (window functions are select-list only)",
      )
    "length" => {
      if arity != 1 {
        raise @types.SqlError::Bind("length() takes exactly one argument")
      }
      let (pe, _) = want(args[0], @types.Str, "string")
      (FuncE("length", [pe], @types.Int32), @types.Int32)
    }
    "upper" | "lower" | "trim" | "ltrim" | "rtrim" => {
      if arity != 1 {
        raise @types.SqlError::Bind("\{name}() takes exactly one argument")
      }
      let (pe, _) = want(args[0], @types.Str, "string")
      (FuncE(name, [pe], @types.Str), @types.Str)
    }
    "substring" => {
      if arity != 2 && arity != 3 {
        raise @types.SqlError::Bind(
          "substring() takes (string, start) or (string, start, length)",
        )
      }
      let (spe, _) = want(args[0], @types.Str, "string")
      let pes = [spe]
      for i in 1.. {
      if arity != 2 {
        raise @types.SqlError::Bind("concat() takes exactly two arguments")
      }
      let (l, _) = want(args[0], @types.Str, "string")
      let (r, _) = want(args[1], @types.Str, "string")
      (FuncE("concat", [l, r], @types.Str), @types.Str)
    }
    "abs" => {
      if arity != 1 {
        raise @types.SqlError::Bind("abs() takes exactly one argument")
      }
      let (pe, dt) = want_num(args[0])
      (FuncE("abs", [pe], dt), dt)
    }
    "sqrt" => {
      if arity != 1 {
        raise @types.SqlError::Bind("sqrt() takes exactly one argument")
      }
      let (pe, _) = want_num(args[0])
      (FuncE("sqrt", [pe], @types.Float64), @types.Float64)
    }
    "floor" | "ceil" => {
      if arity != 1 {
        raise @types.SqlError::Bind("\{name}() takes exactly one argument")
      }
      let (pe, _) = want_num(args[0])
      (FuncE(name, [pe], @types.Float64), @types.Float64)
    }
    "round" => {
      if arity != 1 && arity != 2 {
        raise @types.SqlError::Bind("round() takes (x) or (x, digits)")
      }
      let (pe, _) = want_num(args[0])
      let pes = [pe]
      if arity == 2 {
        let (dpe, _) = want(args[1], @types.Int32, "int32")
        pes.push(dpe)
      }
      (FuncE("round", pes, @types.Float64), @types.Float64)
    }
    "date_trunc" | "date_diff" => {
      // the unit argument must fold to a string constant
      if !(args[0] is Lit(@types.Str(_))) {
        raise @types.SqlError::Bind(
          "\{name}() unit must be a string literal ('day' | 'month' | 'year')",
        )
      }
      if name == "date_trunc" && arity != 2 {
        raise @types.SqlError::Bind("date_trunc() takes (unit, date)")
      }
      if name == "date_diff" && arity != 3 {
        raise @types.SqlError::Bind("date_diff() takes (unit, start, end)")
      }
      let (unit_pe, _) = self.bind(args[0], space)
      let pes = [unit_pe]
      for i in 1.. {
      if arity == 0 {
        raise @types.SqlError::Bind("\{name}() takes at least one argument")
      }
      // unify the argument types; NULL literals fit any target
      let pes : Array[PhysExpr] = []
      let mut dt : @types.DataType? = None
      for a in args {
        if a is Lit(@types.Null) {
          pes.push(Const(@types.Null))
          continue
        }
        let (pe, got) = self.bind(a, space)
        match dt {
          None => dt = Some(got)
          Some(cur) =>
            match unify(cur, got) {
              Some(u) => dt = Some(u)
              None =>
                raise @types.SqlError::Bind(
                  "\{name}() argument types do not unify: \{cur.to_string()} vs \{got.to_string()}",
                )
            }
        }
        pes.push(promote_to(pe, got, dt.unwrap()))
      }
      let out = dt.unwrap_or(@types.Str) // all-NULL args: type is moot
      (FuncE(name, pes, out), out)
    }
    "nullif" => {
      if arity != 2 {
        raise @types.SqlError::Bind("nullif() takes exactly two arguments")
      }
      let (pe, ldt) = self.bind(args[0], space)
      let (qe, rdt) = self.bind(args[1], space)
      let out = match unify(ldt, rdt) {
        Some(u) => u
        None =>
          raise @types.SqlError::Bind(
            "nullif() argument types do not unify: \{ldt.to_string()} vs \{rdt.to_string()}",
          )
      }
      (
        FuncE(
          "nullif",
          [promote_to(pe, ldt, out), promote_to(qe, rdt, out)],
          out,
        ),
        out,
      )
    }
    other => raise @types.SqlError::Bind("unknown function \"\{other}\"")
  }
}

///|
/// Allowed CAST paths; anything else is a bind error.
fn castable(
  src : @types.DataType,
  dst : @types.DataType,
) -> Unit raise @types.SqlError {
  let ok = if src == dst {
    true
  } else {
    match src {
      @types.Int32 | @types.Int64 | @types.Float64 =>
        dst
        is (@types.Int32
        | @types.Int64
        | @types.Float64
        | @types.Str
        | @types.Bool)
      @types.Bool =>
        dst is (@types.Str | @types.Int32 | @types.Int64 | @types.Float64)
      @types.Str =>
        dst
        is (@types.Int32
        | @types.Int64
        | @types.Float64
        | @types.Bool
        | @types.Date
        | @types.Str)
      @types.Date => dst is (@types.Str | @types.Int32)
    }
  }
  if !ok {
    raise @types.SqlError::Bind(
      "cannot cast \{src.to_string()} to \{dst.to_string()}",
    )
  }
}

///|
fn expect_bool(
  _pe : PhysExpr,
  dt : @types.DataType,
  context : String,
) -> Unit raise @types.SqlError {
  if dt != @types.Bool {
    raise @types.SqlError::Bind(
      "\{context} requires bool operands, got \{dt.to_string()}",
    )
  }
}

///|
fn scalar_type(s : @types.Scalar) -> @types.DataType {
  match s {
    @types.Null => @types.DataType::Str // unreachable: NULL literal is rejected first
    @types.Boolean(_) => @types.Bool
    @types.Int32(_) => @types.Int32
    @types.Int64(_) => @types.Int64
    @types.Float64(_) => @types.Float64
    @types.Str(_) => @types.Str
    @types.Date(_) => @types.Date
  }
}

///|
/// Numeric type unification: same type wins, int32 widens to int64 then
/// to float64. Non-numeric mixes (and bool) do not unify, except equal
/// date/string/bool pairs which return themselves for comparison.
fn unify(l : @types.DataType, r : @types.DataType) -> @types.DataType? {
  if l == r {
    return Some(l)
  }
  match (l, r) {
    (@types.Int32, @types.Int64) | (@types.Int64, @types.Int32) =>
      Some(@types.Int64)
    (@types.Int32, @types.Float64) | (@types.Float64, @types.Int32) =>
      Some(@types.Float64)
    (@types.Int64, @types.Float64) | (@types.Float64, @types.Int64) =>
      Some(@types.Float64)
    _ => None
  }
}

///|
/// Result type of an arithmetic node. `/` is true division per DuckDB:
/// numeric operands always yield DOUBLE (zero divisor becomes IEEE
/// inf/nan at evaluation). Non-numeric operand types (Date) keep the
/// unified type and stay on the evaluator's NULL-propagation path.
fn arith_output(op : @types.ArithOp, dt : @types.DataType) -> @types.DataType {
  if op is @types.Div && dt is (@types.Int32 | @types.Int64 | @types.Float64) {
    @types.Float64
  } else {
    dt
  }
}

///|
/// Wrap an expression in a Promote node when its type differs from the
/// unified target (numeric widening only).
fn promote_to(
  pe : PhysExpr,
  from : @types.DataType,
  to : @types.DataType,
) -> PhysExpr {
  if from == to {
    pe
  } else {
    Promote(pe, to)
  }
}