packages feed

nova-nix-0.3.0.0: src/Nix/Eval.hs

{-# LANGUAGE LambdaCase #-}

-- | Nix expression evaluator.
--
-- Nix evaluation is LAZY.  Attribute set members and list elements
-- are stored as thunks and only forced when their value is demanded.
-- Function arguments are likewise thunked — @(x: 1) (throw "boom")@
-- returns @1@ because @x@ is never referenced.
--
-- The evaluator maintains an environment ('Env') that maps variable
-- names to thunks.  @let@, @with@, function application, and
-- recursive attribute sets all extend the environment.
module Nix.Eval
  ( -- * Values (re-exported from Types)
    NixValue (..),
    CompiledRegex (..),
    Thunk (..),

    -- * Attribute sets (re-exported from Types)
    AttrSet (..),
    CAttrSet,
    attrSetFromMap,
    attrSetLookup,
    attrSetKeys,
    attrSetToMap,
    attrSetToAscList,
    attrSetMember,
    attrSetElems,
    attrSetNull,
    attrSetRemoveKeys,
    attrSetSize,

    -- * String context (re-exported from Types)
    StringContextElement (..),
    StringContext (..),
    emptyContext,
    mkStr,

    -- * Environment (re-exported from Types)
    Env (..),
    emptyEnv,

    -- * Evaluation monad (re-exported from Types)
    MonadEval (..),
    PureEval (..),

    -- * Evaluation
    eval,
    evalBytecode,
    force,

    -- * Helpers (for Builtins)
    typeName,
    evaluated,
    readThunkValue,

    -- * Builtin registry
    BuiltinDef (..),
    builtinRegistry,
    builtinNames,

    -- * Platform
    currentSystemStr,
  )
where

import Control.Monad (foldM, when, (>=>))
import qualified Crypto.Hash as CH
import qualified Data.Array as Array
import Data.Bits (complement, xor, (.&.), (.|.))
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import Data.Char (chr, digitToInt, isAlpha, isDigit, isHexDigit, isOctDigit, ord)
import Data.IORef (IORef, atomicModifyIORef', newIORef)
import Data.Int (Int64)
import Data.List (find, foldl', sort)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, maybeToList)
import Data.Sequence (Seq (..))
import qualified Data.Sequence as Seq
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Word (Word32, Word8)
import Foreign.Ptr (Ptr, castPtr, nullPtr, ptrToWordPtr, wordPtrToPtr)
import Foreign.Storable (peekElemOff, pokeElemOff)
import Nix.Derivation (Derivation (..), DerivationOutput (..), textToPlatform, toATerm, toATermForHash)
import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcArg3, cbcData, cbcFlags, cbcOpcode, cbcShortArg)
import Nix.Eval.CEnv (cenvPushWith)
import Nix.Eval.CList (CList (..), clistGet)
import Nix.Eval.CThunk (CThunkPtr)
import Nix.Eval.Compile (BcAttrKey (..), BcBinding (..), compileExpr, decodeBcBindings, decodeBcCaptureInfo, decodeBcFormals, reassembleDouble, reassembleInt64)
import Nix.Eval.Context (extractInputDrvs, extractInputSrcs, plainContext)
import Nix.Eval.Operator (evalBinary, evalUnary, nixCompare, nixEqual)
import Nix.Eval.StringInterp (coerceToString, formatNixFloat, stripIndentedChunks)
import Nix.Eval.Symbol (Symbol (..), symbolText)
import Nix.Eval.Types
  ( AttrSet (..),
    CAttrSet,
    CompiledRegex (..),
    Env (..),
    EvalFormal (..),
    EvalFormals (..),
    MonadEval (..),
    NixValue (..),
    PureEval (..),
    StringContext (..),
    StringContextElement (..),
    Thunk (..),
    allocCSlots,
    attrSetElems,
    attrSetFromMap,
    attrSetKeys,
    attrSetLookup,
    attrSetMapWithKey,
    attrSetMember,
    attrSetNull,
    attrSetRemoveKeys,
    attrSetSize,
    attrSetToAscList,
    attrSetToMap,
    attrSetUnionWith,
    buildCAttrSetKeys,
    buildCSlots,
    cheapThunkBc,
    clistFromThunks,
    clistLen,
    clistThunks,
    emptyContext,
    emptyEnv,
    envFromSlots,
    envLookup,
    envLookupResolved,
    envWithScopesRaw,
    evaluated,
    fillCAttrSetValues,
    fillCSlots,
    mkStr,
    mkSyntheticThunk,
    mkThunk,
    mkThunkBc,
    newCEnv,
    newMinimalEnv,
    readThunkValue,
    thunkToCPtr,
    typeName,
    withScopesForCapture,
  )
import Nix.Expr.Types
  ( AttrKey (..),
    BinaryOp (..),
    CaptureInfo (..),
    Expr (..),
    NixAtom (..),
    UnaryOp (..),
  )
import Nix.Hash (byteToHex, hashPlaceholder, makeFixedOutputPath, makeOutputPath, makeTextPath, sha256Digest, sha256Hex)
import Nix.Store.Path (StorePath (..), defaultStoreDir, defaultStoreDirText, parseStorePath, storePathToFilePath, storePathToText)
import qualified NovaCache.Base32 as Nix32
import qualified NovaCache.Base64 as B64
import System.IO.Unsafe (unsafePerformIO)
import qualified System.Info
import Text.Regex.TDFA (matchAllText)
import qualified Text.Regex.TDFA as RE

-- | Evaluate a Nix expression in an environment.
-- Compiles the expression to bytecode and dispatches to 'evalBytecode'.
eval :: (MonadEval m) => Env -> Expr -> m NixValue
eval env expr =
  let bcIdx = unsafePerformIO (compileExpr expr)
   in evalBytecode env bcIdx

-- | Force a thunk to a value.
--
-- Delegates to 'forceThunk' which is a 'MonadEval' method — this
-- allows IO evaluators to implement memoization (caching the result
-- after the first force) while pure evaluators simply re-evaluate.
force :: (MonadEval m) => Thunk -> m NixValue
force = forceThunk evalBytecode

-- | Evaluate a bytecode instruction by index.
-- This is the primary evaluator — reads opcodes from the C bytecode
-- store and dispatches.  All recursive evaluation goes through this
-- function, never through 'eval' directly.
evalBytecode :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBytecode env bcIdx =
  let opcode = unsafePerformIO (cbcOpcode bcIdx)
   in case opcode of
        0 {- LIT_INT -} ->
          let lo = unsafePerformIO (cbcArg1 bcIdx)
              hi = unsafePerformIO (cbcArg2 bcIdx)
           in pure (VInt (reassembleInt64 lo hi))
        1 {- LIT_FLOAT -} ->
          let lo = unsafePerformIO (cbcArg1 bcIdx)
              hi = unsafePerformIO (cbcArg2 bcIdx)
           in pure (VFloat (reassembleDouble lo hi))
        2 {- LIT_BOOL -} ->
          let flag = unsafePerformIO (cbcShortArg bcIdx)
           in pure (VBool (flag /= 0))
        3 {- LIT_NULL -} -> pure VNull
        4 {- LIT_URI -} ->
          let sym = unsafePerformIO (cbcArg1 bcIdx)
           in pure (mkStr (symbolText (Symbol sym)))
        5 {- LIT_PATH -} ->
          let sym = unsafePerformIO (cbcArg1 bcIdx)
           in VPath <$> resolvePathLiteral (symbolText (Symbol sym))
        6 {- STR -} -> evalBcStr env bcIdx
        7 {- IND_STR -} -> evalBcIndStr env bcIdx
        8 {- VAR -} ->
          let sym = unsafePerformIO (cbcArg1 bcIdx)
           in evalVar env (symbolText (Symbol sym))
        9 {- WITH_VAR -} ->
          let sym = unsafePerformIO (cbcArg1 bcIdx)
              name = symbolText (Symbol sym)
           in evalWithVar env name
        10 {- RESOLVED_VAR -} ->
          let level = fromIntegral (unsafePerformIO (cbcArg1 bcIdx))
              idx = fromIntegral (unsafePerformIO (cbcArg2 bcIdx))
           in force (envLookupResolved level idx env)
        11 {- ATTRS -} -> evalBcAttrs env bcIdx
        12 {- LIST -} -> evalBcList env bcIdx
        13 {- SELECT -} -> evalBcSelect env bcIdx
        14 {- HAS_ATTR -} -> evalBcHasAttr env bcIdx
        15 {- APP -} -> evalBcApp env bcIdx
        16 {- LAMBDA -} -> evalBcLambda env bcIdx
        17 {- LET -} -> evalBcLet env bcIdx
        18 {- IF -} ->
          let condIdx = unsafePerformIO (cbcArg1 bcIdx)
              thenIdx = unsafePerformIO (cbcArg2 bcIdx)
              elseIdx = unsafePerformIO (cbcArg3 bcIdx)
           in do
                condVal <- evalBytecode env condIdx
                case condVal of
                  VBool True -> evalBytecode env thenIdx
                  VBool False -> evalBytecode env elseIdx
                  _ -> throwEvalError ("'if' condition must be a Boolean, got " <> typeName condVal)
        19 {- WITH -} ->
          let scopeIdx = unsafePerformIO (cbcArg1 bcIdx)
              bodyIdx = unsafePerformIO (cbcArg2 bcIdx)
              -- Lazy with: defer forcing the scope until a WITH_VAR lookup
              -- actually needs it.  This is critical for nixpkgs where
              -- all-packages.nix uses `with pkgs;` inside a fixpoint —
              -- eagerly forcing the scope would blackhole.
              scopeThunk = cheapThunkBc env scopeIdx
           in evalBytecode (pushLazyWithScope scopeThunk env) bodyIdx
        20 {- ASSERT -} ->
          let condIdx = unsafePerformIO (cbcArg1 bcIdx)
              bodyIdx = unsafePerformIO (cbcArg2 bcIdx)
           in do
                condVal <- evalBytecode env condIdx
                case condVal of
                  VBool True -> evalBytecode env bodyIdx
                  VBool False -> throwEvalError "assertion failed"
                  _ -> throwEvalError ("assertion condition must be a Boolean, got " <> typeName condVal)
        21 {- UNARY -} ->
          let flags_ = unsafePerformIO (cbcFlags bcIdx)
              operandIdx = unsafePerformIO (cbcArg1 bcIdx)
           in do
                val <- evalBytecode env operandIdx
                evalUnary (decodeUnaryOp flags_) val
        22 {- BINARY -} -> evalBcBinary env bcIdx
        23 {- SEARCH_PATH -} ->
          let sym = unsafePerformIO (cbcArg1 bcIdx)
           in evalSearchPath env (symbolText (Symbol sym))
        _ -> throwEvalError "evalBytecode: unknown opcode"

-- ---------------------------------------------------------------------------
-- Bytecode helpers
-- ---------------------------------------------------------------------------

-- | Decode a UnaryOp from bytecode flags.
decodeUnaryOp :: Word8 -> UnaryOp
decodeUnaryOp 0 = OpNot
decodeUnaryOp 1 = OpNegate
decodeUnaryOp n = error ("decodeUnaryOp: unknown tag " <> show n)

-- | Decode a BinaryOp from bytecode flags.
decodeBinaryOp :: Word8 -> BinaryOp
decodeBinaryOp 0 = OpAdd
decodeBinaryOp 1 = OpSub
decodeBinaryOp 2 = OpMul
decodeBinaryOp 3 = OpDiv
decodeBinaryOp 4 = OpAnd
decodeBinaryOp 5 = OpOr
decodeBinaryOp 6 = OpImpl
decodeBinaryOp 7 = OpEq
decodeBinaryOp 8 = OpNeq
decodeBinaryOp 9 = OpLt
decodeBinaryOp 10 = OpLte
decodeBinaryOp 11 = OpGt
decodeBinaryOp 12 = OpGte
decodeBinaryOp 13 = OpConcat
decodeBinaryOp 14 = OpUpdate
decodeBinaryOp n = error ("decodeBinaryOp: unknown tag " <> show n)

-- | Evaluate a binary operation from bytecode, with short-circuit
-- support for &&, ||, ->.
evalBcBinary :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcBinary env bcIdx0 =
  let flags_ = unsafePerformIO (cbcFlags bcIdx0)
      leftIdx = unsafePerformIO (cbcArg1 bcIdx0)
      rightIdx = unsafePerformIO (cbcArg2 bcIdx0)
      op = decodeBinaryOp flags_
   in case op of
        OpAnd -> evalShortCircuitAnd env leftIdx rightIdx
        OpOr -> evalShortCircuitOr env leftIdx rightIdx
        OpImpl -> evalShortCircuitImpl env leftIdx rightIdx
        OpAdd -> do
          leftVal <- evalBytecode env leftIdx
          rightVal <- evalBytecode env rightIdx
          evalAddWithCoercion leftVal rightVal
        _ -> do
          leftVal <- evalBytecode env leftIdx
          rightVal <- evalBytecode env rightIdx
          evalBinary force op leftVal rightVal

-- | Addition with string coercion fallback, matching C++ Nix behavior.
-- C++ Nix's ExprOpAdd falls through to concatStrings when operands
-- are not both numeric and neither is a path.  concatStrings calls
-- coerceToString on each part, which handles attrsets via outPath.
evalAddWithCoercion :: (MonadEval m) => NixValue -> NixValue -> m NixValue
evalAddWithCoercion left right = case (left, right) of
  -- Numeric: direct arithmetic (matches C++ Nix priority)
  (VFloat _, _) -> evalBinary force OpAdd left right
  (_, VFloat _) -> evalBinary force OpAdd left right
  (VInt _, VInt _) -> evalBinary force OpAdd left right
  -- Path: direct concat
  (VPath _, _) -> evalBinary force OpAdd left right
  -- String-like: direct concat when both are string/path
  (VStr {}, VStr {}) -> evalBinary force OpAdd left right
  (VStr {}, VPath _) -> evalBinary force OpAdd left right
  -- Otherwise: coerce both to strings via concatStrings (matching C++ Nix)
  _ -> do
    (leftStr, leftCtx) <- coerceToString force applyValue left
    (rightStr, rightCtx) <- coerceToString force applyValue right
    pure (VStr (leftStr <> rightStr) (leftCtx <> rightCtx))

-- | Bytecode short-circuit &&
evalShortCircuitAnd :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue
evalShortCircuitAnd env leftIdx rightIdx = do
  leftVal <- evalBytecode env leftIdx
  case leftVal of
    VBool False -> pure (VBool False)
    VBool True -> do
      rightVal <- evalBytecode env rightIdx
      case rightVal of
        VBool _ -> pure rightVal
        _ -> throwEvalError ("second operand of && must be a Boolean, got " <> typeName rightVal)
    _ -> throwEvalError ("first operand of && must be a Boolean, got " <> typeName leftVal)

-- | Bytecode short-circuit ||
evalShortCircuitOr :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue
evalShortCircuitOr env leftIdx rightIdx = do
  leftVal <- evalBytecode env leftIdx
  case leftVal of
    VBool True -> pure (VBool True)
    VBool False -> do
      rightVal <- evalBytecode env rightIdx
      case rightVal of
        VBool _ -> pure rightVal
        _ -> throwEvalError ("second operand of || must be a Boolean, got " <> typeName rightVal)
    _ -> throwEvalError ("first operand of || must be a Boolean, got " <> typeName leftVal)

-- | Bytecode short-circuit ->
evalShortCircuitImpl :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue
evalShortCircuitImpl env leftIdx rightIdx = do
  leftVal <- evalBytecode env leftIdx
  case leftVal of
    VBool False -> pure (VBool True)
    VBool True -> do
      rightVal <- evalBytecode env rightIdx
      case rightVal of
        VBool _ -> pure rightVal
        _ -> throwEvalError ("second operand of -> must be a Boolean, got " <> typeName rightVal)
    _ -> throwEvalError ("first operand of -> must be a Boolean, got " <> typeName leftVal)

-- | Evaluate a string literal from bytecode data buffer.
evalBcStr :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcStr env bcIdx0 = do
  let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))
      dataOff = unsafePerformIO (cbcArg1 bcIdx0)
  chunks <- evalBcStringParts env count dataOff
  pure (VStr (T.concat [t | (_, t, _) <- chunks]) (mconcat [c | (_, _, c) <- chunks]))

-- | Evaluate an indented string literal from bytecode data buffer.  The common
-- indentation is stripped from the LITERAL chunks before concatenation, so an
-- interpolated multi-line value cannot drag the computed indent down — matching
-- C++ Nix.
evalBcIndStr :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcIndStr env bcIdx0 = do
  let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))
      dataOff = unsafePerformIO (cbcArg1 bcIdx0)
  chunks <- evalBcStringParts env count dataOff
  let (text, ctx) = stripIndentedChunks chunks
  pure (VStr text ctx)

-- | Evaluate string parts from the bytecode data buffer.  Each part is two
-- words: (tag, value).  tag=0 -> literal (value = symbol), tag=1 ->
-- interpolation (value = bc_idx).  The 'Bool' marks literal (@True@) vs
-- interpolated (@False@) so indented strings strip only the literals.
evalBcStringParts :: (MonadEval m) => Env -> Int -> Word32 -> m [(Bool, Text, StringContext)]
evalBcStringParts _ 0 _ = pure []
evalBcStringParts env n off = do
  let tag = unsafePerformIO (cbcData off)
      val = unsafePerformIO (cbcData (off + 1))
  chunk <- case tag of
    0 -> pure (True, symbolText (Symbol val), emptyContext)
    _ -> do
      v <- evalBytecode env val
      (txt, ctx) <- coerceToStringInterp v
      pure (False, txt, ctx)
  rest <- evalBcStringParts env (n - 1) (off + 2)
  pure (chunk : rest)

-- | Evaluate a list from bytecode data buffer.
evalBcList :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcList env bcIdx0 =
  let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0)) :: Int
      dataOff = unsafePerformIO (cbcArg1 bcIdx0)
      readChildren 0 _ = []
      readChildren n off =
        let childIdx = unsafePerformIO (cbcData off)
         in cheapThunkBc env childIdx : readChildren (n - 1) (off + 1)
   in pure (VList (clistFromThunks (map thunkToCPtr (readChildren count dataOff))))

-- | Evaluate a function application from bytecode.
evalBcApp :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcApp env bcIdx0 = do
  let funcIdx = unsafePerformIO (cbcArg1 bcIdx0)
      argIdx = unsafePerformIO (cbcArg2 bcIdx0)
  funcVal <- evalBytecode env funcIdx
  case funcVal of
    VLambda closureEnv formals bodyBcIdx -> do
      let argThunk = cheapThunkBc env argIdx
      extEnv <- matchFormals closureEnv formals argThunk
      evalBytecode extEnv bodyBcIdx
    VBuiltin "tryEval" [] -> do
      result <- catchEvalError (evalBytecode env argIdx)
      case result of
        Right val ->
          pure
            ( VAttrs
                ( attrSetFromMap $
                    Map.fromList
                      [ ("success", evaluated (VBool True)),
                        ("value", evaluated val)
                      ]
                )
            )
        Left _ ->
          pure
            ( VAttrs
                ( attrSetFromMap $
                    Map.fromList
                      [ ("success", evaluated (VBool False)),
                        ("value", evaluated (VBool False))
                      ]
                )
            )
    VBuiltin name accArgs -> do
      argVal <- evalBytecode env argIdx
      applyBuiltin name accArgs argVal
    VAttrs attrs
      | Just functorThunk <- attrSetLookup "__functor" attrs -> do
          functor <- force functorThunk
          partiallyApplied <- applyValue functor funcVal
          -- Maintain laziness: thunk the argument for lambdas, like
          -- the normal VLambda path above.  Builtins force anyway.
          case partiallyApplied of
            VLambda closureEnv formals bodyBcIdx -> do
              let argThunk = cheapThunkBc env argIdx
              extEnv <- matchFormals closureEnv formals argThunk
              evalBytecode extEnv bodyBcIdx
            _ -> do
              argVal <- evalBytecode env argIdx
              applyValue partiallyApplied argVal
    _ -> throwEvalError ("attempt to call " <> typeName funcVal <> ", which is not a function")

-- | Evaluate a lambda literal from bytecode — returns VLambda.
evalBcLambda :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcLambda env bcIdx0 =
  let flags_ = unsafePerformIO (cbcFlags bcIdx0)
      formalsOff = unsafePerformIO (cbcArg1 bcIdx0)
      bodyBcIdx = unsafePerformIO (cbcArg2 bcIdx0)
      captureOff = unsafePerformIO (cbcArg3 bcIdx0)
      formals = unsafePerformIO (decodeBcFormals flags_ formalsOff)
      captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)
   in pure (VLambda (buildCaptureEnv env captureInfo) formals bodyBcIdx)

-- | Evaluate a select expression from bytecode.
evalBcSelect :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcSelect env bcIdx0 = do
  let hasDef = unsafePerformIO (cbcFlags bcIdx0) /= 0
      pathLen = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))
      targetIdx = unsafePerformIO (cbcArg1 bcIdx0)
      pathOff = unsafePerformIO (cbcArg2 bcIdx0)
      defIdx = unsafePerformIO (cbcArg3 bcIdx0)
  targetVal <- evalBytecode env targetIdx
  result <- walkBcAttrPath env pathLen pathOff targetVal
  case result of
    Just val -> pure val
    Nothing
      | hasDef -> evalBytecode env defIdx
      | otherwise -> do
          let pathName = collectBcAttrPathNames pathLen pathOff
              targetKeys = case targetVal of
                VAttrs attrs -> T.intercalate ", " (take 20 (attrSetKeys attrs))
                _ -> ""
          throwEvalError ("attribute '" <> pathName <> "' not found in " <> typeName targetVal <> " {" <> targetKeys <> "}")

-- | Evaluate a hasAttr expression from bytecode.
evalBcHasAttr :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcHasAttr env bcIdx0 = do
  let pathLen = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))
      targetIdx = unsafePerformIO (cbcArg1 bcIdx0)
      pathOff = unsafePerformIO (cbcArg2 bcIdx0)
  targetVal <- evalBytecode env targetIdx
  result <- walkBcAttrPath env pathLen pathOff targetVal
  pure (VBool (isJust result))

-- | Collect static attribute path names for error reporting.
collectBcAttrPathNames :: Int -> Word32 -> Text
collectBcAttrPathNames pathLen pathOff = T.intercalate "." (go pathLen pathOff)
  where
    go 0 _ = []
    go n off =
      let isExpr = unsafePerformIO (cbcData off)
          keyVal = unsafePerformIO (cbcData (off + 1))
          name = if isExpr /= 0 then "<expr>" else symbolText (Symbol keyVal)
       in name : go (n - 1) (off + 2)

-- | Walk an attribute path stored in the bytecode data buffer.
-- Each element is two words: (is_expr, key_or_bc_idx).
walkBcAttrPath :: (MonadEval m) => Env -> Int -> Word32 -> NixValue -> m (Maybe NixValue)
walkBcAttrPath _ 0 _ val = pure (Just val)
walkBcAttrPath env n off val = case val of
  VAttrs attrs -> do
    let isExpr = unsafePerformIO (cbcData off)
        keyVal = unsafePerformIO (cbcData (off + 1))
    resolved <-
      if isExpr /= 0
        then do
          keyResult <- evalBytecode env keyVal
          case keyResult of
            VStr s _ -> pure (Just s)
            VNull -> pure Nothing
            _ -> throwEvalError ("dynamic attribute key must be a string, got " <> typeName keyResult)
        else pure (Just (symbolText (Symbol keyVal)))
    case resolved >>= (`attrSetLookup` attrs) of
      Just thunk -> do
        inner <- force thunk
        walkBcAttrPath env (n - 1) (off + 2) inner
      Nothing -> pure Nothing
  -- Non-attrset: attribute path cannot continue.  Return Nothing so
  -- that callers with a default (``a.b or def'') or hasAttr (``a ? b'')
  -- can handle it gracefully, matching C++ Nix behaviour.
  _ -> pure Nothing

-- | Evaluate attribute set from bytecode.
evalBcAttrs :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcAttrs env bcIdx0 = do
  let isRec = unsafePerformIO (cbcFlags bcIdx0) /= 0
      bindCount = unsafePerformIO (cbcShortArg bcIdx0)
      dataOff = unsafePerformIO (cbcArg1 bcIdx0)
      captureOff = unsafePerformIO (cbcArg2 bcIdx0)
      bindings = unsafePerformIO (decodeBcBindings bindCount dataOff)
  if isRec
    then do
      let captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)
      evalBcRecAttrs env bindings captureInfo
    else evalBcNonRecAttrs env bindings

-- | Evaluate a non-recursive attr set from bytecode bindings.
evalBcNonRecAttrs :: (MonadEval m) => Env -> [BcBinding] -> m NixValue
evalBcNonRecAttrs env bindings = do
  thunkMap <- buildBcThunkMap env bindings
  pure (VAttrs (attrSetFromMap thunkMap))

-- | Evaluate a recursive attr set from bytecode bindings.
evalBcRecAttrs :: (MonadEval m) => Env -> [BcBinding] -> CaptureInfo -> m NixValue
evalBcRecAttrs env bindings captureInfo
  | allBcPositional bindings =
      -- Positional path: slots for variable lookup, CAttrSet for return.
      let slotCount = bcBindingSlotCount bindings
          slotsPtr = allocCSlots slotCount
          parentEnv = buildCaptureEnv env captureInfo
          (withArr, withCount) = case captureInfo of
            NoCaptureInfo -> envWithScopesRaw env
            Captures _ -> (nullPtr, 0)
            CapturesWithScopes _ -> withScopesForCapture env
          recEnv = newCEnv slotsPtr slotCount Nothing (Just parentEnv) withArr withCount
          thunkList = buildBcSlotThunks recEnv env bindings
          filled = fillCSlots slotsPtr thunkList
          attrMap = buildBcAttrMapFromSlots bindings thunkList
       in filled `seq` pure (VAttrs (attrSetFromMap attrMap))
  | otherwise = do
      -- Fallback: dynamic/nested keys — two-phase CAttrSet.
      allKeys <- bcBindingAllKeys env bindings
      let cset = buildCAttrSetKeys allKeys
          attrSet = AttrSet cset
          parentEnv = buildCaptureEnv env captureInfo
          (withArr, withCount) = case captureInfo of
            NoCaptureInfo -> envWithScopesRaw env
            Captures _ -> (nullPtr, 0)
            CapturesWithScopes _ -> withScopesForCapture env
          recEnv = newCEnv nullPtr 0 (Just attrSet) (Just parentEnv) withArr withCount
      thunkMap <- buildBcThunkMap recEnv bindings
      let filled = fillCAttrSetValues cset thunkMap
       in filled `seq` pure (VAttrs attrSet)

-- | Evaluate a let expression from bytecode.
evalBcLet :: (MonadEval m) => Env -> Word32 -> m NixValue
evalBcLet env bcIdx0 = do
  let bindCount = unsafePerformIO (cbcShortArg bcIdx0)
      dataOff = unsafePerformIO (cbcArg1 bcIdx0)
      bodyIdx = unsafePerformIO (cbcArg2 bcIdx0)
      captureOff = unsafePerformIO (cbcArg3 bcIdx0)
      bindings = unsafePerformIO (decodeBcBindings bindCount dataOff)
      captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)
  if allBcPositional bindings
    then do
      -- Positional path: slots for O(1) lookup.
      let slotCount = bcBindingSlotCount bindings
          slotsPtr = allocCSlots slotCount
          parentEnv = buildCaptureEnv env captureInfo
          (withArr, withCount) = case captureInfo of
            NoCaptureInfo -> envWithScopesRaw env
            Captures _ -> (nullPtr, 0)
            CapturesWithScopes _ -> withScopesForCapture env
          letEnv = newCEnv slotsPtr slotCount Nothing (Just parentEnv) withArr withCount
          filled = fillCSlots slotsPtr (buildBcSlotThunks letEnv env bindings)
       in filled `seq` evalBytecode letEnv bodyIdx
    else do
      -- Fallback: dynamic/nested keys — two-phase CAttrSet.
      allKeys <- bcBindingAllKeys env bindings
      let cset = buildCAttrSetKeys allKeys
          parentEnv = buildCaptureEnv env captureInfo
          (withArr, withCount) = case captureInfo of
            NoCaptureInfo -> envWithScopesRaw env
            Captures _ -> (nullPtr, 0)
            CapturesWithScopes _ -> withScopesForCapture env
          letEnv = newCEnv nullPtr 0 (Just (AttrSet cset)) (Just parentEnv) withArr withCount
      thunkMap <- buildBcThunkMap letEnv bindings
      let filled = fillCAttrSetValues cset thunkMap
       in filled `seq` evalBytecode letEnv bodyIdx

-- | Check if all bytecode bindings are single static keys (eligible for positional).
-- Must stay in sync with 'allStaticSingleKey' in 'Nix.Expr.Resolve'.
allBcPositional :: [BcBinding] -> Bool
allBcPositional = all isEligible
  where
    isEligible (BcNamed [BcStaticKey _] _) = True
    isEligible (BcInherit _) = True
    isEligible (BcInheritFrom _ _) = True
    isEligible _ = False

-- | Count positional slots for bytecode bindings.
-- Must stay in sync with 'lexicalScopeFromBindings' in 'Nix.Expr.Resolve'.
bcBindingSlotCount :: [BcBinding] -> Int
bcBindingSlotCount = foldl' countOne 0
  where
    countOne !acc (BcNamed [BcStaticKey _] _) = acc + 1
    countOne !acc (BcInherit syms) = acc + length syms
    countOne !acc (BcInheritFrom _ syms) = acc + length syms
    countOne !acc _ = acc

-- | Build thunks for positional bytecode bindings in declaration order.
buildBcSlotThunks :: Env -> Env -> [BcBinding] -> [Thunk]
buildBcSlotThunks recEnv outerEnv = concatMap slotThunk
  where
    slotThunk (BcNamed [BcStaticKey _] valBcIdx) =
      [mkThunkBc recEnv valBcIdx]
    slotThunk (BcInherit syms) =
      map (inheritLookup outerEnv . symbolText . Symbol) syms
    slotThunk (BcInheritFrom fromBcIdx syms) =
      -- inherit (from) x y z; → one thunk per name that selects from the from-expr.
      -- Each thunk gets a minimal env with the from-value at slot 0.
      let fromThunk = mkThunkBc recEnv fromBcIdx
          mkInheritThunk sym =
            let name = symbolText (Symbol sym)
                selectExpr = ESelect (EResolvedVar 0 0) [StaticKey name] Nothing
                (sp, sc) = buildCSlots [fromThunk]
                fromEnv = newMinimalEnv sp sc
             in mkSyntheticThunk fromEnv selectExpr
       in map mkInheritThunk syms
    -- Unreachable: allBcPositional guards this path.
    slotThunk _ = []

-- | Build attr map from slots for positional bytecode bindings.
buildBcAttrMapFromSlots :: [BcBinding] -> [Thunk] -> Map Text Thunk
buildBcAttrMapFromSlots bindings thunks = go bindings thunks Map.empty
  where
    go [] _ !acc = acc
    go (BcNamed [BcStaticKey sym] _ : bs) (t : ts) !acc =
      go bs ts (Map.insert (symbolText (Symbol sym)) t acc)
    go (BcInherit syms : bs) ts !acc =
      let (used, rest) = splitAt (length syms) ts
          accMerged = foldl' (\a (sym, t0) -> Map.insert (symbolText (Symbol sym)) t0 a) acc (zip syms used)
       in go bs rest accMerged
    go (BcInheritFrom _ syms : bs) ts !acc =
      let (used, rest) = splitAt (length syms) ts
          accMerged = foldl' (\a (sym, t0) -> Map.insert (symbolText (Symbol sym)) t0 a) acc (zip syms used)
       in go bs rest accMerged
    -- Unreachable: allBcPositional guards this path.
    go (_ : bs) ts !acc = go bs ts acc

-- | Build thunk map for bytecode attrs (non-rec or fallback rec path).
buildBcThunkMap :: (MonadEval m) => Env -> [BcBinding] -> m (Map Text Thunk)
buildBcThunkMap thunkEnv = foldM addBinding Map.empty
  where
    addBinding acc (BcNamed keys valBcIdx) = do
      resolvedKeys <- mapM (resolveBcKey thunkEnv) keys
      case sequence resolvedKeys of
        Nothing -> pure acc -- null key -> skip
        Just [key] ->
          pure (insertWithMerge acc key (mkThunkBc thunkEnv valBcIdx))
        Just path ->
          let nested = buildBcNestedAttr thunkEnv path valBcIdx
           in pure (foldl' (\a (k, t0) -> insertWithMerge a k t0) acc (Map.toList nested))
    addBinding acc (BcInherit syms) =
      pure (foldl' (\a sym -> let name = symbolText (Symbol sym) in insertWithMerge a name (inheritLookup thunkEnv name)) acc syms)
    addBinding acc (BcInheritFrom fromBcIdx syms) =
      -- inherit (from) name -> select name from the from-expr.
      -- Create a small env with the from value at slot 0, then a
      -- synthetic expression that selects name from slot 0.
      let addInheritFrom a sym =
            let name = symbolText (Symbol sym)
                selectExpr = ESelect (EResolvedVar 0 0) [StaticKey name] Nothing
                (sp, sc) = buildCSlots [mkThunkBc thunkEnv fromBcIdx]
                fromEnv = newMinimalEnv sp sc
             in insertWithMerge a name (mkSyntheticThunk fromEnv selectExpr)
       in pure (foldl' addInheritFrom acc syms)

    insertWithMerge acc key thunk =
      case Map.lookup key acc of
        Nothing -> Map.insert key thunk acc
        Just existing -> Map.insert key (mergeThunks existing thunk) acc

-- | Resolve a bytecode attr key to text.
resolveBcKey :: (MonadEval m) => Env -> BcAttrKey -> m (Maybe Text)
resolveBcKey _env (BcStaticKey sym) = pure (Just (symbolText (Symbol sym)))
resolveBcKey env (BcDynamicKey bcIdx0) = do
  val <- evalBytecode env bcIdx0
  case val of
    VStr s _ -> pure (Just s)
    VNull -> pure Nothing
    _ -> throwEvalError ("dynamic attribute key must be a string, got " <> typeName val)

-- | Build a nested attribute structure from a resolved dotted path (bytecode).
buildBcNestedAttr :: Env -> [Text] -> Word32 -> Map Text Thunk
buildBcNestedAttr _thunkEnv [] _valBcIdx = Map.empty
buildBcNestedAttr thunkEnv [key] valBcIdx =
  Map.singleton key (mkThunkBc thunkEnv valBcIdx)
buildBcNestedAttr thunkEnv (key : rest) valBcIdx =
  Map.singleton key (evaluated (VAttrs (attrSetFromMap (buildBcNestedAttr thunkEnv rest valBcIdx))))

-- | Extract all top-level keys from bytecode bindings, resolving
-- dynamic keys as needed.  Used by the fallback path (two-phase
-- CAttrSet construction) where all keys must be known before thunks.
bcBindingAllKeys :: (MonadEval m) => Env -> [BcBinding] -> m [Text]
bcBindingAllKeys env bindings = fmap concat (mapM oneBinding bindings)
  where
    oneBinding (BcNamed (key : _) _) = do
      resolved <- resolveBcKey env key
      pure (maybeToList resolved)
    oneBinding (BcNamed [] _) = pure []
    oneBinding (BcInherit syms) =
      pure (map (symbolText . Symbol) syms)
    oneBinding (BcInheritFrom _ syms) =
      pure (map (symbolText . Symbol) syms)

-- ---------------------------------------------------------------------------
-- Search paths (<nixpkgs>, <nixpkgs/lib>)
-- ---------------------------------------------------------------------------

-- | Evaluate a search path expression.
-- Desugars to @builtins.findFile builtins.nixPath "name"@ — exactly how
-- real Nix handles @\<name\>@ expressions.
evalSearchPath :: (MonadEval m) => Env -> Text -> m NixValue
evalSearchPath env name = do
  builtinsVal <- evalVar env "builtins"
  case builtinsVal of
    VAttrs builtinsAttrs ->
      case attrSetLookup "nixPath" builtinsAttrs of
        Just nixPathThunk -> do
          nixPathVal <- force nixPathThunk
          builtinFindFile nixPathVal (mkStr name)
        Nothing ->
          throwEvalError ("file '" <> name <> "' was not found in the Nix search path")
    _ ->
      throwEvalError ("file '" <> name <> "' was not found in the Nix search path")

-- ---------------------------------------------------------------------------
-- Variables
-- ---------------------------------------------------------------------------

evalVar :: (MonadEval m) => Env -> Text -> m NixValue
evalVar env name =
  case envLookup name env of
    Just thunk -> force thunk
    Nothing -> throwEvalError ("undefined variable '" <> name <> "'")

-- | Evaluate a with-scoped variable: check with-scopes first (innermost
-- to outermost), then fall back to the standard name-based lookup
-- (parent chain to builtins).  For trimmed envs ('CapturesWithScopes'),
-- the root scope is already appended to 'envWithScopes' so the
-- with-scope lookup finds builtins without needing a parent chain.
-- Supports both resolved (CAttrSet*) and lazy (CThunk*, tagged with bit 0)
-- with-scope entries.  Lazy entries are forced on first lookup and the
-- pointer is updated in place so subsequent lookups hit the resolved
-- attrset directly.
evalWithVar :: (MonadEval m) => Env -> Text -> m NixValue
evalWithVar env name =
  let (withArr, withCount) = envWithScopesRaw env
   in evalWithVarScopes env name withArr (fromIntegral withCount) 0

evalWithVarScopes :: (MonadEval m) => Env -> Text -> Ptr (Ptr ()) -> Int -> Int -> m NixValue
evalWithVarScopes env name withArr count idx
  | idx >= count = evalVar env name
  | otherwise = do
      let scopePtr = unsafePerformIO (peekElemOff withArr idx)
      if isLazyWithScope scopePtr
        then do
          -- Lazy with-scope: force the thunk to get the attrset
          let thunkPtr = untagWithScope scopePtr
          scopeVal <- force (Thunk thunkPtr)
          case scopeVal of
            VAttrs (AttrSet cset) -> do
              -- Cache: replace the tagged thunk pointer with the resolved
              -- attrset pointer so future lookups are fast.
              let resolvedPtr = castPtr cset
              seq (unsafePerformIO (pokeElemOff withArr idx resolvedPtr)) (pure ())
              case attrSetLookup name (AttrSet cset) of
                Just thunk -> force thunk
                Nothing -> evalWithVarScopes env name withArr count (idx + 1)
            _ -> throwEvalError ("'with' requires a set, got " <> typeName scopeVal)
        else
          -- Resolved attrset scope (normal path)
          case attrSetLookup name (AttrSet (castPtr scopePtr)) of
            Just thunk -> force thunk
            Nothing -> evalWithVarScopes env name withArr count (idx + 1)

-- | Check if a with-scope pointer is tagged as lazy (bit 0 set).
isLazyWithScope :: Ptr () -> Bool
isLazyWithScope ptr = ptrToWordPtr ptr .&. 1 /= 0

-- | Remove the lazy tag from a with-scope pointer, returning a CThunkPtr.
untagWithScope :: Ptr () -> CThunkPtr
untagWithScope ptr =
  let tagged = ptrToWordPtr ptr
   in wordPtrToPtr (tagged .&. complement 1)

-- | Tag a CThunkPtr as a lazy with-scope (set bit 0).
tagLazyWithScope :: CThunkPtr -> Ptr ()
tagLazyWithScope ptr =
  let raw = ptrToWordPtr (castPtr ptr)
   in wordPtrToPtr (raw .|. 1)

-- | Push a lazy (thunk-based) with-scope onto the environment.
-- The scope is NOT forced until a WITH_VAR lookup actually needs it.
{-# NOINLINE pushLazyWithScope #-}
pushLazyWithScope :: Thunk -> Env -> Env
pushLazyWithScope (Thunk thunkPtr) =
  pushWithScopeRaw (tagLazyWithScope thunkPtr)

-- | Push a raw pointer as a with-scope.
{-# NOINLINE pushWithScopeRaw #-}
pushWithScopeRaw :: Ptr () -> Env -> Env
pushWithScopeRaw ptr (Env envPtr) =
  Env (unsafePerformIO (cenvPushWith envPtr ptr))

-- ---------------------------------------------------------------------------
-- Formals matching + env helpers (used by evalBcApp, applyValue, etc.)
-- ---------------------------------------------------------------------------

-- | Match a lambda's formals against an argument thunk.
matchFormals :: (MonadEval m) => Env -> EvalFormals -> Thunk -> m Env
matchFormals closureEnv (EFName _) argThunk =
  let (sp, sc) = buildCSlots [argThunk]
   in pure (envFromSlots sp sc closureEnv)
matchFormals closureEnv (EFSet formals allowExtra) argThunk = do
  argVal <- force argThunk
  matchFormalSet closureEnv formals allowExtra argVal Nothing
matchFormals closureEnv (EFNamedSet _ formals allowExtra) argThunk = do
  argVal <- force argThunk
  matchFormalSet closureEnv formals allowExtra argVal (Just argThunk)

-- | Match destructuring set pattern formals against an attrset value.
matchFormalSet :: (MonadEval m) => Env -> [EvalFormal] -> Bool -> NixValue -> Maybe Thunk -> m Env
matchFormalSet closureEnv formals allowExtra argVal atThunk =
  case argVal of
    VAttrs attrs -> do
      checkExtraKeys formals allowExtra attrs
      checkMissingFormals attrs formals
      let formalEnv = envFromSlots formalSlotsPtr formalSlotCount closureEnv
          (formalSlotsPtr, formalSlotCount) = buildCSlots formalThunks
          formalThunks = case atThunk of
            Nothing -> map resolveOneFormal formals
            Just at -> at : map resolveOneFormal formals
          resolveOneFormal (EvalFormal name defBcIdx) =
            case attrSetLookup name attrs of
              Just thunk -> thunk
              Nothing -> case defBcIdx of
                Just bcIdx -> mkThunkBc formalEnv bcIdx
                Nothing -> error "matchFormalSet: missing required formal (unreachable)"
      pure formalEnv
    _ -> throwEvalError ("function expects a set argument, got " <> typeName argVal)

checkExtraKeys :: (MonadEval m) => [EvalFormal] -> Bool -> AttrSet -> m ()
checkExtraKeys _ True _ = pure ()
checkExtraKeys formals False attrs =
  let expected = map efName formals
      actual = attrSetKeys attrs
      extra = filter (`notElem` expected) actual
   in case extra of
        [] -> pure ()
        (k : _) -> throwEvalError ("unexpected attribute '" <> k <> "' in function argument")

checkMissingFormals :: (MonadEval m) => AttrSet -> [EvalFormal] -> m ()
checkMissingFormals attrs formals =
  let allMissing = [efName f | f <- formals, isNothing (efDefault f), not (attrSetMember (efName f) attrs)]
   in case allMissing of
        [] -> pure ()
        (name : _) ->
          let provided = attrSetKeys attrs
              provSnippet = T.intercalate ", " (take 20 provided)
              missSnippet = T.intercalate ", " allMissing
           in throwEvalError ("missing required attribute '" <> name <> "'; all missing: [" <> missSnippet <> "]; provided keys (" <> T.pack (show (length provided)) <> "): [" <> provSnippet <> "]")

-- | Build capture environment from capture info.
-- NoCaptureInfo: no trimming, use env as-is.
-- Captures: build minimal env from captured slots.
-- CapturesWithScopes: build minimal env + copy with-scopes.
buildCaptureEnv :: Env -> CaptureInfo -> Env
buildCaptureEnv env NoCaptureInfo = env
buildCaptureEnv env (Captures captureList) =
  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]
   in newMinimalEnv slotsPtr slotCount
buildCaptureEnv env (CapturesWithScopes captureList) =
  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]
      (withArr, withCount) = withScopesForCapture env
   in newCEnv slotsPtr slotCount Nothing Nothing withArr withCount

-- | Look up a name in the environment and return its thunk.
-- Used by @inherit@ bindings (both bytecode and Expr paths).
inheritLookup :: Env -> Text -> Thunk
inheritLookup env name =
  case envLookup name env of
    Just thunk -> thunk
    Nothing -> error ("inheritLookup: undefined variable '" <> T.unpack name <> "' (unreachable)")

-- | Merge two thunks for nested attribute update.
-- If both are computed attrsets, recursively merge with attrSetUnionWith.
-- Otherwise the new value wins.
mergeThunks :: Thunk -> Thunk -> Thunk
mergeThunks a b =
  case (readThunkValue a, readThunkValue b) of
    (Just (VAttrs aa), Just (VAttrs bb)) ->
      evaluated (VAttrs (attrSetUnionWith mergeThunks aa bb))
    _ -> b

-- ---------------------------------------------------------------------------
-- Builtin registry (single-definition-site for all builtins)
-- ---------------------------------------------------------------------------

-- | A builtin function definition: its arity and implementation.
data BuiltinDef m = BuiltinDef
  { bdArity :: !Int,
    bdApply :: [NixValue] -> m NixValue
  }

-- | Define an arity-1 builtin.
builtin1 :: (MonadEval m) => Text -> (NixValue -> m NixValue) -> (Text, BuiltinDef m)
builtin1 name f =
  ( name,
    BuiltinDef 1 $ \case
      [a] -> f a
      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
  )

-- | Define an arity-2 builtin.
builtin2 ::
  (MonadEval m) =>
  Text ->
  (NixValue -> NixValue -> m NixValue) ->
  (Text, BuiltinDef m)
builtin2 name f =
  ( name,
    BuiltinDef 2 $ \case
      [a, b] -> f a b
      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
  )

-- | Define an arity-3 builtin.
builtin3 ::
  (MonadEval m) =>
  Text ->
  (NixValue -> NixValue -> NixValue -> m NixValue) ->
  (Text, BuiltinDef m)
builtin3 name f =
  ( name,
    BuiltinDef 3 $ \case
      [a, b, c] -> f a b c
      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
  )

-- | Central registry of all builtins.  Adding a new builtin is a single
-- entry here plus its implementation function — no other files need changes.
builtinRegistry :: (MonadEval m) => Map Text (BuiltinDef m)
builtinRegistry =
  Map.fromList
    [ -- Type checking (arity 1)
      builtin1 "typeOf" (pure . mkStr . typeOfValue),
      builtin1 "isNull" (pure . VBool . isNullVal),
      builtin1 "isInt" (pure . VBool . isIntVal),
      builtin1 "isFloat" (pure . VBool . isFloatVal),
      builtin1 "isBool" (pure . VBool . isBoolVal),
      builtin1 "isString" (pure . VBool . isStringVal),
      builtin1 "isList" (pure . VBool . isListVal),
      builtin1 "isAttrs" (pure . VBool . isAttrsVal),
      builtin1 "isFunction" (pure . VBool . isFunctionVal),
      -- List operations (arity 1)
      builtin1 "length" builtinLength,
      builtin1 "head" builtinHead,
      builtin1 "tail" builtinTail,
      -- String operations (arity 1)
      builtin1 "toString" (fmap (uncurry VStr) . coerceToStringPermissive),
      builtin1 "stringLength" builtinStringLength,
      -- Control (arity 1)
      builtin1 "throw" builtinThrow,
      builtin1 "abort" builtinAbort,
      -- Attr set operations (arity 1)
      builtin1 "attrNames" builtinAttrNames,
      builtin1 "attrValues" builtinAttrValues,
      builtin1 "listToAttrs" builtinListToAttrs,
      -- Attr set operations (arity 2)
      builtin2 "hasAttr" builtinHasAttr,
      builtin2 "getAttr" builtinGetAttr,
      builtin2 "removeAttrs" builtinRemoveAttrs,
      builtin2 "intersectAttrs" builtinIntersectAttrs,
      builtin2 "catAttrs" builtinCatAttrs,
      -- List higher-order (arity 2)
      builtin2 "map" builtinMap,
      builtin2 "filter" builtinFilter,
      builtin2 "genList" builtinGenList,
      builtin2 "sort" builtinSort,
      builtin2 "concatMap" builtinConcatMap,
      builtin2 "any" builtinAny,
      builtin2 "all" builtinAll,
      builtin2 "elem" builtinElem,
      builtin2 "elemAt" builtinElemAt,
      builtin2 "partition" builtinPartition,
      builtin2 "groupBy" builtinGroupBy,
      -- String operations (arity 2)
      builtin2 "concatStringsSep" builtinConcatStringsSep,
      -- Arity 3
      builtin3 "foldl'" builtinFoldl,
      builtin3 "substring" builtinSubstring,
      -- Numeric
      builtin1 "isPath" (pure . VBool . isPathVal),
      builtin1 "ceil" builtinCeil,
      builtin1 "floor" builtinFloor,
      builtin2 "seq" builtinSeq,
      builtin2 "trace" builtinTrace,
      builtin2 "warn" builtinWarn,
      builtin1 "unsafeDiscardStringContext" builtinDiscardContext,
      builtin1 "unsafeDiscardOutputDependency" builtinDiscardOutputDep,
      -- String context introspection
      builtin1 "hasContext" builtinHasContext,
      builtin1 "getContext" builtinGetContext,
      builtin2 "appendContext" builtinAppendContext,
      builtin1 "baseNameOf" builtinBaseNameOf,
      builtin1 "dirOf" builtinDirOf,
      builtin1 "concatLists" builtinConcatLists,
      builtin2 "lessThan" builtinLessThan,
      -- Arithmetic + bitwise
      builtin2 "add" builtinAdd,
      builtin2 "sub" builtinSub,
      builtin2 "mul" builtinMul,
      builtin2 "div" builtinDiv,
      builtin2 "mod" builtinMod,
      builtin2 "bitAnd" builtinBitAnd,
      builtin2 "bitOr" builtinBitOr,
      builtin2 "bitXor" builtinBitXor,
      builtin2 "min" builtinMin,
      builtin2 "max" builtinMax,
      -- Attr set higher-order
      builtin2 "mapAttrs" builtinMapAttrs,
      builtin1 "functionArgs" builtinFunctionArgs,
      builtin2 "setFunctionArgs" builtinSetFunctionArgs,
      builtin2 "zipAttrsWith" builtinZipAttrsWith,
      -- String manipulation
      builtin2 "match" builtinMatch,
      builtin2 "split" builtinSplit,
      builtin3 "replaceStrings" builtinReplaceStrings,
      builtin2 "compareVersions" builtinCompareVersions,
      builtin1 "splitVersion" builtinSplitVersion,
      builtin1 "parseDrvName" builtinParseDrvName,
      -- Serialization + hashing
      builtin1 "toJSON" builtinToJSON,
      builtin1 "fromJSON" builtinFromJSON,
      builtin2 "hashString" builtinHashString,
      -- Error handling + sequencing
      builtin1 "tryEval" (\_ -> throwEvalError "unreachable: tryEval handled in evalApp"),
      builtin2 "deepSeq" builtinDeepSeq,
      -- Graph traversal
      builtin1 "genericClosure" builtinGenericClosure,
      -- IO builtins (delegate to MonadEval methods)
      builtin1 "import" builtinImport,
      builtin1 "readFile" builtinReadFile,
      builtin1 "pathExists" builtinPathExists,
      builtin1 "readDir" builtinReadDir,
      builtin1 "getEnv" builtinGetEnv,
      builtin1 "toPath" builtinToPath,
      -- Store path operations
      builtin1 "placeholder" builtinPlaceholder,
      builtin1 "storePath" builtinStorePath,
      builtin2 "findFile" builtinFindFile,
      builtin2 "toFile" builtinToFile,
      builtin2 "scopedImport" builtinScopedImport,
      -- Network fetchers
      builtin1 "fetchurl" builtinFetchurl,
      builtin1 "fetchTarball" builtinFetchTarball,
      builtin1 "fetchGit" builtinFetchGit,
      -- Derivation construction: lazy 'derivation' wrapper over the eager
      -- 'derivationStrict' primop (matches C++ Nix corepkgs/derivation.nix).
      builtin1 "derivation" builtinDerivationLazy,
      builtin1 "derivationStrict" builtinDerivationStrict,
      -- Error context (pass-through — context only matters on error)
      builtin2 "addErrorContext" (\_ val -> pure val),
      -- Attr position (return null — nixpkgs handles this gracefully)
      builtin2 "unsafeGetAttrPos" (\_ _ -> pure VNull),
      -- Debugging (traceVerbose: same as trace for now, --trace-verbose not yet gated)
      builtin2 "traceVerbose" builtinTrace,
      builtin1 "break" pure,
      -- IO: file hashing + type detection
      builtin2 "hashFile" builtinHashFile,
      builtin1 "readFileType" builtinReadFileType,
      -- Serialization
      builtin1 "fromTOML" builtinFromTOML,
      -- Hash conversion
      builtin1 "convertHash" builtinConvertHash,
      -- XML serialization
      builtin1 "toXML" builtinToXML,
      -- Source filtering + path import
      builtin1 "path" builtinPath,
      builtin2 "filterSource" builtinFilterSource,
      -- Experimental feature stubs
      builtin2 "outputOf" builtinOutputOf,
      builtin1 "fetchTree" builtinFetchTree,
      builtin1 "fetchClosure" builtinFetchClosure
    ]

-- | Names of all registered builtins.
builtinNames :: [Text]
builtinNames = Map.keys (builtinRegistry :: Map Text (BuiltinDef PureEval))

-- ---------------------------------------------------------------------------
-- Builtin dispatch (partial application via accumulated args)
-- ---------------------------------------------------------------------------

-- | Arity of a builtin (how many arguments before execution).
builtinArity :: Text -> Int
builtinArity name = maybe 1 bdArity (Map.lookup name (builtinRegistry :: Map Text (BuiltinDef PureEval)))

-- | Apply a builtin with accumulated args.  If we have enough args,
-- execute; otherwise return a partially applied builtin.
applyBuiltin :: (MonadEval m) => Text -> [NixValue] -> NixValue -> m NixValue
applyBuiltin name accArgs arg =
  let allArgs = accArgs ++ [arg]
      arity = builtinArity name
   in if length allArgs < arity
        then pure (VBuiltin name (precompileArgs name allArgs))
        else executeBuiltin name allArgs

-- | Pre-compile regex patterns at partial application time.
-- When builtins.match or builtins.split receives its first argument
-- (the pattern string), compile it immediately and store the compiled
-- RE.Regex in a VCompiledRegex, replacing the raw VStr.  The compiled
-- form is carried in VBuiltin's accumulated args and reused on every
-- subsequent application — zero recompilation.
precompileArgs :: Text -> [NixValue] -> [NixValue]
precompileArgs "match" [VStr pat _] =
  let anchored = "^" <> pat <> "$"
   in case cachedCompileRegex anchored of
        Just compiled -> [VCompiledRegex (CompiledRegex pat compiled)]
        Nothing -> [VStr pat emptyContext] -- fail later at execute time
precompileArgs "split" [VStr pat _] =
  case cachedCompileRegex pat of
    Just compiled -> [VCompiledRegex (CompiledRegex pat compiled)]
    Nothing -> [VStr pat emptyContext] -- fail later at execute time
precompileArgs _ args = args

-- | Apply a function value (lambda or builtin) to one argument.
-- Used by higher-order builtins to invoke user-supplied functions.
applyValue :: (MonadEval m) => NixValue -> NixValue -> m NixValue
applyValue (VLambda closureEnv formals bodyBcIdx) arg = do
  extEnv <- matchFormals closureEnv formals (evaluated arg)
  evalBytecode extEnv bodyBcIdx
applyValue (VBuiltin name accArgs) arg =
  applyBuiltin name accArgs arg
applyValue other _ =
  throwEvalError ("attempt to call " <> typeName other <> ", which is not a function")

-- | Execute a builtin once all arguments are collected.
--
-- Direct case dispatch avoids rebuilding the polymorphic 'builtinRegistry'
-- Map on every call.  'builtinRegistry' is polymorphic in @m@ so GHC
-- cannot cache it as a CAF — it gets reconstructed on every use.
-- Pattern matching on the name is zero-allocation.
executeBuiltin :: (MonadEval m) => Text -> [NixValue] -> m NixValue
executeBuiltin name args = case name of
  -- Type checking (arity 1)
  "typeOf" -> apply1 (pure . mkStr . typeOfValue)
  "isNull" -> apply1 (pure . VBool . isNullVal)
  "isInt" -> apply1 (pure . VBool . isIntVal)
  "isFloat" -> apply1 (pure . VBool . isFloatVal)
  "isBool" -> apply1 (pure . VBool . isBoolVal)
  "isString" -> apply1 (pure . VBool . isStringVal)
  "isList" -> apply1 (pure . VBool . isListVal)
  "isAttrs" -> apply1 (pure . VBool . isAttrsVal)
  "isFunction" -> apply1 (pure . VBool . isFunctionVal)
  -- List operations (arity 1)
  "length" -> apply1 builtinLength
  "head" -> apply1 builtinHead
  "tail" -> apply1 builtinTail
  -- String operations (arity 1)
  "toString" -> apply1 (fmap (uncurry VStr) . coerceToStringPermissive)
  "stringLength" -> apply1 builtinStringLength
  -- Control (arity 1)
  "throw" -> apply1 builtinThrow
  "abort" -> apply1 builtinAbort
  -- Attr set operations (arity 1)
  "attrNames" -> apply1 builtinAttrNames
  "attrValues" -> apply1 builtinAttrValues
  "listToAttrs" -> apply1 builtinListToAttrs
  -- Attr set operations (arity 2)
  "hasAttr" -> apply2 builtinHasAttr
  "getAttr" -> apply2 builtinGetAttr
  "removeAttrs" -> apply2 builtinRemoveAttrs
  "intersectAttrs" -> apply2 builtinIntersectAttrs
  "catAttrs" -> apply2 builtinCatAttrs
  -- List higher-order (arity 2)
  "map" -> apply2 builtinMap
  "filter" -> apply2 builtinFilter
  "genList" -> apply2 builtinGenList
  "sort" -> apply2 builtinSort
  "concatMap" -> apply2 builtinConcatMap
  "any" -> apply2 builtinAny
  "all" -> apply2 builtinAll
  "elem" -> apply2 builtinElem
  "elemAt" -> apply2 builtinElemAt
  "partition" -> apply2 builtinPartition
  "groupBy" -> apply2 builtinGroupBy
  -- String operations (arity 2)
  "concatStringsSep" -> apply2 builtinConcatStringsSep
  -- Arity 3
  "foldl'" -> apply3 builtinFoldl
  "substring" -> apply3 builtinSubstring
  -- Numeric
  "isPath" -> apply1 (pure . VBool . isPathVal)
  "ceil" -> apply1 builtinCeil
  "floor" -> apply1 builtinFloor
  "seq" -> apply2 builtinSeq
  "trace" -> apply2 builtinTrace
  "warn" -> apply2 builtinWarn
  "unsafeDiscardStringContext" -> apply1 builtinDiscardContext
  "unsafeDiscardOutputDependency" -> apply1 builtinDiscardOutputDep
  -- String context introspection
  "hasContext" -> apply1 builtinHasContext
  "getContext" -> apply1 builtinGetContext
  "appendContext" -> apply2 builtinAppendContext
  "baseNameOf" -> apply1 builtinBaseNameOf
  "dirOf" -> apply1 builtinDirOf
  "concatLists" -> apply1 builtinConcatLists
  "lessThan" -> apply2 builtinLessThan
  -- Arithmetic + bitwise
  "add" -> apply2 builtinAdd
  "sub" -> apply2 builtinSub
  "mul" -> apply2 builtinMul
  "div" -> apply2 builtinDiv
  "mod" -> apply2 builtinMod
  "bitAnd" -> apply2 builtinBitAnd
  "bitOr" -> apply2 builtinBitOr
  "bitXor" -> apply2 builtinBitXor
  "min" -> apply2 builtinMin
  "max" -> apply2 builtinMax
  -- Attr set higher-order
  "mapAttrs" -> apply2 builtinMapAttrs
  "functionArgs" -> apply1 builtinFunctionArgs
  "setFunctionArgs" -> apply2 builtinSetFunctionArgs
  "zipAttrsWith" -> apply2 builtinZipAttrsWith
  -- String manipulation
  "match" -> apply2 builtinMatch
  "split" -> apply2 builtinSplit
  "replaceStrings" -> apply3 builtinReplaceStrings
  "compareVersions" -> apply2 builtinCompareVersions
  "splitVersion" -> apply1 builtinSplitVersion
  "parseDrvName" -> apply1 builtinParseDrvName
  -- Serialization + hashing
  "toJSON" -> apply1 builtinToJSON
  "fromJSON" -> apply1 builtinFromJSON
  "hashString" -> apply2 builtinHashString
  -- Error handling + sequencing
  "tryEval" -> apply1 (\_ -> throwEvalError "unreachable: tryEval handled in evalApp")
  "deepSeq" -> apply2 builtinDeepSeq
  -- Graph traversal
  "genericClosure" -> apply1 builtinGenericClosure
  -- IO builtins (delegate to MonadEval methods)
  "import" -> apply1 builtinImport
  "readFile" -> apply1 builtinReadFile
  "pathExists" -> apply1 builtinPathExists
  "readDir" -> apply1 builtinReadDir
  "getEnv" -> apply1 builtinGetEnv
  "toPath" -> apply1 builtinToPath
  -- Store path operations
  "placeholder" -> apply1 builtinPlaceholder
  "storePath" -> apply1 builtinStorePath
  "findFile" -> apply2 builtinFindFile
  "toFile" -> apply2 builtinToFile
  "scopedImport" -> apply2 builtinScopedImport
  -- Network fetchers
  "fetchurl" -> apply1 builtinFetchurl
  "fetchTarball" -> apply1 builtinFetchTarball
  "fetchGit" -> apply1 builtinFetchGit
  -- Derivation construction: lazy 'derivation' over eager 'derivationStrict'
  "derivation" -> apply1 builtinDerivationLazy
  "derivationStrict" -> apply1 builtinDerivationStrict
  -- Error context (pass-through — context only matters on error)
  "addErrorContext" -> apply2 (\_ val -> pure val)
  -- Attr position (return null — nixpkgs handles this gracefully)
  "unsafeGetAttrPos" -> apply2 (\_ _ -> pure VNull)
  -- Debugging (traceVerbose: same as trace for now)
  "traceVerbose" -> apply2 builtinTrace
  "break" -> apply1 pure
  -- IO: file hashing + type detection
  "hashFile" -> apply2 builtinHashFile
  "readFileType" -> apply1 builtinReadFileType
  -- Serialization
  "fromTOML" -> apply1 builtinFromTOML
  -- Hash conversion
  "convertHash" -> apply1 builtinConvertHash
  -- XML serialization
  "toXML" -> apply1 builtinToXML
  -- Source filtering + path import
  "path" -> apply1 builtinPath
  "filterSource" -> apply2 builtinFilterSource
  -- Experimental feature stubs
  "outputOf" -> apply2 builtinOutputOf
  "fetchTree" -> apply1 builtinFetchTree
  "fetchClosure" -> apply1 builtinFetchClosure
  _ -> throwEvalError ("unknown builtin '" <> name <> "'")
  where
    apply1 f = case args of
      [a] -> f a
      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
    apply2 f = case args of
      [a, b] -> f a b
      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")
    apply3 f = case args of
      [a, b, c] -> f a b c
      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")

-- ---------------------------------------------------------------------------
-- Builtin implementations — type checking
-- ---------------------------------------------------------------------------

typeOfValue :: NixValue -> Text
typeOfValue val = case val of
  VInt _ -> "int"
  VFloat _ -> "float"
  VBool _ -> "bool"
  VNull -> "null"
  VStr _ _ -> "string"
  VPath _ -> "path"
  VList _ -> "list"
  VAttrs _ -> "set"
  VLambda {} -> "lambda"
  VBuiltin _ _ -> "lambda"
  VDerivation _ -> "set"
  VCompiledRegex _ -> "lambda"

isNullVal :: NixValue -> Bool
isNullVal VNull = True
isNullVal _ = False

isIntVal :: NixValue -> Bool
isIntVal (VInt _) = True
isIntVal _ = False

isFloatVal :: NixValue -> Bool
isFloatVal (VFloat _) = True
isFloatVal _ = False

isBoolVal :: NixValue -> Bool
isBoolVal (VBool _) = True
isBoolVal _ = False

isStringVal :: NixValue -> Bool
isStringVal (VStr _ _) = True
isStringVal _ = False

isListVal :: NixValue -> Bool
isListVal (VList _) = True
isListVal _ = False

isAttrsVal :: NixValue -> Bool
isAttrsVal (VAttrs _) = True
isAttrsVal _ = False

isFunctionVal :: NixValue -> Bool
isFunctionVal (VLambda {}) = True
isFunctionVal (VBuiltin _ _) = True
isFunctionVal _ = False

-- ---------------------------------------------------------------------------
-- Builtin implementations — list (arity 1)
-- ---------------------------------------------------------------------------

builtinLength :: (MonadEval m) => NixValue -> m NixValue
builtinLength (VList cl) = pure (VInt (fromIntegral (clistLen cl)))
builtinLength other = throwEvalError ("builtins.length: expected a list, got " <> typeName other)

builtinHead :: (MonadEval m) => NixValue -> m NixValue
builtinHead (VList cl)
  | clistLen cl == 0 = throwEvalError "builtins.head: empty list"
  | otherwise = case clistThunks cl of
      (p : _) -> force (Thunk p)
      [] -> throwEvalError "builtins.head: empty list" -- unreachable: clistLen > 0
builtinHead other = throwEvalError ("builtins.head: expected a list, got " <> typeName other)

builtinTail :: (MonadEval m) => NixValue -> m NixValue
builtinTail (VList cl)
  | clistLen cl == 0 = throwEvalError "builtins.tail: empty list"
  | otherwise = pure (VList (clistFromThunks (drop 1 (clistThunks cl))))
builtinTail other = throwEvalError ("builtins.tail: expected a list, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — string (arity 1)
-- ---------------------------------------------------------------------------

builtinStringLength :: (MonadEval m) => NixValue -> m NixValue
builtinStringLength (VStr s _) = pure (VInt (fromIntegral (T.length s)))
builtinStringLength other =
  throwEvalError ("builtins.stringLength: expected a string, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — control
-- ---------------------------------------------------------------------------

builtinThrow :: (MonadEval m) => NixValue -> m NixValue
builtinThrow (VStr msg _) = throwEvalError msg
builtinThrow other = throwEvalError ("builtins.throw: expected a string, got " <> typeName other)

builtinAbort :: (MonadEval m) => NixValue -> m NixValue
builtinAbort (VStr msg _) = abortEvaluation msg
builtinAbort other = abortEvaluation ("builtins.abort: expected a string, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — attr set (arity 1)
-- ---------------------------------------------------------------------------

builtinAttrNames :: (MonadEval m) => NixValue -> m NixValue
builtinAttrNames (VAttrs attrs) =
  -- Nix returns attribute names lexicographically sorted; the C array is in
  -- interned-symbol order, so sort here (consistent with builtins.attrValues,
  -- which sorts via attrSetElems).
  let thunks = map (evaluated . mkStr) (sort (attrSetKeys attrs))
   in pure (VList (clistFromThunks (map thunkToCPtr thunks)))
builtinAttrNames other =
  throwEvalError ("builtins.attrNames: expected a set, got " <> typeName other)

builtinAttrValues :: (MonadEval m) => NixValue -> m NixValue
builtinAttrValues (VAttrs attrs) =
  pure (VList (clistFromThunks (map thunkToCPtr (attrSetElems attrs))))
builtinAttrValues other =
  throwEvalError ("builtins.attrValues: expected a set, got " <> typeName other)

builtinListToAttrs :: (MonadEval m) => NixValue -> m NixValue
builtinListToAttrs (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  pairs <- mapM listToAttrsPair thunks
  -- Nix listToAttrs uses first-wins: if duplicate name, first element wins.
  let firstWins = Map.fromListWith (\_ kept -> kept) pairs
  pure (VAttrs (attrSetFromMap firstWins))
builtinListToAttrs other =
  throwEvalError ("builtins.listToAttrs: expected a list, got " <> typeName other)

-- | Extract { name, value } from a thunk for listToAttrs.
listToAttrsPair :: (MonadEval m) => Thunk -> m (Text, Thunk)
listToAttrsPair thunk = do
  val <- force thunk
  case val of
    VAttrs attrs -> do
      nameThunk <-
        maybe (throwEvalError "builtins.listToAttrs: element missing 'name'") pure $
          attrSetLookup "name" attrs
      nameVal <- force nameThunk
      case nameVal of
        VStr keyName _ ->
          case attrSetLookup "value" attrs of
            Just valueThunk -> pure (keyName, valueThunk)
            Nothing -> throwEvalError "builtins.listToAttrs: element missing 'value'"
        _ -> throwEvalError "builtins.listToAttrs: 'name' must be a string"
    _ -> throwEvalError "builtins.listToAttrs: element must be a set"

-- ---------------------------------------------------------------------------
-- Builtin implementations — attr set (arity 2)
-- ---------------------------------------------------------------------------

builtinHasAttr :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinHasAttr (VStr key _) (VAttrs attrs) =
  pure (VBool (attrSetMember key attrs))
builtinHasAttr (VStr _ _) other =
  throwEvalError ("builtins.hasAttr: expected a set, got " <> typeName other)
builtinHasAttr other _ =
  throwEvalError ("builtins.hasAttr: expected a string, got " <> typeName other)

builtinGetAttr :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinGetAttr (VStr key _) (VAttrs attrs) =
  case attrSetLookup key attrs of
    Just thunk -> force thunk
    Nothing -> throwEvalError ("builtins.getAttr: attribute '" <> key <> "' not found")
builtinGetAttr (VStr _ _) other =
  throwEvalError ("builtins.getAttr: expected a set, got " <> typeName other)
builtinGetAttr other _ =
  throwEvalError ("builtins.getAttr: expected a string, got " <> typeName other)

builtinRemoveAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinRemoveAttrs (VAttrs attrs) (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  keys <- mapM forceToString thunks
  pure (VAttrs (attrSetRemoveKeys keys attrs))
  where
    forceToString thunk = do
      val <- force thunk
      case val of
        VStr s _ -> pure s
        _ -> throwEvalError "builtins.removeAttrs: key list must contain strings"
builtinRemoveAttrs (VAttrs _) other =
  throwEvalError ("builtins.removeAttrs: expected a list, got " <> typeName other)
builtinRemoveAttrs other _ =
  throwEvalError ("builtins.removeAttrs: expected a set, got " <> typeName other)

builtinIntersectAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinIntersectAttrs (VAttrs a) (VAttrs b) =
  -- Iterate keys of 'a' (typically the smaller set, e.g. functionArgs)
  -- and point-lookup each in 'b' (typically the large set, e.g. nixpkgs).
  -- This avoids materializing all thunks in 'b'.
  let keysA = attrSetKeys a
      result = Map.fromList [(k, thunk) | k <- keysA, Just thunk <- [attrSetLookup k b]]
   in pure (VAttrs (attrSetFromMap result))
builtinIntersectAttrs (VAttrs _) other =
  throwEvalError ("builtins.intersectAttrs: expected a set, got " <> typeName other)
builtinIntersectAttrs other _ =
  throwEvalError ("builtins.intersectAttrs: expected a set, got " <> typeName other)

builtinCatAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinCatAttrs (VStr key _) (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  vals <- catAttrsCollect key thunks
  pure (VList (clistFromThunks (map thunkToCPtr vals)))
builtinCatAttrs (VStr _ _) other =
  throwEvalError ("builtins.catAttrs: expected a list, got " <> typeName other)
builtinCatAttrs other _ =
  throwEvalError ("builtins.catAttrs: expected a string, got " <> typeName other)

-- | Collect values for a given key from a list of attrsets.
-- Tail-recursive with accumulator to avoid stack overflow on large lists.
catAttrsCollect :: (MonadEval m) => Text -> [Thunk] -> m [Thunk]
catAttrsCollect key = go []
  where
    go !acc [] = pure (reverse acc)
    go !acc (thunk : rest) = do
      val <- force thunk
      case val of
        VAttrs attrs ->
          case attrSetLookup key attrs of
            Just found -> go (found : acc) rest
            Nothing -> go acc rest
        _ -> throwEvalError "builtins.catAttrs: list element must be a set"

-- ---------------------------------------------------------------------------
-- Builtin implementations — list higher-order (arity 2)
-- ---------------------------------------------------------------------------

builtinMap :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinMap func (VList cl) =
  -- Lazy: each element is a deferred application, forced only on demand.
  let thunks = map Thunk (clistThunks cl)
   in pure (VList (clistFromThunks (map (thunkToCPtr . deferApply func) thunks)))
builtinMap _ other =
  throwEvalError ("builtins.map: expected a list, got " <> typeName other)

builtinFilter :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinFilter predFn (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  filtered <- filterThunks predFn thunks
  pure (VList (clistFromThunks (map thunkToCPtr filtered)))
builtinFilter _ other =
  throwEvalError ("builtins.filter: expected a list, got " <> typeName other)

filterThunks :: (MonadEval m) => NixValue -> [Thunk] -> m [Thunk]
filterThunks _ [] = pure []
filterThunks predFn (thunk : rest) = do
  val <- force thunk
  result <- applyValue predFn val
  case result of
    VBool True -> (thunk :) <$> filterThunks predFn rest
    VBool False -> filterThunks predFn rest
    _ -> throwEvalError "builtins.filter: predicate must return a bool"

builtinGenList :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinGenList func (VInt n)
  | n < 0 = throwEvalError "builtins.genList: length must be non-negative"
  | otherwise =
      -- Lazy: each element is a deferred @f i@, forced only on demand.
      -- Slot 0 = function.
      let fnThunk = evaluated func
          (sp, sc) = buildCSlots [fnThunk]
          env = newMinimalEnv sp sc
          mkIndexThunk i = mkThunk env (EApp (EResolvedVar 0 0) (ELit (NixInt i)))
       in pure (VList (clistFromThunks (map (thunkToCPtr . mkIndexThunk) [0 .. n - 1])))
builtinGenList _ other =
  throwEvalError ("builtins.genList: expected an integer, got " <> typeName other)

builtinSort :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinSort comparator (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  vals <- mapM force thunks
  sorted <- mergeSort comparator vals
  pure (VList (clistFromThunks (map (thunkToCPtr . evaluated) sorted)))
builtinSort _ other =
  throwEvalError ("builtins.sort: expected a list, got " <> typeName other)

-- | Stable O(n log n) merge sort using a user-supplied comparator.
-- The comparator takes two args (curried) and returns bool.
mergeSort :: (MonadEval m) => NixValue -> [NixValue] -> m [NixValue]
mergeSort _ [] = pure []
mergeSort _ [x] = pure [x]
mergeSort cmp xs = do
  let half = length xs `div` 2
      (left, right) = splitAt half xs
  sortedLeft <- mergeSort cmp left
  sortedRight <- mergeSort cmp right
  mergeSorted cmp sortedLeft sortedRight

mergeSorted :: (MonadEval m) => NixValue -> [NixValue] -> [NixValue] -> m [NixValue]
mergeSorted _ [] ys = pure ys
mergeSorted _ xs [] = pure xs
mergeSorted cmp (x : xs) (y : ys) = do
  partial <- applyValue cmp x
  result <- applyValue partial y
  case result of
    VBool True -> (x :) <$> mergeSorted cmp xs (y : ys)
    VBool False -> (y :) <$> mergeSorted cmp (x : xs) ys
    _ -> throwEvalError "builtins.sort: comparator must return a bool"

builtinConcatMap :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinConcatMap func (VList cl) = do
  -- Semi-eager: must force each application to discover list structure for
  -- concatenation, but element thunks within those sub-lists stay lazy.
  let thunks = map Thunk (clistThunks cl)
      deferredApps = map (deferApply func) thunks
  results <- mapM force deferredApps
  concatted <- mapM extractList results
  pure (VList (clistFromThunks (map thunkToCPtr (concat concatted))))
builtinConcatMap _ other =
  throwEvalError ("builtins.concatMap: expected a list, got " <> typeName other)

extractList :: (MonadEval m) => NixValue -> m [Thunk]
extractList (VList cl) = pure (map Thunk (clistThunks cl))
extractList other =
  throwEvalError ("builtins.concatMap: function must return a list, got " <> typeName other)

builtinAny :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinAny predFn (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  result <- anyThunk predFn thunks
  pure (VBool result)
builtinAny _ other =
  throwEvalError ("builtins.any: expected a list, got " <> typeName other)

anyThunk :: (MonadEval m) => NixValue -> [Thunk] -> m Bool
anyThunk _ [] = pure False
anyThunk predFn (thunk : rest) = do
  val <- force thunk
  result <- applyValue predFn val
  case result of
    VBool True -> pure True
    VBool False -> anyThunk predFn rest
    _ -> throwEvalError "builtins.any: predicate must return a bool"

builtinAll :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinAll predFn (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  result <- allThunk predFn thunks
  pure (VBool result)
builtinAll _ other =
  throwEvalError ("builtins.all: expected a list, got " <> typeName other)

allThunk :: (MonadEval m) => NixValue -> [Thunk] -> m Bool
allThunk _ [] = pure True
allThunk predFn (thunk : rest) = do
  val <- force thunk
  result <- applyValue predFn val
  case result of
    VBool True -> allThunk predFn rest
    VBool False -> pure False
    _ -> throwEvalError "builtins.all: predicate must return a bool"

builtinElem :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinElem needle (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  found <- elemCheck needle thunks
  pure (VBool found)
builtinElem _ other =
  throwEvalError ("builtins.elem: expected a list, got " <> typeName other)

elemCheck :: (MonadEval m) => NixValue -> [Thunk] -> m Bool
elemCheck _ [] = pure False
elemCheck needle (thunk : rest) = do
  val <- force thunk
  eq <- nixEqual force needle val
  if eq then pure True else elemCheck needle rest

builtinElemAt :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinElemAt (VList cl) (VInt idx)
  | idx < 0 || fromIntegral idx >= clistLen cl = elemAtOOB idx cl
  | otherwise =
      -- O(1) direct C array access instead of materializing the whole list
      let ptr = unsafePerformIO (clistGet (unCList cl) (fromIntegral idx))
       in force (Thunk ptr)
  where
    elemAtOOB i c =
      throwEvalError
        ( "builtins.elemAt: index "
            <> T.pack (show i)
            <> " out of bounds for list of length "
            <> T.pack (show (clistLen c))
        )
builtinElemAt (VList _) other =
  throwEvalError ("builtins.elemAt: expected an integer, got " <> typeName other)
builtinElemAt other _ =
  throwEvalError ("builtins.elemAt: expected a list, got " <> typeName other)

builtinPartition :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinPartition predFn (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  (rightThunks, wrongThunks) <- partitionThunks predFn thunks
  pure
    ( VAttrs
        ( attrSetFromMap $
            Map.fromList
              [ ("right", evaluated (VList (clistFromThunks (map thunkToCPtr rightThunks)))),
                ("wrong", evaluated (VList (clistFromThunks (map thunkToCPtr wrongThunks))))
              ]
        )
    )
builtinPartition _ other =
  throwEvalError ("builtins.partition: expected a list, got " <> typeName other)

-- | Tail-recursive partition with accumulator to avoid stack overflow.
partitionThunks :: (MonadEval m) => NixValue -> [Thunk] -> m ([Thunk], [Thunk])
partitionThunks predFn = go [] []
  where
    go !rs !ws [] = pure (reverse rs, reverse ws)
    go !rs !ws (thunk : rest) = do
      val <- force thunk
      result <- applyValue predFn val
      case result of
        VBool True -> go (thunk : rs) ws rest
        VBool False -> go rs (thunk : ws) rest
        _ -> throwEvalError "builtins.partition: predicate must return a bool"

builtinGroupBy :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinGroupBy func (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  groups <- groupByCollect func thunks Map.empty
  pure (VAttrs (attrSetFromMap (Map.map (evaluated . VList . clistFromThunks . map thunkToCPtr . reverse) groups)))
builtinGroupBy _ other =
  throwEvalError ("builtins.groupBy: expected a list, got " <> typeName other)

groupByCollect ::
  (MonadEval m) =>
  NixValue ->
  [Thunk] ->
  Map Text [Thunk] ->
  m (Map Text [Thunk])
groupByCollect _ [] acc = pure acc
groupByCollect func (thunk : rest) acc = do
  val <- force thunk
  result <- applyValue func val
  case result of
    VStr key _ ->
      groupByCollect func rest (Map.insertWith (++) key [thunk] acc)
    _ -> throwEvalError "builtins.groupBy: function must return a string"

-- ---------------------------------------------------------------------------
-- Builtin implementations — string (arity 2)
-- ---------------------------------------------------------------------------

builtinConcatStringsSep :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinConcatStringsSep (VStr sep sepCtx) (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  pairs <- mapM forceToStrCtx thunks
  let texts = map fst pairs
      mergedCtx = sepCtx <> mconcat (map snd pairs)
  pure (VStr (T.intercalate sep texts) mergedCtx)
  where
    forceToStrCtx thunk = do
      val <- force thunk
      -- Nix coerces list elements to strings (paths, derivations, etc.)
      coerceToString force applyValue val
builtinConcatStringsSep (VStr _ _) other =
  throwEvalError ("builtins.concatStringsSep: expected a list, got " <> typeName other)
builtinConcatStringsSep other _ =
  throwEvalError ("builtins.concatStringsSep: expected a string, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — arity 3
-- ---------------------------------------------------------------------------

builtinFoldl :: (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue
builtinFoldl op initial (VList cl) =
  foldlStrict op initial (map Thunk (clistThunks cl))
builtinFoldl _ _ other =
  throwEvalError ("builtins.foldl': expected a list, got " <> typeName other)

-- | Strict left fold: apply @op acc elem@ for each element.
-- @op@ is curried so we call @applyValue op acc@ then @applyValue partial elem@.
foldlStrict :: (MonadEval m) => NixValue -> NixValue -> [Thunk] -> m NixValue
foldlStrict _ acc [] = pure acc
foldlStrict op acc (thunk : rest) = do
  val <- force thunk
  partial <- applyValue op acc
  stepped <- applyValue partial val
  foldlStrict op stepped rest

builtinSubstring :: (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue
builtinSubstring (VInt start) (VInt len) (VStr s ctx) =
  let clampedStart = max 0 (fromIntegral start)
      -- Nix clamps len to available length (negative len means rest of string)
      available = T.length s - clampedStart
      clampedLen =
        if len < 0
          then available
          else min (fromIntegral len) available
   in -- Context is preserved through substring (matching real Nix).
      pure (VStr (T.take clampedLen (T.drop clampedStart s)) ctx)
builtinSubstring _ _ (VStr _ _) =
  throwEvalError "builtins.substring: start and length must be integers"
builtinSubstring _ _ other =
  throwEvalError ("builtins.substring: expected a string, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin helpers
-- ---------------------------------------------------------------------------

-- | Build a thunk that defers @f arg@ — the application only happens when
-- the thunk is forced.  Reuses the existing eval machinery via a synthetic
-- @EApp (EResolvedVar 0 0) (EResolvedVar 0 1)@ in a self-contained env.
-- Slot 0 = function, slot 1 = argument.
deferApply :: NixValue -> Thunk -> Thunk
deferApply func argThunk =
  let (sp, sc) = buildCSlots [evaluated func, argThunk]
      env = newMinimalEnv sp sc
   in mkSyntheticThunk env deferApplyExpr

-- | Shared expression for 'deferApply'.  Allocated once as a CAF.
deferApplyExpr :: Expr
deferApplyExpr = EApp (EResolvedVar 0 0) (EResolvedVar 0 1)
{-# NOINLINE deferApplyExpr #-}

-- | Permissive coercion used by @builtins.toString@.
--
-- Like 'coerceToString' but additionally handles lists: elements are
-- recursively coerced and joined with spaces, matching real Nix semantics.
-- @toString [1 2 3]@ gives @"1 2 3"@.
coerceToStringPermissive :: (MonadEval m) => NixValue -> m (Text, StringContext)
coerceToStringPermissive (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  parts <- mapM coerceThunk thunks
  let texts = map fst parts
      ctx = mconcat (map snd parts)
  pure (T.intercalate " " texts, ctx)
  where
    coerceThunk thunk = do
      val <- force thunk
      coerceToStringPermissive val
coerceToStringPermissive other = coerceToString force applyValue other

-- | Coerce a value to a string for a DERIVATION field (an env value or an
-- arg).  Like 'coerceToStringPermissive', but a path literal is copied into
-- the store: it becomes its source store path, with that path added to the
-- string context so it lands in the derivation's @inputSrcs@ — matching C++
-- Nix's copy-to-store coercion of paths in derivation arguments/environment.
coerceToStoreString :: (MonadEval m) => NixValue -> m (Text, StringContext)
coerceToStoreString (VPath p) = do
  spText <- storeSourcePath p
  case parseStorePath defaultStoreDir spText of
    Just sp -> pure (spText, StringContext (Set.singleton (SCPlain sp)))
    Nothing -> pure (spText, mempty)
coerceToStoreString (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  parts <- mapM (force >=> coerceToStoreString) thunks
  pure (T.intercalate " " (map fst parts), mconcat (map snd parts))
coerceToStoreString other = coerceToStringPermissive other

-- | Coerce a value for string interpolation (@"${...}"@).  Like
-- 'coerceToString', but a path literal is copied into the store and replaced by
-- its source store path (with context) — matching C++ Nix, where interpolation
-- uses @copyToStore = true@, unlike 'builtins.toString', which does not copy.
coerceToStringInterp :: (MonadEval m) => NixValue -> m (Text, StringContext)
coerceToStringInterp (VPath p) = do
  spText <- storeSourcePath p
  case parseStorePath defaultStoreDir spText of
    Just sp -> pure (spText, StringContext (Set.singleton (SCPlain sp)))
    Nothing -> pure (spText, mempty)
coerceToStringInterp other = coerceToString force applyValue other

-- | The current system platform string.
currentSystemStr :: Text
currentSystemStr = case (System.Info.arch, System.Info.os) of
  ("x86_64", "mingw32") -> "x86_64-windows"
  ("x86_64", "darwin") -> "x86_64-darwin"
  ("aarch64", "darwin") -> "aarch64-darwin"
  ("aarch64", "linux") -> "aarch64-linux"
  ("x86_64", "linux") -> "x86_64-linux"
  (arch, os) -> T.pack arch <> "-" <> T.pack os

-- | Store dir with trailing slash, for building store paths.
storeDirPrefix :: Text
storeDirPrefix = defaultStoreDirText <> "/"

-- ---------------------------------------------------------------------------
-- Builtin implementations — numeric + context
-- ---------------------------------------------------------------------------

isPathVal :: NixValue -> Bool
isPathVal (VPath _) = True
isPathVal _ = False

builtinCeil :: (MonadEval m) => NixValue -> m NixValue
builtinCeil (VFloat f) = pure (VInt (ceiling f))
builtinCeil (VInt n) = pure (VInt n)
builtinCeil other = throwEvalError ("builtins.ceil: expected a number, got " <> typeName other)

builtinFloor :: (MonadEval m) => NixValue -> m NixValue
builtinFloor (VFloat f) = pure (VInt (floor f))
builtinFloor (VInt n) = pure (VInt n)
builtinFloor other = throwEvalError ("builtins.floor: expected a number, got " <> typeName other)

builtinDiscardContext :: (MonadEval m) => NixValue -> m NixValue
builtinDiscardContext (VStr s _) = pure (mkStr s)
builtinDiscardContext other =
  throwEvalError ("builtins.unsafeDiscardStringContext: expected a string, got " <> typeName other)

-- | Strip only derivation output dependencies (SCDrvOutput, SCAllOutputs),
-- keeping plain store path references (SCPlain).
builtinDiscardOutputDep :: (MonadEval m) => NixValue -> m NixValue
builtinDiscardOutputDep (VStr s (StringContext ctx)) =
  let kept = Set.filter isPlain ctx
   in pure (VStr s (StringContext kept))
  where
    isPlain (SCPlain _) = True
    isPlain _ = False
builtinDiscardOutputDep other =
  throwEvalError ("builtins.unsafeDiscardOutputDependency: expected a string, got " <> typeName other)

-- | Check whether a string has any context elements.
builtinHasContext :: (MonadEval m) => NixValue -> m NixValue
builtinHasContext (VStr _ ctx) = pure (VBool (ctx /= emptyContext))
builtinHasContext other =
  throwEvalError ("builtins.hasContext: expected a string, got " <> typeName other)

-- | Return the context of a string as an attrset.
--
-- Each key is a store path string.  Each value is an attrset with:
--   - @path@: true if there's a SCPlain reference
--   - @allOutputs@: true if there's a SCAllOutputs reference
--   - @outputs@: list of output names from SCDrvOutput references
builtinGetContext :: (MonadEval m) => NixValue -> m NixValue
builtinGetContext (VStr _ (StringContext ctx)) = do
  let grouped = groupContextByPath (Set.toList ctx)
      attrMap = Map.map contextEntryToAttrs grouped
  pure (VAttrs (attrSetFromMap attrMap))
builtinGetContext other =
  throwEvalError ("builtins.getContext: expected a string, got " <> typeName other)

-- | Intermediate representation for grouping context elements by store path.
data ContextEntry = ContextEntry
  { cePath :: !Bool,
    ceAllOutputs :: !Bool,
    ceOutputs :: ![Text]
  }

-- | Group context elements by their store path.
groupContextByPath :: [StringContextElement] -> Map Text ContextEntry
groupContextByPath = foldl' addElement Map.empty
  where
    addElement acc (SCPlain sp) =
      Map.insertWith mergeEntry (spToText sp) (ContextEntry True False []) acc
    addElement acc (SCDrvOutput sp outName) =
      Map.insertWith mergeEntry (spToText sp) (ContextEntry False False [outName]) acc
    addElement acc (SCAllOutputs sp) =
      Map.insertWith mergeEntry (spToText sp) (ContextEntry False True []) acc
    mergeEntry new old =
      ContextEntry
        (cePath new || cePath old)
        (ceAllOutputs new || ceAllOutputs old)
        (ceOutputs new ++ ceOutputs old)
    spToText sp =
      T.pack (storePathToFilePath defaultStoreDir sp)

-- | Convert a ContextEntry to an attrset thunk.
contextEntryToAttrs :: ContextEntry -> Thunk
contextEntryToAttrs entry =
  let fields =
        [("path", evaluated (VBool True)) | cePath entry]
          ++ [("allOutputs", evaluated (VBool True)) | ceAllOutputs entry]
          ++ [("outputs", evaluated (VList (clistFromThunks [thunkToCPtr (evaluated (mkStr o)) | o <- ceOutputs entry]))) | not (null (ceOutputs entry))]
   in evaluated (VAttrs (attrSetFromMap (Map.fromList fields)))

-- | Append context entries to a string from an attrset.
--
-- @builtins.appendContext string contextAttrset@ adds the specified
-- context elements to the string.
builtinAppendContext :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinAppendContext (VStr s ctx) (VAttrs contextAttrs) = do
  newCtx <- parseContextAttrs (attrSetToMap contextAttrs)
  pure (VStr s (ctx <> newCtx))
builtinAppendContext (VStr _ _) other =
  throwEvalError ("builtins.appendContext: second argument must be a set, got " <> typeName other)
builtinAppendContext other _ =
  throwEvalError ("builtins.appendContext: first argument must be a string, got " <> typeName other)

-- | Parse a context attrset into a StringContext.
-- Each key is a store path; each value is an attrset with optional
-- @path@, @allOutputs@, and @outputs@ fields.
parseContextAttrs :: (MonadEval m) => Map Text Thunk -> m StringContext
parseContextAttrs attrs = do
  elements <- mapM parseOneCtx (Map.toList attrs)
  pure (StringContext (Set.fromList (concat elements)))
  where
    parseOneCtx (pathText, thunk) = do
      val <- force thunk
      case val of
        VAttrs inner -> do
          let sp = case parseStorePath defaultStoreDir pathText of
                Just parsed -> parsed
                Nothing -> StorePath (T.take 32 (T.drop (T.length storeDirPrefix) pathText)) (T.drop 33 (T.drop (T.length storeDirPrefix) pathText))
          hasPath <- getBoolAttr "path" inner
          hasAllOuts <- getBoolAttr "allOutputs" inner
          outNames <- getOutputsList inner
          let pathElems = [SCPlain sp | hasPath]
              allOutElems = [SCAllOutputs sp | hasAllOuts]
              outElems = [SCDrvOutput sp o | o <- outNames]
          pure (pathElems ++ allOutElems ++ outElems)
        _ -> throwEvalError "builtins.appendContext: context entry must be a set"

    getBoolAttr key attrs' = case attrSetLookup key attrs' of
      Nothing -> pure False
      Just thunk -> do
        val <- force thunk
        case val of
          VBool b -> pure b
          _ -> pure False

    getOutputsList attrs' = case attrSetLookup "outputs" attrs' of
      Nothing -> pure []
      Just thunk -> do
        val <- force thunk
        case val of
          VList cl -> mapM (forceToOutputName . Thunk) (clistThunks cl)
          _ -> pure []

    forceToOutputName thunk = do
      val <- force thunk
      case val of
        VStr s _ -> pure s
        _ -> throwEvalError "builtins.appendContext: output name must be a string"

builtinBaseNameOf :: (MonadEval m) => NixValue -> m NixValue
builtinBaseNameOf (VStr s ctx) = pure (VStr (lastComponent s) ctx)
builtinBaseNameOf (VPath p) = pure (mkStr (lastComponent p))
builtinBaseNameOf other =
  throwEvalError ("builtins.baseNameOf: expected a string or path, got " <> typeName other)

lastComponent :: Text -> Text
lastComponent t = case T.splitOn "/" t of
  [] -> t
  parts -> case reverse (filter (not . T.null) parts) of
    [] -> ""
    (final : _) -> final

builtinDirOf :: (MonadEval m) => NixValue -> m NixValue
builtinDirOf (VStr s ctx) = pure (VStr (dirComponent s) ctx)
builtinDirOf (VPath p) = pure (VPath (dirComponent p))
builtinDirOf other =
  throwEvalError ("builtins.dirOf: expected a string or path, got " <> typeName other)

dirComponent :: Text -> Text
dirComponent t =
  let idx = T.findIndex (== '/') (T.reverse t)
   in case idx of
        Nothing -> "."
        Just n -> T.take (T.length t - n - 1) t

builtinConcatLists :: (MonadEval m) => NixValue -> m NixValue
builtinConcatLists (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  sublists <- mapM forceThenExtractList thunks
  pure (VList (clistFromThunks (concat sublists)))
  where
    forceThenExtractList thunk = do
      val <- force thunk
      case val of
        VList innerCl -> pure (clistThunks innerCl)
        _ -> throwEvalError "builtins.concatLists: element must be a list"
builtinConcatLists other =
  throwEvalError ("builtins.concatLists: expected a list, got " <> typeName other)

builtinLessThan :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinLessThan a b = VBool <$> nixCompare a b

-- ---------------------------------------------------------------------------
-- Builtin implementations — arithmetic + bitwise
-- ---------------------------------------------------------------------------

builtinAdd :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinAdd (VInt a) (VInt b) = pure (VInt (a + b))
builtinAdd (VInt a) (VFloat b) = pure (VFloat (fromIntegral a + b))
builtinAdd (VFloat a) (VInt b) = pure (VFloat (a + fromIntegral b))
builtinAdd (VFloat a) (VFloat b) = pure (VFloat (a + b))
builtinAdd l r = throwEvalError ("builtins.add: expected numbers, got " <> typeName l <> " and " <> typeName r)

builtinSub :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinSub (VInt a) (VInt b) = pure (VInt (a - b))
builtinSub (VInt a) (VFloat b) = pure (VFloat (fromIntegral a - b))
builtinSub (VFloat a) (VInt b) = pure (VFloat (a - fromIntegral b))
builtinSub (VFloat a) (VFloat b) = pure (VFloat (a - b))
builtinSub l r = throwEvalError ("builtins.sub: expected numbers, got " <> typeName l <> " and " <> typeName r)

builtinMul :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinMul (VInt a) (VInt b) = pure (VInt (a * b))
builtinMul (VInt a) (VFloat b) = pure (VFloat (fromIntegral a * b))
builtinMul (VFloat a) (VInt b) = pure (VFloat (a * fromIntegral b))
builtinMul (VFloat a) (VFloat b) = pure (VFloat (a * b))
builtinMul l r = throwEvalError ("builtins.mul: expected numbers, got " <> typeName l <> " and " <> typeName r)

builtinDiv :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinDiv _ (VInt 0) = throwEvalError "builtins.div: division by zero"
builtinDiv (VInt a) (VInt b)
  | a == minBound && b == -1 = pure (VInt minBound)
  | otherwise = pure (VInt (quot a b))
builtinDiv _ (VFloat 0) = throwEvalError "builtins.div: division by zero"
builtinDiv (VInt a) (VFloat b) = pure (VFloat (fromIntegral a / b))
builtinDiv (VFloat a) (VInt b) = pure (VFloat (a / fromIntegral b))
builtinDiv (VFloat a) (VFloat b) = pure (VFloat (a / b))
builtinDiv l r = throwEvalError ("builtins.div: expected numbers, got " <> typeName l <> " and " <> typeName r)

builtinMod :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinMod _ (VInt 0) = throwEvalError "builtins.mod: division by zero"
builtinMod (VInt a) (VInt b)
  | a == minBound && b == -1 = pure (VInt 0)
  | otherwise = pure (VInt (rem a b))
builtinMod l r = throwEvalError ("builtins.mod: expected two integers, got " <> typeName l <> " and " <> typeName r)

builtinMin :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinMin a b = do
  aIsLess <- nixCompare a b
  pure (if aIsLess then a else b)

builtinMax :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinMax a b = do
  aIsLess <- nixCompare a b
  pure (if aIsLess then b else a)

builtinBitAnd :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinBitAnd (VInt a) (VInt b) = pure (VInt (a .&. b))
builtinBitAnd _ _ = throwEvalError "builtins.bitAnd: expected two integers"

builtinBitOr :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinBitOr (VInt a) (VInt b) = pure (VInt (a .|. b))
builtinBitOr _ _ = throwEvalError "builtins.bitOr: expected two integers"

builtinBitXor :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinBitXor (VInt a) (VInt b) = pure (VInt (xor a b))
builtinBitXor _ _ = throwEvalError "builtins.bitXor: expected two integers"

-- ---------------------------------------------------------------------------
-- Builtin implementations — attr set higher-order
-- ---------------------------------------------------------------------------

builtinMapAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinMapAttrs func (VAttrs attrs) =
  -- Each attr value is a deferred @f key val@, forced only on demand.
  -- Eagerly builds all thunks via attrSetMapWithKey — with C arena thunks
  -- (~16 bytes each), this is cheaper than the former MappedAttrs overhead
  -- and keeps all data off the GHC heap.
  -- Slot 0 = function, slot 1 = key, slot 2 = value.
  pure (VAttrs (attrSetMapWithKey deferAttr attrs))
  where
    deferAttr key valThunk =
      let (sp, sc) = buildCSlots [evaluated func, evaluated (mkStr key), valThunk]
          env = newMinimalEnv sp sc
       in mkSyntheticThunk env mapAttrsExpr
builtinMapAttrs _ other =
  throwEvalError ("builtins.mapAttrs: expected a set, got " <> typeName other)

-- | Shared expression for 'builtinMapAttrs'.  Allocated once as a CAF.
mapAttrsExpr :: Expr
mapAttrsExpr = EApp (EApp (EResolvedVar 0 0) (EResolvedVar 0 1)) (EResolvedVar 0 2)
{-# NOINLINE mapAttrsExpr #-}

builtinFunctionArgs :: (MonadEval m) => NixValue -> m NixValue
builtinFunctionArgs (VLambda _ formals _) = pure (formalsToAttrs formals)
builtinFunctionArgs (VBuiltin _ _) = pure (VAttrs (attrSetFromMap Map.empty))
-- Callable sets with __functionArgs metadata (from setFunctionArgs).
builtinFunctionArgs (VAttrs attrs)
  | Just faThunk <- attrSetLookup "__functionArgs" attrs = force faThunk
builtinFunctionArgs other =
  throwEvalError ("builtins.functionArgs: expected a function, got " <> typeName other)

formalsToAttrs :: EvalFormals -> NixValue
formalsToAttrs (EFName _) = VAttrs (attrSetFromMap Map.empty)
formalsToAttrs (EFSet formals _) = formalsListToAttrs formals
formalsToAttrs (EFNamedSet _ formals _) = formalsListToAttrs formals

formalsListToAttrs :: [EvalFormal] -> NixValue
formalsListToAttrs formals =
  VAttrs $
    attrSetFromMap $
      Map.fromList
        [(efName f, evaluated (VBool (isJust (efDefault f)))) | f <- formals]

-- | @builtins.setFunctionArgs f args@ — wraps @f@ in a callable attrset
-- with @__functor@ (so it remains callable) and @__functionArgs@ metadata.
-- Used by nixpkgs @lib.mirrorFunctionArgs@ / @lib.makeOverridable@.
--
-- @__functor@ is @self: f@ — a lambda that ignores @self@ and returns
-- the original function.  The function is captured in the closure env.
builtinSetFunctionArgs :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinSetFunctionArgs func (VAttrs argSpec) =
  -- Closure env holds the function at slot 0.  The __functor lambda
  -- body is EResolvedVar 1 0: level 1 (past _self's slot), index 0.
  let (sp, sc) = buildCSlots [evaluated func]
      closureEnv = newMinimalEnv sp sc
      bodyBcIdx = unsafePerformIO (compileExpr (EResolvedVar 1 0))
      -- __functor = self: __fn  (ignores self, returns the original function)
      functorLambda = VLambda closureEnv (EFName "_self") bodyBcIdx
   in pure $
        VAttrs $
          attrSetFromMap $
            Map.fromList
              [ ("__functor", evaluated functorLambda),
                ("__functionArgs", evaluated (VAttrs argSpec))
              ]
builtinSetFunctionArgs func args =
  throwEvalError ("builtins.setFunctionArgs: expected a set as second argument, got " <> typeName args <> " (func: " <> typeName func <> ")")

builtinZipAttrsWith :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinZipAttrsWith func (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  attrSets <- mapM forceToAttrSet thunks
  let merged = mergeAllAttrs attrSets
      -- Lazy: each result is a deferred f(name)(values) thunk, not eagerly
      -- evaluated.  Critical for nixpkgs evalModules fixpoint — config is a
      -- self-referencing lazy attrset that must be COMPUTED (holding the lazy
      -- result) before any individual attribute thunks are forced.
      resultPairs = map (deferZip func) (Map.toList merged)
  pure (VAttrs (attrSetFromMap (Map.fromList resultPairs)))
  where
    forceToAttrSet thunk = do
      val <- force thunk
      case val of
        VAttrs attrs -> pure (attrSetToMap attrs)
        _ -> throwEvalError "builtins.zipAttrsWith: list element must be a set"
    mergeAllAttrs = foldl' (\acc m -> Map.unionWith (++) acc (Map.map (: []) m)) Map.empty
    -- Slot 0 = function, slot 1 = name, slot 2 = values list.
    deferZip fn (key, thunkList) =
      let valueList = VList (clistFromThunks (map thunkToCPtr thunkList))
          (slots, slotCount) = buildCSlots [evaluated fn, evaluated (mkStr key), evaluated valueList]
          env = newMinimalEnv slots slotCount
       in (key, mkSyntheticThunk env mapAttrsExpr)
builtinZipAttrsWith _ other =
  throwEvalError ("builtins.zipAttrsWith: expected a list, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — string manipulation
-- ---------------------------------------------------------------------------

builtinReplaceStrings ::
  (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue
builtinReplaceStrings (VList fromCl) (VList toCl) (VStr input inputCtx) = do
  let fromThunks = map Thunk (clistThunks fromCl)
      toThunks = map Thunk (clistThunks toCl)
  froms <- mapM forceStr fromThunks
  toStrs <- mapM forceStr toThunks
  when (length froms /= length toStrs) $
    throwEvalError "builtins.replaceStrings: 'from' and 'to' must have the same length"
  let fromTexts = map fst froms
      toTexts = map fst toStrs
      -- Nix semantics: input context + 'to' string contexts (not 'from').
      -- Ideally only 'to' contexts for patterns that matched, but including
      -- all 'to' contexts is the common implementation.
      mergedCtx = inputCtx <> mconcat (map snd toStrs)
      pairs = zip fromTexts toTexts
  pure (VStr (replaceAll pairs input) mergedCtx)
  where
    forceStr thunk = do
      val <- force thunk
      case val of
        VStr s ctx -> pure (s, ctx)
        _ -> throwEvalError "builtins.replaceStrings: elements must be strings"
builtinReplaceStrings _ _ (VStr _ _) =
  throwEvalError "builtins.replaceStrings: first two arguments must be lists"
builtinReplaceStrings _ _ other =
  throwEvalError ("builtins.replaceStrings: expected a string, got " <> typeName other)

-- | Replace all occurrences, O(n) via chunk list + T.concat.
replaceAll :: [(Text, Text)] -> Text -> Text
replaceAll pairs input = T.concat (go input)
  where
    go remaining
      | T.null remaining =
          -- At end of string, still check for empty-from match
          case findMatch pairs remaining of
            Just (replacement, _, _) -> [replacement]
            Nothing -> []
      | otherwise = case findMatch pairs remaining of
          Just (replacement, rest, matched) ->
            if T.null matched
              then case T.uncons remaining of
                -- empty-from: insert replacement then advance 1 char
                Just (ch, after) -> replacement : T.singleton ch : go after
                Nothing -> [replacement]
              else replacement : go rest
          Nothing -> case T.uncons remaining of
            Just (ch, after) -> T.singleton ch : go after
            Nothing -> []
    findMatch [] _ = Nothing
    findMatch ((from, to) : rest) txt
      | T.null from = Just (to, txt, from)
      | Just suffix <- T.stripPrefix from txt = Just (to, suffix, from)
      | otherwise = findMatch rest txt

-- ---------------------------------------------------------------------------
-- Builtin implementations — regex (POSIX ERE via regex-tdfa)
-- ---------------------------------------------------------------------------

-- ---------------------------------------------------------------------------
-- Regex compilation cache
-- ---------------------------------------------------------------------------

-- | Global regex compilation cache.  Keyed by the raw pattern string
-- (including anchoring for match).  Idempotent memoization via
-- unsafePerformIO — same rationale as thunk memoization.
{-# NOINLINE regexCacheRef #-}
regexCacheRef :: IORef (Map Text RE.Regex)
regexCacheRef = unsafePerformIO (newIORef Map.empty)

-- | Compile a regex, using the global cache to avoid recompilation.
-- Returns Nothing for invalid patterns.  NOINLINE prevents GHC from
-- inlining and floating the unsafePerformIO reads.
{-# NOINLINE cachedCompileRegex #-}
cachedCompileRegex :: Text -> Maybe RE.Regex
cachedCompileRegex pat =
  unsafePerformIO $ do
    cache <- atomicModifyIORef' regexCacheRef (\c -> (c, c))
    case Map.lookup pat cache of
      Just compiled -> pure (Just compiled)
      Nothing -> case RE.makeRegexM (T.unpack pat) :: Maybe RE.Regex of
        Nothing -> pure Nothing
        Just compiled -> do
          atomicModifyIORef' regexCacheRef (\c -> (Map.insert pat compiled c, ()))
          pure (Just compiled)

-- ---------------------------------------------------------------------------
-- Regex builtins
-- ---------------------------------------------------------------------------

-- | @builtins.match regex str@: match a POSIX ERE against a string.
-- The regex is implicitly anchored (must match the entire string).
-- Returns @null@ if no match, or a list of capture group strings
-- (empty string for unmatched optional groups).
builtinMatch :: (MonadEval m) => NixValue -> NixValue -> m NixValue
-- Pre-compiled path: regex was compiled at partial-application time.
builtinMatch (VCompiledRegex (CompiledRegex _ compiled)) (VStr str _) =
  matchWithCompiled compiled str
-- Direct 2-arg call: use global compilation cache.
builtinMatch (VStr regex _) (VStr str _) =
  let anchored = "^" <> regex <> "$"
   in case cachedCompileRegex anchored of
        Nothing -> throwEvalError ("builtins.match: invalid regex: " <> regex)
        Just compiled -> matchWithCompiled compiled str
builtinMatch (VStr _ _) other =
  throwEvalError ("builtins.match: expected a string, got " <> typeName other)
builtinMatch (VCompiledRegex _) other =
  throwEvalError ("builtins.match: expected a string, got " <> typeName other)
builtinMatch other _ =
  throwEvalError ("builtins.match: expected a string (regex), got " <> typeName other)

-- | Shared match logic for pre-compiled and freshly-compiled regex paths.
matchWithCompiled :: (MonadEval m) => RE.Regex -> Text -> m NixValue
matchWithCompiled compiled str =
  let matches = matchAllText compiled (T.unpack str)
   in case matches of
        [] -> pure VNull
        (match : _) ->
          -- match is an Array of (String, (offset, len)) pairs.
          -- Index 0 is the full match; indices 1.. are capture groups.
          let groups = Array.elems match
              -- Skip index 0 (full match) — return only capture groups.
              captureGroups = drop 1 groups
              toThunk (s, _) = evaluated (mkStr (T.pack s))
           in pure (VList (clistFromThunks (map (thunkToCPtr . toThunk) captureGroups)))

-- | @builtins.split regex str@: split a string by a POSIX ERE.
-- Returns an alternating list of non-matched strings and match-group lists.
-- Example: @split "(x)" "axbxc"@ → @["a" ["x"] "b" ["x"] "c"]@
builtinSplit :: (MonadEval m) => NixValue -> NixValue -> m NixValue
-- Pre-compiled path: regex was compiled at partial-application time.
builtinSplit (VCompiledRegex (CompiledRegex _ compiled)) (VStr str _) =
  splitWithCompiled compiled str
-- Direct 2-arg call: use global compilation cache.
builtinSplit (VStr regex _) (VStr str _) =
  case cachedCompileRegex regex of
    Nothing -> throwEvalError ("builtins.split: invalid regex: " <> regex)
    Just compiled -> splitWithCompiled compiled str
builtinSplit (VStr _ _) other =
  throwEvalError ("builtins.split: expected a string, got " <> typeName other)
builtinSplit (VCompiledRegex _) other =
  throwEvalError ("builtins.split: expected a string, got " <> typeName other)
builtinSplit other _ =
  throwEvalError ("builtins.split: expected a string (regex), got " <> typeName other)

-- | Shared split logic for pre-compiled and freshly-compiled regex paths.
splitWithCompiled :: (MonadEval m) => RE.Regex -> Text -> m NixValue
splitWithCompiled compiled str =
  let allMatches = matchAllText compiled (T.unpack str)
      strText = T.unpack str
      result = buildSplitResult strText 0 allMatches
   in pure (VList (clistFromThunks (map thunkToCPtr result)))

-- | Build the alternating list for builtins.split.
buildSplitResult :: String -> Int -> [Array.Array Int (String, (Int, Int))] -> [Thunk]
buildSplitResult remaining pos [] =
  -- No more matches — emit the rest of the string.
  [evaluated (mkStr (T.pack (drop pos remaining)))]
buildSplitResult remaining pos (match : rest) =
  let elems = Array.elems match
      (_, (matchStart, matchLen)) = case elems of
        (full : _) -> full
        [] -> ("", (pos, 0)) -- defensive: should not happen from matchAllText
        -- Text before this match
      before = T.pack (take (matchStart - pos) (drop pos remaining))
      -- Capture groups (indices 1..)
      groups = drop 1 (Array.elems match)
      groupThunks = map (\(s, _) -> evaluated (mkStr (T.pack s))) groups
      -- Continue after this match
      afterPos = matchStart + matchLen
   in evaluated (mkStr before)
        : evaluated (VList (clistFromThunks (map thunkToCPtr groupThunks)))
        : buildSplitResult remaining afterPos rest

builtinCompareVersions :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinCompareVersions (VStr a _) (VStr b _) =
  pure (VInt (compareVersionParts (splitVersionStr a) (splitVersionStr b)))
builtinCompareVersions _ _ = throwEvalError "builtins.compareVersions: expected two strings"

-- | Convert 'Ordering' to the Nix compareVersions convention: -1, 0, 1.
ordToNix :: Ordering -> Int64
ordToNix LT = -1
ordToNix EQ = 0
ordToNix GT = 1

compareVersionParts :: [Text] -> [Text] -> Int64
compareVersionParts [] [] = 0
compareVersionParts [] (_ : _) = -1
compareVersionParts (_ : _) [] = 1
compareVersionParts (a : as) (b : bs) =
  case compareComponent a b of
    EQ -> compareVersionParts as bs
    cmp -> ordToNix cmp

compareComponent :: Text -> Text -> Ordering
compareComponent a b
  | a == b = EQ
  | allDigits a && allDigits b = compare (readInt a) (readInt b)
  -- "pre" sorts before everything else (Nix pre-release convention)
  | a == "pre" = LT
  | b == "pre" = GT
  | a == "" = LT
  | b == "" = GT
  -- Alphabetic components sort before numeric in Nix
  | isAlphaComp a && allDigits b = LT
  | allDigits a && isAlphaComp b = GT
  | otherwise = compare a b
  where
    allDigits t = not (T.null t) && T.all isDigit t
    isAlphaComp t = case T.uncons t of
      Just (c, _) -> isAlpha c
      Nothing -> False
    readInt :: Text -> Int64
    readInt = T.foldl' (\acc c -> acc * 10 + fromIntegral (digitToInt c)) (0 :: Int64)

splitVersionStr :: Text -> [Text]
splitVersionStr t
  | T.null t = []
  | otherwise =
      let (component, rest) = spanComponent t
       in component : splitVersionAfterComponent rest

splitVersionAfterComponent :: Text -> [Text]
splitVersionAfterComponent t = case T.uncons t of
  Nothing -> []
  Just ('.', rest) -> splitVersionStr rest
  Just _ -> splitVersionStr t

spanComponent :: Text -> (Text, Text)
spanComponent t = case T.uncons t of
  Nothing -> ("", "")
  Just (c, rest)
    | isDigit c -> T.span isDigit t
    | isAlpha c -> T.span isAlpha t
    | otherwise -> (T.singleton c, rest)

builtinSplitVersion :: (MonadEval m) => NixValue -> m NixValue
builtinSplitVersion (VStr s _) =
  pure (VList (clistFromThunks (map (thunkToCPtr . evaluated . mkStr) (splitVersionComponents s))))
builtinSplitVersion other =
  throwEvalError ("builtins.splitVersion: expected a string, got " <> typeName other)

splitVersionComponents :: Text -> [Text]
splitVersionComponents t = case T.uncons t of
  Nothing -> []
  Just ('.', rest) -> splitVersionComponents rest
  Just (c, _)
    | isDigit c ->
        let (digits, rest) = T.span isDigit t
         in digits : splitVersionComponents rest
    | isAlpha c ->
        let (alpha, rest) = T.span isAlpha t
         in alpha : splitVersionComponents rest
    | otherwise ->
        -- Non-alphanumeric separator (e.g. '-', '_'): consume as single char
        let (_, rest) = T.splitAt 1 t
         in splitVersionComponents rest

builtinParseDrvName :: (MonadEval m) => NixValue -> m NixValue
builtinParseDrvName (VStr s _) =
  let (name, version) = parseName s
   in pure
        ( VAttrs
            ( attrSetFromMap $
                Map.fromList
                  [ ("name", evaluated (mkStr name)),
                    ("version", evaluated (mkStr version))
                  ]
            )
        )
builtinParseDrvName other =
  throwEvalError ("builtins.parseDrvName: expected a string, got " <> typeName other)

parseName :: Text -> (Text, Text)
parseName t =
  case findVersionDash t 0 of
    Nothing -> (t, "")
    Just idx -> (T.take idx t, T.drop (idx + 1) t)

findVersionDash :: Text -> Int -> Maybe Int
findVersionDash t idx = case T.uncons (T.drop idx t) of
  Nothing -> Nothing
  Just ('-', after)
    | Just (d, _) <- T.uncons after,
      isDigit d ->
        Just idx
  Just _ -> findVersionDash t (idx + 1)

-- ---------------------------------------------------------------------------
-- Builtin implementations — serialization + hashing
-- ---------------------------------------------------------------------------

builtinToJSON :: (MonadEval m) => NixValue -> m NixValue
builtinToJSON val = do
  (json, ctx) <- valueToJSON val
  pure (VStr json ctx)

valueToJSON :: (MonadEval m) => NixValue -> m (Text, StringContext)
valueToJSON VNull = pure ("null", emptyContext)
valueToJSON (VBool True) = pure ("true", emptyContext)
valueToJSON (VBool False) = pure ("false", emptyContext)
valueToJSON (VInt n) = pure (T.pack (show n), emptyContext)
valueToJSON (VFloat f) = pure (formatNixFloat f, emptyContext)
valueToJSON (VStr s ctx) = pure (jsonEscapeString s, ctx)
valueToJSON (VList cl) = do
  let thunks = map Thunk (clistThunks cl)
  vals <- mapM force thunks
  results <- mapM valueToJSON vals
  let jsonVals = map fst results
      ctx = mconcat (map snd results)
  pure ("[" <> T.intercalate "," jsonVals <> "]", ctx)
valueToJSON (VAttrs attrs) =
  -- Derivation-like attrsets (with outPath) coerce to string in toJSON,
  -- matching C++ Nix behavior.
  case attrSetLookup "outPath" attrs of
    Just outThunk -> do
      outVal <- force outThunk
      (s, ctx) <- coerceToString force applyValue outVal
      pure (jsonEscapeString s, ctx)
    Nothing -> do
      let m = attrSetToMap attrs
          sortedKeys = Map.keys m
      results <- mapM (jsonPair m) sortedKeys
      let pairs = map fst results
          ctx = mconcat (map snd results)
      pure ("{" <> T.intercalate "," pairs <> "}", ctx)
  where
    jsonPair attrMap key = case Map.lookup key attrMap of
      Nothing -> pure ("", emptyContext)
      Just thunk -> do
        val <- force thunk
        (jsonVal, ctx) <- valueToJSON val
        pure (jsonEscapeString key <> ":" <> jsonVal, ctx)
valueToJSON (VPath p) = pure (jsonEscapeString p, emptyContext)
valueToJSON (VLambda {}) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"
valueToJSON (VBuiltin _ _) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"
valueToJSON (VDerivation _) = throwEvalError "builtins.toJSON: cannot convert a derivation to JSON"
valueToJSON (VCompiledRegex _) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"

jsonEscapeString :: Text -> Text
jsonEscapeString s = "\"" <> T.concatMap escapeChar s <> "\""
  where
    escapeChar '"' = "\\\""
    escapeChar '\\' = "\\\\"
    escapeChar '\n' = "\\n"
    escapeChar '\r' = "\\r"
    escapeChar '\t' = "\\t"
    escapeChar c
      | ord c < 0x20 = "\\u" <> T.pack (padHex 4 (showHex' (ord c)))
      | otherwise = T.singleton c
    padHex n str = replicate (n - length str) '0' ++ str
    showHex' 0 = "0"
    showHex' num = go num ""
      where
        go 0 acc = acc
        go v acc =
          let (q, r) = quotRem v 16
           in go q (hexDigit r : acc)

-- | Safe hex digit lookup (total for 0–15).
hexDigit :: Int -> Char
hexDigit n
  | n >= 0 && n <= 9 = chr (ord '0' + n)
  | n >= 10 && n <= 15 = chr (ord 'a' + n - 10)
  | otherwise = '?' -- unreachable for valid hex

builtinFromJSON :: (MonadEval m) => NixValue -> m NixValue
builtinFromJSON (VStr s _) = case parseJSON (T.strip s) of
  Just (val, rest)
    | T.null (T.strip rest) -> pure val
    | otherwise -> throwEvalError "builtins.fromJSON: trailing content after JSON value"
  Nothing -> throwEvalError "builtins.fromJSON: invalid JSON"
builtinFromJSON other =
  throwEvalError ("builtins.fromJSON: expected a string, got " <> typeName other)

parseJSON :: Text -> Maybe (NixValue, Text)
parseJSON t = case T.uncons (T.stripStart t) of
  Nothing -> Nothing
  Just ('n', rest)
    | Just suffix <- T.stripPrefix "ull" rest -> Just (VNull, suffix)
  Just ('t', rest)
    | Just suffix <- T.stripPrefix "rue" rest -> Just (VBool True, suffix)
  Just ('f', rest)
    | Just suffix <- T.stripPrefix "alse" rest -> Just (VBool False, suffix)
  Just ('"', _) -> parseJSONString (T.stripStart t)
  Just ('[', rest) -> parseJSONArray rest
  Just ('{', rest) -> parseJSONObject rest
  Just (c, _)
    | c == '-' || isDigit c -> parseJSONNumber (T.stripStart t)
  _ -> Nothing

parseJSONString :: Text -> Maybe (NixValue, Text)
parseJSONString t = case T.uncons t of
  Just ('"', rest) ->
    let (strVal, remaining) = parseJSONStringContent rest
     in Just (mkStr strVal, remaining)
  _ -> Nothing

-- | Parse JSON string content, O(n) via chunk list + T.concat.
parseJSONStringContent :: Text -> (Text, Text)
parseJSONStringContent = go []
  where
    go !chunks t = case T.uncons t of
      Nothing -> (T.concat (reverse chunks), "")
      Just ('"', rest) -> (T.concat (reverse chunks), rest)
      Just ('\\', rest) -> case T.uncons rest of
        Just ('"', r) -> go ("\"" : chunks) r
        Just ('\\', r) -> go ("\\" : chunks) r
        Just ('/', r) -> go ("/" : chunks) r
        Just ('n', r) -> go ("\n" : chunks) r
        Just ('r', r) -> go ("\r" : chunks) r
        Just ('t', r) -> go ("\t" : chunks) r
        Just ('u', r) -> case parseHex4 r of
          Just (hi, r2)
            -- UTF-16 surrogate pair: high surrogate followed by \uXXXX low
            | hi >= 0xD800 && hi <= 0xDBFF ->
                case T.stripPrefix "\\u" r2 of
                  Just r3 -> case parseHex4 r3 of
                    Just (lo, r4)
                      | lo >= 0xDC00 && lo <= 0xDFFF ->
                          let combined = 0x10000 + (hi - 0xD800) * 0x400 + (lo - 0xDC00)
                           in go (T.singleton (chr combined) : chunks) r4
                    _ -> go (T.singleton (chr hi) : chunks) r2
                  Nothing -> go (T.singleton (chr hi) : chunks) r2
          Just (codepoint, r2) ->
            go (T.singleton (chr codepoint) : chunks) r2
          Nothing -> go ("u" : chunks) r
        _ -> (T.concat (reverse chunks), rest)
      Just (c, rest) -> go (T.singleton c : chunks) rest

parseHex4 :: Text -> Maybe (Int, Text)
parseHex4 t
  | T.length t >= 4 =
      let hex = T.take 4 t
       in if T.all isHexDigit hex
            then Just (readHex4 hex, T.drop 4 t)
            else Nothing
  | otherwise = Nothing

readHex4 :: Text -> Int
readHex4 = T.foldl' (\acc c -> acc * 16 + digitToInt c) 0

parseJSONNumber :: Text -> Maybe (NixValue, Text)
parseJSONNumber t =
  let (numStr, rest) = T.span (\c -> isDigit c || c == '.' || c == '-' || c == 'e' || c == 'E' || c == '+') t
   in if T.null numStr
        then Nothing
        else
          if T.any (\c -> c == '.' || c == 'e' || c == 'E') numStr
            then case reads (T.unpack numStr) :: [(Double, String)] of
              [(d, "")] -> Just (VFloat d, rest)
              _ -> Nothing
            else case reads (T.unpack numStr) :: [(Int64, String)] of
              [(n, "")] -> Just (VInt n, rest)
              _ -> Nothing

parseJSONArray :: Text -> Maybe (NixValue, Text)
parseJSONArray t = parseJSONArrayElements (T.stripStart t) []

parseJSONArrayElements :: Text -> [Thunk] -> Maybe (NixValue, Text)
parseJSONArrayElements t acc = case T.uncons (T.stripStart t) of
  Just (']', rest) -> Just (VList (clistFromThunks (map thunkToCPtr (reverse acc))), rest)
  _ -> case parseJSON t of
    Just (val, rest) ->
      let stripped = T.stripStart rest
       in case T.uncons stripped of
            Just (',', rest2) -> parseJSONArrayElements rest2 (evaluated val : acc)
            Just (']', rest2) -> Just (VList (clistFromThunks (map thunkToCPtr (reverse (evaluated val : acc)))), rest2)
            _ -> Nothing
    Nothing -> Nothing

parseJSONObject :: Text -> Maybe (NixValue, Text)
parseJSONObject t = parseJSONObjectEntries (T.stripStart t) Map.empty

parseJSONObjectEntries :: Text -> Map Text Thunk -> Maybe (NixValue, Text)
parseJSONObjectEntries t acc = case T.uncons (T.stripStart t) of
  Just ('}', rest) -> Just (VAttrs (attrSetFromMap acc), rest)
  _ -> case parseJSONString (T.stripStart t) of
    Just (VStr key _, rest) -> case T.uncons (T.stripStart rest) of
      Just (':', rest2) -> case parseJSON rest2 of
        Just (val, rest3) ->
          let stripped = T.stripStart rest3
              updated = Map.insert key (evaluated val) acc
           in case T.uncons stripped of
                Just (',', rest4) -> parseJSONObjectEntries rest4 updated
                Just ('}', rest4) -> Just (VAttrs (attrSetFromMap updated), rest4)
                _ -> Nothing
        Nothing -> Nothing
      _ -> Nothing
    _ -> Nothing

builtinHashString :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinHashString (VStr algo _) (VStr input _) =
  hashBytesWithAlgo "hashString" algo (TE.encodeUtf8 input)
builtinHashString (VStr _ _) other =
  throwEvalError ("builtins.hashString: expected a string, got " <> typeName other)
builtinHashString other _ =
  throwEvalError ("builtins.hashString: expected a string, got " <> typeName other)

digestToHex :: (BA.ByteArrayAccess a) => a -> Text
digestToHex digest =
  let bytes = BA.unpack digest
   in T.pack (concatMap byteToHex bytes)

-- ---------------------------------------------------------------------------
-- Builtin implementations — deep evaluation
-- ---------------------------------------------------------------------------

builtinDeepSeq :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinDeepSeq first second = do
  deepForce first
  pure second

deepForce :: (MonadEval m) => NixValue -> m ()
deepForce (VList cl) = mapM_ ((force >=> deepForce) . Thunk) (clistThunks cl)
deepForce (VAttrs attrs) = mapM_ (force >=> deepForce) (attrSetElems attrs)
deepForce _ = pure ()

-- | @builtins.seq a b@ — evaluate @a@ to WHNF, then return @b@.
builtinSeq :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinSeq !_first = pure

-- | @builtins.trace msg val@ — print @msg@ to stderr, return @val@.
builtinTrace :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinTrace msgVal result = do
  msg <- case msgVal of
    VStr s _ -> pure s
    other -> pure (showValueForTrace other)
  traceMessage ("trace: " <> msg)
  pure result

-- | @builtins.warn msg val@ — print warning to stderr, return @val@.
builtinWarn :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinWarn msgVal result = do
  msg <- case msgVal of
    VStr s _ -> pure s
    other -> pure (showValueForTrace other)
  traceMessage ("warning: " <> msg)
  pure result

-- | Pretty-print a value for trace/warn, matching C++ Nix's printValue.
showValueForTrace :: NixValue -> Text
showValueForTrace (VInt n) = T.pack (show n)
showValueForTrace (VFloat f) = formatNixFloat f
showValueForTrace (VBool True) = "true"
showValueForTrace (VBool False) = "false"
showValueForTrace VNull = "null"
showValueForTrace (VPath p) = p
showValueForTrace other = "<<" <> typeName other <> ">>"

-- ---------------------------------------------------------------------------
-- Builtin implementations — graph traversal
-- ---------------------------------------------------------------------------

builtinGenericClosure :: (MonadEval m) => NixValue -> m NixValue
builtinGenericClosure (VAttrs attrs) = do
  startSetThunk <-
    maybe (throwEvalError "builtins.genericClosure: missing 'startSet'") pure $
      attrSetLookup "startSet" attrs
  operatorThunk <-
    maybe (throwEvalError "builtins.genericClosure: missing 'operator'") pure $
      attrSetLookup "operator" attrs
  startSetVal <- force startSetThunk
  operatorVal <- force operatorThunk
  case startSetVal of
    VList cl -> do
      let items = map Thunk (clistThunks cl)
      result <- closureLoop operatorVal (Seq.fromList items) [] []
      pure (VList (clistFromThunks (map (thunkToCPtr . evaluated) result)))
    _ -> throwEvalError "builtins.genericClosure: 'startSet' must be a list"
builtinGenericClosure other =
  throwEvalError ("builtins.genericClosure: expected a set, got " <> typeName other)

-- | BFS loop for genericClosure.  Uses Data.Sequence for O(1) queue
-- append (the old list-based version was O(n) per operator call).
-- seenKeys is still a linear scan — Nix value equality is monadic so
-- Set/HashMap is not directly applicable without specialising on key type.
closureLoop ::
  (MonadEval m) =>
  NixValue ->
  Seq Thunk ->
  [NixValue] ->
  [NixValue] ->
  m [NixValue]
closureLoop _ Empty _ acc = pure (reverse acc)
closureLoop operator (thunk :<| rest) seenKeys acc = do
  item <- force thunk
  key <- extractKey item
  alreadySeen <- keyInList key seenKeys
  if alreadySeen
    then closureLoop operator rest seenKeys acc
    else do
      newItems <- applyValue operator item
      case newItems of
        VList newCl ->
          closureLoop operator (rest <> Seq.fromList (map Thunk (clistThunks newCl))) (key : seenKeys) (item : acc)
        _ -> throwEvalError "builtins.genericClosure: operator must return a list"

extractKey :: (MonadEval m) => NixValue -> m NixValue
extractKey (VAttrs attrs) =
  case attrSetLookup "key" attrs of
    Just thunk -> force thunk
    Nothing -> throwEvalError "builtins.genericClosure: item missing 'key' attribute"
extractKey _ = throwEvalError "builtins.genericClosure: item must be a set with 'key'"

keyInList :: (MonadEval m) => NixValue -> [NixValue] -> m Bool
keyInList _ [] = pure False
keyInList key (seen : rest) = do
  eq <- nixEqual force key seen
  if eq then pure True else keyInList key rest

-- ---------------------------------------------------------------------------
-- IO builtins (delegate to MonadEval methods)
-- ---------------------------------------------------------------------------

-- | Coerce a value to a path 'Text'.  Accepts 'VPath' and 'VStr';
-- throws a type error for anything else.
coerceToPath :: (MonadEval m) => Text -> NixValue -> m Text
coerceToPath _ (VPath p) = pure p
coerceToPath _ (VStr s _) = pure s
coerceToPath name other =
  throwEvalError ("builtins." <> name <> ": expected a path or string, got " <> typeName other)

builtinImport :: (MonadEval m) => NixValue -> m NixValue
builtinImport (VPath p) = importFile p
builtinImport (VStr s _) = importFile s
builtinImport other =
  throwEvalError ("import: expected a path or string, got " <> typeName other)

builtinReadFile :: (MonadEval m) => NixValue -> m NixValue
builtinReadFile val = do
  p <- coerceToPath "readFile" val
  mkStr <$> readFileText p

builtinPathExists :: (MonadEval m) => NixValue -> m NixValue
builtinPathExists val = do
  p <- coerceToPath "pathExists" val
  VBool <$> doesPathExist p

builtinReadDir :: (MonadEval m) => NixValue -> m NixValue
builtinReadDir val = do
  p <- coerceToPath "readDir" val
  entries <- listDirectory p
  pure (VAttrs (attrSetFromMap (Map.fromList [(name, evaluated (mkStr fileType)) | (name, fileType) <- entries])))

-- ---------------------------------------------------------------------------
-- Builtin implementations — environment + paths
-- ---------------------------------------------------------------------------

builtinGetEnv :: (MonadEval m) => NixValue -> m NixValue
builtinGetEnv (VStr name _) = mkStr <$> getEnvVar name
builtinGetEnv other =
  throwEvalError ("builtins.getEnv: expected a string, got " <> typeName other)

builtinToPath :: (MonadEval m) => NixValue -> m NixValue
builtinToPath (VPath p) = pure (VPath p)
builtinToPath (VStr s _) = case T.uncons s of
  Nothing -> throwEvalError "builtins.toPath: empty path"
  Just ('/', _) -> pure (VPath s)
  Just _ -> throwEvalError ("builtins.toPath: path must be absolute, got " <> s)
builtinToPath other =
  throwEvalError ("builtins.toPath: expected a string or path, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — store path operations
-- ---------------------------------------------------------------------------

builtinPlaceholder :: (MonadEval m) => NixValue -> m NixValue
builtinPlaceholder (VStr outputName _) = pure (mkStr (hashPlaceholder outputName))
builtinPlaceholder other =
  throwEvalError ("builtins.placeholder: expected a string, got " <> typeName other)

builtinStorePath :: (MonadEval m) => NixValue -> m NixValue
builtinStorePath (VPath p) = validateStorePath p
builtinStorePath (VStr s _) = validateStorePath s
builtinStorePath other =
  throwEvalError ("builtins.storePath: expected a path or string, got " <> typeName other)

validateStorePath :: (MonadEval m) => Text -> m NixValue
validateStorePath p
  | storeDirPrefix `T.isPrefixOf` p,
    T.length p > T.length storeDirPrefix,
    let basename = T.drop (T.length storeDirPrefix) p,
    T.length basename >= 33,
    Just ('-', _) <- T.uncons (T.drop 32 basename) =
      pure (VPath p)
  | otherwise =
      throwEvalError ("builtins.storePath: not a valid store path: " <> p)

-- ---------------------------------------------------------------------------
-- Builtin implementations — Nix search path
-- ---------------------------------------------------------------------------

builtinFindFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinFindFile (VList cl) (VStr name _) = do
  let searchPath = map Thunk (clistThunks cl)
  entries <- mapM forceSearchEntry searchPath
  findFirst entries name
builtinFindFile (VList _) other =
  throwEvalError ("builtins.findFile: expected a string, got " <> typeName other)
builtinFindFile other _ =
  throwEvalError ("builtins.findFile: expected a list, got " <> typeName other)

-- | Extract {prefix, path} from a search path entry thunk.
forceSearchEntry :: (MonadEval m) => Thunk -> m (Text, Text)
forceSearchEntry thunk = do
  val <- force thunk
  case val of
    VAttrs attrs -> do
      prefixThunk <-
        maybe (throwEvalError "builtins.findFile: entry missing 'prefix'") pure $
          attrSetLookup "prefix" attrs
      pathThunk <-
        maybe (throwEvalError "builtins.findFile: entry missing 'path'") pure $
          attrSetLookup "path" attrs
      prefixVal <- force prefixThunk
      pathVal <- force pathThunk
      prefix <- case prefixVal of
        VStr s _ -> pure s
        _ -> throwEvalError "builtins.findFile: 'prefix' must be a string"
      path <- case pathVal of
        VStr s _ -> pure s
        VPath s -> pure s
        _ -> throwEvalError "builtins.findFile: 'path' must be a string or path"
      pure (prefix, path)
    _ -> throwEvalError "builtins.findFile: search path entry must be a set"

-- | Iterate search path entries, checking for a match.
findFirst :: (MonadEval m) => [(Text, Text)] -> Text -> m NixValue
findFirst [] name =
  throwEvalError ("file '" <> name <> "' was not found in the Nix search path")
findFirst ((prefix, path) : rest) name
  | prefix == name || (not (T.null prefix) && (prefix <> "/") `T.isPrefixOf` name) =
      let suffix = if prefix == name then "" else T.drop (T.length prefix + 1) name
          candidate = if T.null suffix then path else path <> "/" <> suffix
       in do
            exists <- doesPathExist candidate
            if exists
              then pure (VPath candidate)
              else findFirst rest name
  | T.null prefix =
      let candidate = path <> "/" <> name
       in do
            exists <- doesPathExist candidate
            if exists
              then pure (VPath candidate)
              else findFirst rest name
  | otherwise = findFirst rest name

-- ---------------------------------------------------------------------------
-- Builtin implementations — store file creation
-- ---------------------------------------------------------------------------

builtinToFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinToFile (VStr name _) (VStr contents _) = do
  storePath <- writeToStore name contents
  pure (VPath storePath)
builtinToFile (VStr _ _) other =
  throwEvalError ("builtins.toFile: expected a string, got " <> typeName other)
builtinToFile other _ =
  throwEvalError ("builtins.toFile: expected a string, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — scoped import
-- ---------------------------------------------------------------------------

builtinScopedImport :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinScopedImport (VAttrs attrs) pathVal = do
  p <- coerceToPath "scopedImport" pathVal
  let scope = Map.toList (attrSetToMap attrs)
  scopedImportFile scope p
builtinScopedImport other _ =
  throwEvalError ("builtins.scopedImport: expected a set, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — network fetchers
-- ---------------------------------------------------------------------------

builtinFetchurl :: (MonadEval m) => NixValue -> m NixValue
builtinFetchurl (VStr url _) = fetchUrlSimple url Nothing
builtinFetchurl (VAttrs attrs) = do
  url <- forceAttrStr "builtins.fetchurl" "url" attrs
  sha256 <- forceOptionalAttrStr attrs "sha256"
  fetchUrlSimple url sha256
builtinFetchurl other =
  throwEvalError ("builtins.fetchurl: expected a string or set, got " <> typeName other)

builtinFetchTarball :: (MonadEval m) => NixValue -> m NixValue
builtinFetchTarball (VStr url _) = fetchAndExtractTarball url
builtinFetchTarball (VAttrs attrs) = do
  url <- forceAttrStr "builtins.fetchTarball" "url" attrs
  fetchAndExtractTarball url
builtinFetchTarball other =
  throwEvalError ("builtins.fetchTarball: expected a string or set, got " <> typeName other)

-- | Download a tarball, extract it, and return the path to the extracted
-- directory.  Uses a content-hashed temp directory.  Downloads and extracts
-- in a single shell pipeline to avoid binary-as-text encoding issues.
fetchAndExtractTarball :: (MonadEval m) => Text -> m NixValue
fetchAndExtractTarball url = do
  sysTmp <- getTempDir
  let urlHash = sha256Hex (TE.encodeUtf8 url)
      extractDir = sysTmp <> "/nova-nix-tarball-" <> urlHash
  -- Single pipeline: mkdir, download, extract with --strip-components=1
  -- The -- separator prevents argument injection from the URL.
  (code, _, errOut) <-
    runProcess
      "sh"
      [ "-c",
        "mkdir -p \"$1\" && curl -sSfL -- \"$2\" | tar -xz -C \"$1\" --strip-components=1",
        "--",
        extractDir,
        url
      ]
      ""
  case code of
    0 -> pure (VPath extractDir)
    _ -> throwEvalError ("builtins.fetchTarball: " <> errOut)

builtinFetchGit :: (MonadEval m) => NixValue -> m NixValue
builtinFetchGit (VStr url _) = do
  sysTmp <- getTempDir
  let urlHash = sha256Hex (TE.encodeUtf8 url)
      cloneDir = sysTmp <> "/nova-nix-fetchgit-" <> urlHash
  (code, _, errOut) <- runProcess "git" ["clone", "--depth", "1", "--", url, cloneDir] ""
  case code of
    0 -> pure (VPath cloneDir)
    _ -> throwEvalError ("builtins.fetchGit: git clone failed: " <> errOut)
builtinFetchGit (VAttrs attrs) = do
  url <- forceAttrStr "builtins.fetchGit" "url" attrs
  builtinFetchGit (mkStr url)
builtinFetchGit other =
  throwEvalError ("builtins.fetchGit: expected a string or set, got " <> typeName other)

-- | Resolve the system temp directory.  Checks @TMPDIR@ (Unix), then
-- @TEMP@ (Windows), falls back to @\/tmp@.
getTempDir :: (MonadEval m) => m Text
getTempDir = do
  candidates <- mapM getEnvVar ["TMPDIR", "TEMP"]
  pure (fromMaybe "/tmp" (find (not . T.null) candidates))

-- | Fetch a URL and optionally verify its hash.
fetchUrlSimple :: (MonadEval m) => Text -> Maybe Text -> m NixValue
fetchUrlSimple url _sha256 = do
  (code, stdout, stderr) <- runProcess "curl" ["-sSfL", "--", url] ""
  if code /= 0
    then throwEvalError ("fetch failed: " <> stderr)
    else do
      storePath <- writeToStore "fetchurl-result" stdout
      pure (VPath storePath)

-- | Force a required string attribute from an attrset, using full Nix string
-- coercion (VStr, VPath, VInt, VBool, VAttrs via __toString/outPath).
forceAttrStr :: (MonadEval m) => Text -> Text -> AttrSet -> m Text
forceAttrStr builtin key attrs =
  case attrSetLookup key attrs of
    Nothing -> throwEvalError (builtin <> ": missing required attribute '" <> key <> "'")
    Just thunk -> do
      val <- force thunk
      result <- catchEvalError (coerceToString force applyValue val)
      case result of
        Right (s, _ctx) -> pure s
        Left _ -> throwEvalError (builtin <> ": '" <> key <> "' must be a string")

-- | Force an optional string attribute via full Nix coercion.
forceOptionalAttrStr :: (MonadEval m) => AttrSet -> Text -> m (Maybe Text)
forceOptionalAttrStr attrs key =
  case attrSetLookup key attrs of
    Nothing -> pure Nothing
    Just thunk -> do
      val <- force thunk
      result <- catchEvalError (coerceToString force applyValue val)
      case result of
        Right (s, _ctx) -> pure (Just s)
        Left _ -> pure Nothing

-- ---------------------------------------------------------------------------
-- Builtin implementations — derivation construction
-- ---------------------------------------------------------------------------

-- | Eager derivation computation — @builtins.derivationStrict@.  Forces all
-- input attrs into env vars, content-hashes, and returns the full derivation
-- attrset (drvPath, outPath, per-output, _derivation).  Called LAZILY by the
-- @derivation@ wrapper ('builtinDerivationLazy'), so forcing a derivation to
-- WHNF never forces this — matching C++ Nix's derivationStrict/derivation split.
builtinDerivationStrict :: (MonadEval m) => NixValue -> m NixValue
builtinDerivationStrict (VAttrs attrs) = do
  -- Extract required attributes
  drvName <- forceAttrStr "derivation" "name" attrs
  system <- forceAttrStr ("derivation \"" <> drvName <> "\"") "system" attrs
  builder <- forceAttrStr ("derivation \"" <> drvName <> "\"") "builder" attrs

  -- Extract optional outputs (default ["out"])
  outputNames <- case attrSetLookup "outputs" attrs of
    Nothing -> pure ["out"]
    Just thunk -> do
      val <- force thunk
      case val of
        VList cl -> mapM (forceToText . Thunk) (clistThunks cl)
        _ -> throwEvalError "derivation: 'outputs' must be a list of strings"

  -- Extract optional args (default []).  Path literals in args (e.g. stdenv's
  -- ./default-builder.sh) are copied into the store; their source paths flow
  -- into inputSrcs via the returned context.
  (builderArgs, argsContext) <- case attrSetLookup "args" attrs of
    Nothing -> pure ([], mempty)
    Just thunk -> do
      val <- force thunk
      case val of
        VList cl -> do
          parts <- mapM (\t -> force (Thunk t) >>= coerceToStoreString) (clistThunks cl)
          pure (map fst parts, mconcat (map snd parts))
        _ -> throwEvalError "derivation: 'args' must be a list of strings"

  -- Materialize once, reuse for both env collection and result merge
  let materialized = attrSetToMap attrs

  -- Collect string-coercible attrs into the build env, EXCLUDING "args"
  -- (C++ Nix puts args in the Derive() args field, never the env).  The
  -- per-output env vars ($out, …) are added below.  Carries merged context.
  (drvEnvPairs, envContext) <- collectDrvEnvWithContext (Map.delete "__ignoreNulls" (Map.delete "args" materialized))

  let fullContext = envContext <> argsContext
      inputDrvs = extractInputDrvs fullContext
      inputSrcs = extractInputSrcs fullContext
      platform = textToPlatform system
      baseEnv = Map.fromList drvEnvPairs
      drvRefs = inputSrcs ++ Map.keys inputDrvs
      drvFileName = drvName <> ".drv"
      -- Build a Derivation sharing this call's inputs/platform/builder/args.
      mkDrv outs env =
        Derivation
          { drvOutputs = outs,
            drvInputDrvs = inputDrvs,
            drvInputSrcs = inputSrcs,
            drvPlatform = platform,
            drvBuilder = builder,
            drvArgs = builderArgs,
            drvEnv = env
          }
      -- Output carrying only its name; the path is masked at render time.
      maskedOutput name = DerivationOutput name (StorePath "" "") "" ""
      -- Resolve an input derivation's modulo hash (hex) from the cache,
      -- populated bottom-up by earlier 'builtinDerivationStrict' calls.
      resolveInputModulo (sp, outs) = do
        let inputPathText = storePathToText defaultStoreDir sp
        cached <- lookupDrvHash inputPathText
        case cached of
          Just hex -> pure (hex, outs)
          Nothing -> do
            -- Eval is bottom-up, so inputs evaluated this session are always
            -- cached by the time a dependent hashes.  A miss means an input
            -- referenced but not evaluated here (a pure-eval synthetic context,
            -- or a pre-built store drv): fall back to its store hash so
            -- evaluation still produces a value, and warn for visibility.
            traceMessage
              ("derivation: input modulo hash not cached, using store hash for " <> inputPathText)
            pure (spHash sp, outs)

  -- Fixed-output derivations (fetchurl etc.) are content-addressed and hash
  -- via the @fixed:out:@ scheme; input-addressed derivations recurse through
  -- the modulo hashes of their inputs.
  mFixed <- detectFixedOutput attrs

  (drvPathText, drvSP, outPaths, completeDrv) <- case mFixed of
    Just (foAlgo, foMode, foDigest) -> do
      let foPath = makeFixedOutputPath drvName foAlgo foMode foDigest
          foPathText = storePathToText defaultStoreDir foPath
          algoField = (if foMode == "recursive" then "r:" else "") <> foAlgo
          foHashHex = bytesToHex foDigest
          foModulo =
            sha256Digest
              (TE.encodeUtf8 ("fixed:out:" <> algoField <> ":" <> foHashHex <> ":" <> foPathText))
          contents =
            mkDrv
              [DerivationOutput "out" foPath algoField foHashHex]
              (Map.insert "out" foPathText baseEnv)
          drvSp = makeTextPath drvFileName (sha256Digest (TE.encodeUtf8 (toATerm contents))) drvRefs
          drvText = storePathToText defaultStoreDir drvSp
      cacheDrvHash drvText (bytesToHex foModulo)
      pure (drvText, drvSp, [("out", foPathText)], contents)
    Nothing -> do
      inputSubst <- mapM resolveInputModulo (Map.toList inputDrvs)
      let maskedEnv = foldr (`Map.insert` "") baseEnv outputNames
          maskedDrv = mkDrv (map maskedOutput outputNames) maskedEnv
          -- Masked modulo hash → this derivation's own output paths.
          moduloMasked = sha256Digest (TE.encodeUtf8 (toATermForHash True (Just inputSubst) maskedDrv))
          outStorePaths = [(n, makeOutputPath n moduloMasked drvName) | n <- outputNames]
          outPathTexts = [(n, storePathToText defaultStoreDir sp) | (n, sp) <- outStorePaths]
          realEnv = foldr (\(n, t) e -> Map.insert n t e) baseEnv outPathTexts
          contents = mkDrv [DerivationOutput n sp "" "" | (n, sp) <- outStorePaths] realEnv
          -- Unmasked modulo hash (real outputs, inputs substituted) cached for
          -- when this derivation is itself an input to another.
          moduloUnmasked = sha256Digest (TE.encodeUtf8 (toATermForHash False (Just inputSubst) contents))
          drvSp = makeTextPath drvFileName (sha256Digest (TE.encodeUtf8 (toATerm contents))) drvRefs
          drvText = storePathToText defaultStoreDir drvSp
      cacheDrvHash drvText (bytesToHex moduloUnmasked)
      pure (drvText, drvSp, outPathTexts, contents)

  let mainOutPath = case outPaths of
        ((_, p) : _) -> p
        [] -> ""
      -- The default output is the FIRST in @outputs@ (matching C++ Nix, which
      -- returns @(head outputsList).value@) — not necessarily @out@.
      mainOutName = case outPaths of
        ((n, _) : _) -> n
        [] -> "out"

  -- Context for output paths: each output carries SCDrvOutput context
  -- Context for drvPath: carries SCAllOutputs context
  let drvPathCtx = StringContext (Set.singleton (SCAllOutputs drvSP))
      outPathCtx outName = StringContext (Set.singleton (SCDrvOutput drvSP outName))

  -- Build per-output attrsets matching Nix: drv.out = { outPath, drvPath, type }
  let mkOutputAttrs outName outP =
        let outCtx = outPathCtx outName
            outputAttrMap =
              Map.fromList
                [ ("outPath", evaluated (VStr outP outCtx)),
                  ("drvPath", evaluated (VStr drvPathText drvPathCtx)),
                  ("type", evaluated (mkStr "derivation"))
                ]
         in evaluated (VAttrs (attrSetFromMap outputAttrMap))

  -- Build result attrset: original attrs + drvPath, outPath, type, per-output attrs
  let baseAttrs =
        Map.fromList $
          [ ("type", evaluated (mkStr "derivation")),
            ("drvPath", evaluated (VStr drvPathText drvPathCtx)),
            ("outPath", evaluated (VStr mainOutPath (outPathCtx mainOutName))),
            ("name", evaluated (mkStr drvName)),
            ("system", evaluated (mkStr system)),
            ("builder", evaluated (mkStr builder)),
            ("_derivation", evaluated (VDerivation completeDrv))
          ]
            ++ [(outName, mkOutputAttrs outName outP) | (outName, outP) <- outPaths]
      -- Merge original attrs underneath so computed attrs take priority
      resultAttrs = Map.union baseAttrs materialized

  pure (VAttrs (attrSetFromMap resultAttrs))
builtinDerivationStrict other =
  throwEvalError ("derivation: expected a set, got " <> typeName other)

-- | Detect a fixed-output derivation.  Returns @Just (algo, mode, rawDigest)@
-- when @outputHash@ is present and non-empty (fetchurl, fetchgit, …), else
-- 'Nothing' for an ordinary input-addressed derivation.  @mode@ is
-- @\"flat\"@ or @\"recursive\"@; @algo@ is e.g. @\"sha256\"@.
detectFixedOutput :: (MonadEval m) => AttrSet -> m (Maybe (Text, Text, BS.ByteString))
detectFixedOutput attrs =
  case attrSetLookup "outputHash" attrs of
    Nothing -> pure Nothing
    Just thunk -> do
      val <- force thunk
      case val of
        VStr ohash _
          | not (T.null ohash) -> do
              ohAlgo <- optDrvStrAttr "outputHashAlgo" attrs
              ohMode <- optDrvStrAttr "outputHashMode" attrs
              (algo, digest) <- normalizeFixedHash ohash ohAlgo
              let mode = if ohMode == "recursive" then "recursive" else "flat"
              pure (Just (algo, mode, digest))
        _ -> pure Nothing

-- | Read an optional string attribute, defaulting to @\"\"@ when absent or
-- not a string.
optDrvStrAttr :: (MonadEval m) => Text -> AttrSet -> m Text
optDrvStrAttr key attrs =
  case attrSetLookup key attrs of
    Nothing -> pure ""
    Just thunk -> do
      val <- force thunk
      case val of
        VStr s _ -> pure s
        _ -> pure ""

-- | Decode a fixed-output hash (SRI @algo-base64@, @algo:hash@, or a bare
-- hash plus a separate algorithm) to its algorithm name and raw bytes.
normalizeFixedHash :: (MonadEval m) => Text -> Text -> m (Text, BS.ByteString)
normalizeFixedHash ohash ohAlgo
  | Just (algo, b64) <- parseSRI ohash = do
      bytes <- decodeBase64E "derivation" b64
      pure (algo, bytes)
  | Just (algo, rest) <- parseAlgoPrefix ohash = decodeWithAlgo algo rest
  | not (T.null ohAlgo) = decodeWithAlgo ohAlgo ohash
  | otherwise =
      throwEvalError ("derivation: cannot determine outputHash algorithm for " <> ohash)

-- | Lazy @derivation@ wrapper — mirrors C++ Nix's @corepkgs/derivation.nix@.
-- Returns a WHNF attrset whose @drvPath@/@outPath@/output-path/@_derivation@
-- attrs are LAZY thunks that defer to 'builtinDerivationStrict'.  Forcing a
-- derivation to WHNF therefore does NOT force its input/env closure — which is
-- essential for nixpkgs, where merely referencing a derivation (e.g.
-- @drv != null@, @assert (libxcrypt != null)@) must not build its whole closure.
--
-- The lazy thunks are built with the same synthetic-select pattern used by
-- @inherit (from)@: a single shared @strict@ thunk (so the eager computation
-- runs at most once) selected from via fresh minimal envs.
builtinDerivationLazy :: (MonadEval m) => NixValue -> m NixValue
builtinDerivationLazy (VAttrs attrs) = do
  -- Output names are cheap (matches @drvAttrs @ { outputs ? [ "out" ], ... }@).
  outputNames <- case attrSetLookup "outputs" attrs of
    Nothing -> pure ["out"]
    Just thunk -> do
      val <- force thunk
      case val of
        VList cl -> mapM (forceToText . Thunk) (clistThunks cl)
        _ -> throwEvalError "derivation: 'outputs' must be a list of strings"
  -- One shared thunk computing @derivationStrict attrs@, forced only when an
  -- output path / drvPath is actually read.
  let drvAttrsThunk = evaluated (VAttrs attrs)
      strictBuiltinThunk = evaluated (VBuiltin "derivationStrict" [])
      strictThunk =
        let (sp, sc) = buildCSlots [drvAttrsThunk, strictBuiltinThunk]
            envDS = newMinimalEnv sp sc
         in mkSyntheticThunk envDS (EApp (EResolvedVar 0 1) (EResolvedVar 0 0))
      selectStrict field =
        let (sp, sc) = buildCSlots [strictThunk]
            envF = newMinimalEnv sp sc
         in mkSyntheticThunk envF (ESelect (EResolvedVar 0 0) [StaticKey field] Nothing)
  -- WHNF spine: input attrs (unforced) overlaid with the lazy computed attrs.
  let computedAttrs =
        Map.fromList $
          [ ("type", evaluated (mkStr "derivation")),
            ("drvPath", selectStrict "drvPath"),
            ("outPath", selectStrict "outPath"),
            ("_derivation", selectStrict "_derivation")
          ]
            ++ [(outName, selectStrict outName) | outName <- outputNames]
      resultAttrs = Map.union computedAttrs (attrSetToMap attrs)
  pure (VAttrs (attrSetFromMap resultAttrs))
builtinDerivationLazy other =
  throwEvalError ("derivation: expected a set, got " <> typeName other)

-- | Force a thunk to a Text string via full Nix coercion.
forceToText :: (MonadEval m) => Thunk -> m Text
forceToText thunk = do
  val <- force thunk
  (s, _ctx) <- coerceToString force applyValue val
  pure s

-- | Collect all string-coercible attributes for the derivation environment,
-- along with the merged string context from all collected values.
-- Uses 'coerceToStringPermissive' for full Nix coercion including
-- __toString, outPath, and list-to-space-separated-string.
collectDrvEnvWithContext :: (MonadEval m) => Map Text Thunk -> m ([(Text, Text)], StringContext)
collectDrvEnvWithContext attrs = do
  let pairs = Map.toList attrs
  results <- mapM tryCoerce pairs
  let envPairs = catMaybes [fmap (\(k, v, _) -> (k, v)) r | r <- results]
      mergedCtx = mconcat [ctx | Just (_, _, ctx) <- results]
  pure (envPairs, mergedCtx)
  where
    tryCoerce (key, thunk) = do
      val <- force thunk
      case val of
        VNull -> pure Nothing
        _ -> do
          result <- catchEvalError (coerceToStoreString val)
          case result of
            Right (s, ctx) -> pure (Just (key, s, ctx))
            Left _ -> pure Nothing

-- ---------------------------------------------------------------------------
-- Builtin implementations — hashFile, readFileType
-- ---------------------------------------------------------------------------

-- | @builtins.hashFile algo path@ — hash raw bytes of a file on disk.
-- Returns base-16 hex string, matching @builtins.hashString@ output format.
builtinHashFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinHashFile (VStr algo _) (VPath path) = do
  bytes <- readFileBytes path
  hashBytesWithAlgo "hashFile" algo bytes
builtinHashFile (VStr algo _) (VStr path _) = do
  bytes <- readFileBytes path
  hashBytesWithAlgo "hashFile" algo bytes
builtinHashFile (VStr _ _) other =
  throwEvalError ("builtins.hashFile: expected a path, got " <> typeName other)
builtinHashFile other _ =
  throwEvalError ("builtins.hashFile: expected a string, got " <> typeName other)

-- | Shared hash dispatch for raw 'ByteString' input.
hashBytesWithAlgo :: (MonadEval m) => Text -> Text -> BS.ByteString -> m NixValue
hashBytesWithAlgo ctx algo bytes = case algo of
  "sha256" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA256)))
  "sha512" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA512)))
  "sha1" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA1)))
  "md5" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.MD5)))
  _ -> throwEvalError ("builtins." <> ctx <> ": unknown hash algorithm '" <> algo <> "'")

-- | @builtins.readFileType path@ — classify a filesystem entry.
-- Returns @"regular"@, @"directory"@, @"symlink"@, or @"unknown"@.
builtinReadFileType :: (MonadEval m) => NixValue -> m NixValue
builtinReadFileType (VPath path) = mkStr <$> getFileType path
builtinReadFileType (VStr path _) = mkStr <$> getFileType path
builtinReadFileType other =
  throwEvalError ("builtins.readFileType: expected a path, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin implementations — convertHash
-- ---------------------------------------------------------------------------

-- | @builtins.convertHash { hash, hashAlgo?, toHashFormat }@ — convert
-- between hash representations.  Supports base16, nix32, base64, and sri.
builtinConvertHash :: (MonadEval m) => NixValue -> m NixValue
builtinConvertHash (VAttrs attrs) = do
  hashVal <- requireStrAttr "convertHash" "hash" attrs
  toFmt <- requireStrAttr "convertHash" "toHashFormat" attrs
  -- Detect input format and decode to raw bytes + algo
  (algo, rawBytes) <- decodeHashInput attrs hashVal
  -- Encode to target format
  case toFmt of
    "base16" -> pure (mkStr (bytesToHex rawBytes))
    "nix32" -> pure (mkStr (Nix32.encode rawBytes))
    "base32" -> pure (mkStr (Nix32.encode rawBytes)) -- deprecated alias
    "base64" -> pure (mkStr (bytesToBase64 rawBytes))
    "sri" -> pure (mkStr (algo <> "-" <> bytesToBase64 rawBytes))
    _ -> throwEvalError ("builtins.convertHash: unknown toHashFormat '" <> toFmt <> "'")
builtinConvertHash other =
  throwEvalError ("builtins.convertHash: expected a set, got " <> typeName other)

-- | Extract algo + raw bytes from the hash input, handling SRI, prefixed, and plain formats.
decodeHashInput :: (MonadEval m) => AttrSet -> Text -> m (Text, BS.ByteString)
decodeHashInput attrs hashStr
  -- SRI format: algo-base64
  | Just (algo, b64) <- parseSRI hashStr = do
      bytes <- decodeBase64E "convertHash" b64
      pure (algo, bytes)
  -- Prefixed format: algo:hex or algo:nix32
  | Just (algo, rest) <- parseAlgoPrefix hashStr =
      decodeWithAlgo algo rest
  -- Plain hash — need hashAlgo attribute
  | otherwise = do
      algo <- requireStrAttr "convertHash" "hashAlgo" attrs
      decodeWithAlgo algo hashStr

-- | Try to decode as hex, then nix32, then base64.
decodeWithAlgo :: (MonadEval m) => Text -> Text -> m (Text, BS.ByteString)
decodeWithAlgo algo s
  | Just bytes <- hexToBytes s = pure (algo, bytes)
  | Right bytes <- Nix32.decode s = pure (algo, bytes)
  | Right bytes <- decodeBase64Pure s = pure (algo, bytes)
  | otherwise = throwEvalError ("builtins.convertHash: cannot decode hash '" <> s <> "'")

-- | Parse @sha256-base64...@ SRI format.
parseSRI :: Text -> Maybe (Text, Text)
parseSRI t = case T.breakOn "-" t of
  (algo, rest)
    | not (T.null rest) && algo `elem` ["sha256", "sha512", "sha1", "md5"] ->
        Just (algo, T.drop 1 rest)
  _ -> Nothing

-- | Parse @sha256:value@ prefixed format.
parseAlgoPrefix :: Text -> Maybe (Text, Text)
parseAlgoPrefix t = case T.breakOn ":" t of
  (algo, rest)
    | not (T.null rest) && algo `elem` ["sha256", "sha512", "sha1", "md5"] ->
        Just (algo, T.drop 1 rest)
  _ -> Nothing

-- | Require a string attribute from an attrset.
requireStrAttr :: (MonadEval m) => Text -> Text -> AttrSet -> m Text
requireStrAttr ctx key attrs = case attrSetLookup key attrs of
  Just thunk -> do
    val <- force thunk
    case val of
      VStr s _ -> pure s
      _ -> throwEvalError ("builtins." <> ctx <> ": " <> key <> " must be a string")
  Nothing -> throwEvalError ("builtins." <> ctx <> ": missing required attribute '" <> key <> "'")

-- | Encode bytes to base16 hex.
bytesToHex :: BS.ByteString -> Text
bytesToHex = T.pack . concatMap byteToHex . BS.unpack

-- | Decode hex string to bytes.
hexToBytes :: Text -> Maybe BS.ByteString
hexToBytes t
  | T.null t = Just BS.empty
  | odd (T.length t) = Nothing
  | T.all isHexDigit t = Just (BS.pack (go (T.unpack t)))
  | otherwise = Nothing
  where
    go [] = []
    go (hi : lo : rest) = fromIntegral (digitToInt hi * 16 + digitToInt lo) : go rest
    go [_] = [] -- unreachable due to odd check

-- ---------------------------------------------------------------------------
-- Base64 encode/decode — delegates to nova-cache (base64-bytestring under the hood)
-- ---------------------------------------------------------------------------

-- | Encode bytes to base64.
bytesToBase64 :: BS.ByteString -> Text
bytesToBase64 = B64.encode

-- | Decode base64 text to bytes (pure).
decodeBase64Pure :: Text -> Either Text BS.ByteString
decodeBase64Pure t =
  -- Strip whitespace and any existing padding, then re-pad to a multiple of
  -- 4.  base64-bytestring's 'decode' requires correct padding, so SRI hashes
  -- (correctly-padded standard base64, e.g. @sha256-…NQ=@) would otherwise be
  -- rejected once their trailing @=@ was removed.
  let stripped = T.filter (\c -> c /= '\n' && c /= '\r' && c /= '=') t
      padLen = (4 - (T.length stripped `mod` 4)) `mod` 4
      padded = stripped <> T.replicate padLen "="
   in case B64.decode padded of
        Right bytes -> Right bytes
        Left _ -> Left "invalid base64"

-- | Decode base64 with error context for builtins.
decodeBase64E :: (MonadEval m) => Text -> Text -> m BS.ByteString
decodeBase64E ctx t = case decodeBase64Pure t of
  Right bytes -> pure bytes
  Left _ -> throwEvalError ("builtins." <> ctx <> ": invalid base64 encoding")

-- ---------------------------------------------------------------------------
-- Builtin implementations — fromTOML
-- ---------------------------------------------------------------------------

-- | @builtins.fromTOML str@ — parse a TOML document to a Nix value.
-- Hand-rolled parser covering the TOML v1.0 subset used by nixpkgs:
-- bare/quoted keys, dotted keys, basic/literal strings (multiline),
-- integers (dec/hex/oct/bin), floats (inc. inf/nan), booleans,
-- inline tables, arrays, array-of-tables, and standard tables.
-- Datetimes are represented as strings (matching real Nix).
builtinFromTOML :: (MonadEval m) => NixValue -> m NixValue
builtinFromTOML (VStr s _) = case parseTOML s of
  Right val -> pure val
  Left err -> throwEvalError ("builtins.fromTOML: " <> err)
builtinFromTOML other =
  throwEvalError ("builtins.fromTOML: expected a string, got " <> typeName other)

-- | Intermediate TOML value before conversion to NixValue.
data TOMLValue
  = TOMLStr !Text
  | TOMLInt !Int64
  | TOMLFloat !Double
  | TOMLBool !Bool
  | TOMLArray ![TOMLValue]
  | TOMLTable !(Map Text TOMLValue)
  deriving (Show)

-- | Parse a TOML document into a NixValue.
parseTOML :: Text -> Either Text NixValue
parseTOML input = do
  table <- parseTOMLDoc (T.lines input)
  pure (tomlToNix (TOMLTable table))

-- | Convert a TOMLValue to NixValue.
tomlToNix :: TOMLValue -> NixValue
tomlToNix val = case val of
  TOMLStr s -> mkStr s
  TOMLInt n -> VInt n
  TOMLFloat d -> VFloat d
  TOMLBool b -> VBool b
  TOMLArray xs -> VList (clistFromThunks (map (thunkToCPtr . evaluated . tomlToNix) xs))
  TOMLTable m -> VAttrs (attrSetFromMap (Map.map (evaluated . tomlToNix) m))

-- | Parse all lines of a TOML document into a table.
parseTOMLDoc :: [Text] -> Either Text (Map Text TOMLValue)
parseTOMLDoc lns = go lns [] Map.empty
  where
    go [] _ root = Right root
    go (line : rest) currentPath root
      | T.null stripped || T.isPrefixOf "#" stripped =
          -- Empty line or comment
          go rest currentPath root
      | T.isPrefixOf "[[" stripped && T.isSuffixOf "]]" stripped =
          -- Array of tables: [[key]]
          let keyStr = T.strip (T.drop 2 (T.dropEnd 2 stripped))
              keys = parseDottedKey keyStr
           in go rest keys (insertArrayTable keys root)
      | T.isPrefixOf "[" stripped && T.isSuffixOf "]" stripped =
          -- Standard table: [key]
          let keyStr = T.strip (T.drop 1 (T.dropEnd 1 stripped))
              keys = parseDottedKey keyStr
           in go rest keys (ensureTable keys root)
      | otherwise =
          -- Key = value pair
          case parseKVLine stripped of
            Right (keys, val) ->
              go rest currentPath (insertNested (currentPath ++ keys) val root)
            Left err -> Left err
      where
        stripped = T.strip line

-- | Parse a key = value line.
parseKVLine :: Text -> Either Text ([Text], TOMLValue)
parseKVLine line =
  let (keyPart, afterEq) = splitAtEquals line
   in case T.uncons afterEq of
        Nothing -> Left ("expected '=' in: " <> line)
        Just _ -> do
          let val = T.strip afterEq
          parsed <- parseTOMLValue val
          Right (parseDottedKey (T.strip keyPart), parsed)

-- | Split a line at the first unquoted '=' sign.
splitAtEquals :: Text -> (Text, Text)
splitAtEquals = go T.empty
  where
    go acc t = case T.uncons t of
      Nothing -> (acc, T.empty)
      Just ('=', rest) -> (acc, rest)
      Just ('"', rest) ->
        let (quoted, after) = T.break (== '"') rest
         in case T.uncons after of
              Just ('"', r) -> go (acc <> "\"" <> quoted <> "\"") r
              _ -> go (acc <> "\"" <> quoted) after
      Just ('\'', rest) ->
        let (quoted, after) = T.break (== '\'') rest
         in case T.uncons after of
              Just ('\'', r) -> go (acc <> "'" <> quoted <> "'") r
              _ -> go (acc <> "'" <> quoted) after
      Just (c, rest) -> go (T.snoc acc c) rest

-- | Parse dotted key like @foo.bar."baz qux"@ into @["foo", "bar", "baz qux"]@.
parseDottedKey :: Text -> [Text]
parseDottedKey t
  | T.null t = []
  | otherwise = case T.uncons t of
      Just ('"', rest) ->
        let (key, after) = T.break (== '"') rest
         in key : parseDottedKey (T.drop 1 (T.stripStart (dropDot after)))
      Just ('\'', rest) ->
        let (key, after) = T.break (== '\'') rest
         in key : parseDottedKey (T.drop 1 (T.stripStart (dropDot after)))
      _ ->
        let (key, after) = T.break (\c -> c == '.' || c == '"') t
         in T.strip key : case T.uncons after of
              Nothing -> []
              Just _ -> parseDottedKey (T.drop 1 (T.stripStart after))
  where
    dropDot txt = case T.uncons txt of
      Just ('.', rest) -> rest
      _ -> txt

-- | Parse a TOML value (right side of '=').
parseTOMLValue :: Text -> Either Text TOMLValue
parseTOMLValue t =
  let stripped = T.strip t
      -- Strip inline comments (not inside strings)
      cleaned = stripInlineComment stripped
   in case T.uncons cleaned of
        Nothing -> Left "empty value"
        Just ('"', _)
          | T.isPrefixOf "\"\"\"" cleaned -> parseMultilineBasicStr (T.drop 3 cleaned)
          | otherwise -> parseBasicStr (T.drop 1 cleaned)
        Just ('\'', _)
          | T.isPrefixOf "'''" cleaned -> parseMultilineLiteralStr (T.drop 3 cleaned)
          | otherwise -> parseLiteralStr (T.drop 1 cleaned)
        Just ('{', _) -> parseInlineTable cleaned
        Just ('[', _) -> parseInlineArray cleaned
        Just ('t', _)
          | T.isPrefixOf "true" cleaned -> Right (TOMLBool True)
        Just ('f', _)
          | T.isPrefixOf "false" cleaned -> Right (TOMLBool False)
        Just ('i', _)
          | T.isPrefixOf "inf" cleaned -> Right (TOMLFloat (1 / 0))
        Just ('+', rest)
          | T.isPrefixOf "inf" rest -> Right (TOMLFloat (1 / 0))
          | T.isPrefixOf "nan" rest -> Right (TOMLFloat (0 / 0))
        Just ('-', rest)
          | T.isPrefixOf "inf" rest -> Right (TOMLFloat (negate (1 / 0)))
          | T.isPrefixOf "nan" rest -> Right (TOMLFloat (0 / 0))
        Just ('n', _)
          | T.isPrefixOf "nan" cleaned -> Right (TOMLFloat (0 / 0))
        _ -> parseTOMLNumberOrDatetime cleaned

-- | Strip inline comment from a value (not inside quotes).
stripInlineComment :: Text -> Text
stripInlineComment = go (0 :: Int)
  where
    go _ t | T.null t = T.empty
    go depth t = case T.uncons t of
      Nothing -> T.empty
      Just ('#', _) | depth == (0 :: Int) -> T.empty
      Just ('"', rest)
        | depth == 0 ->
            let (str, after) = T.break (== '"') rest
             in T.cons '"' (str <> T.take 1 after <> go depth (T.drop 1 after))
      Just ('[', rest) -> T.cons '[' (go (depth + 1) rest)
      Just ('{', rest) -> T.cons '{' (go (depth + 1) rest)
      Just (']', rest) -> T.cons ']' (go (max 0 (depth - 1)) rest)
      Just ('}', rest) -> T.cons '}' (go (max 0 (depth - 1)) rest)
      Just (c, rest) -> T.cons c (go depth rest)

-- | Parse a basic (double-quoted) TOML string.
-- O(n) via chunk list + T.concat instead of O(n^2) T.snoc.
parseBasicStr :: Text -> Either Text TOMLValue
parseBasicStr = go []
  where
    go !chunks t = case T.uncons t of
      Nothing -> Left "unterminated basic string"
      Just ('"', _) -> Right (TOMLStr (T.concat (reverse chunks)))
      Just ('\\', rest) -> case T.uncons rest of
        Just ('n', r) -> go ("\n" : chunks) r
        Just ('t', r) -> go ("\t" : chunks) r
        Just ('r', r) -> go ("\r" : chunks) r
        Just ('\\', r) -> go ("\\" : chunks) r
        Just ('"', r) -> go ("\"" : chunks) r
        Just ('b', r) -> go ("\b" : chunks) r
        Just ('f', r) -> go ("\f" : chunks) r
        Just ('u', r) -> case parseHex4 r of
          Just (cp, r2) -> go (T.singleton (chr cp) : chunks) r2
          Nothing -> Left "invalid \\u escape"
        _ -> Left "invalid escape sequence"
      Just (c, rest) -> go (T.singleton c : chunks) rest

-- | Parse a literal (single-quoted) TOML string.
parseLiteralStr :: Text -> Either Text TOMLValue
parseLiteralStr t =
  let (content, rest) = T.break (== '\'') t
   in case T.uncons rest of
        Just ('\'', _) -> Right (TOMLStr content)
        _ -> Left "unterminated literal string"

-- | Parse a multiline basic string.
parseMultilineBasicStr :: Text -> Either Text TOMLValue
parseMultilineBasicStr t =
  case T.breakOn "\"\"\"" t of
    (content, rest)
      | T.isPrefixOf "\"\"\"" rest ->
          Right (TOMLStr (T.replace "\\\n" "" (stripLeadingNewline content)))
      | otherwise -> Left ("unterminated multiline basic string, remaining: " <> T.take 20 rest)

-- | Parse a multiline literal string.
parseMultilineLiteralStr :: Text -> Either Text TOMLValue
parseMultilineLiteralStr t =
  case T.breakOn "'''" t of
    (content, rest)
      | T.isPrefixOf "'''" rest -> Right (TOMLStr (stripLeadingNewline content))
      | otherwise -> Left "unterminated multiline literal string"

-- | Strip a leading newline (TOML spec: first newline after opening quotes is trimmed).
stripLeadingNewline :: Text -> Text
stripLeadingNewline t = case T.uncons t of
  Just ('\n', rest) -> rest
  Just ('\r', rest) -> case T.uncons rest of
    Just ('\n', r) -> r
    _ -> rest
  _ -> t

-- | Parse a TOML number or datetime.
parseTOMLNumberOrDatetime :: Text -> Either Text TOMLValue
parseTOMLNumberOrDatetime s
  -- Hex, octal, binary integers
  | T.isPrefixOf "0x" s || T.isPrefixOf "0X" s = parseHexInt (T.drop 2 s)
  | T.isPrefixOf "0o" s || T.isPrefixOf "0O" s = parseOctInt (T.drop 2 s)
  | T.isPrefixOf "0b" s || T.isPrefixOf "0B" s = parseBinInt (T.drop 2 s)
  -- Contains date separators → treat as datetime string
  | T.any (== 'T') s && T.any (== '-') s = Right (TOMLStr s)
  | T.count "-" s >= 2 && T.any isDigit s = Right (TOMLStr s)
  | T.any (== ':') s && T.any isDigit s = Right (TOMLStr s)
  -- Float (has dot or exponent)
  | T.any (== '.') s || T.any (\c -> c == 'e' || c == 'E') s = parseFloat s
  -- Plain integer
  | otherwise = parseInt s

-- | Parse a plain decimal integer, ignoring underscores.
parseInt :: Text -> Either Text TOMLValue
parseInt t =
  let cleaned = T.filter (/= '_') t
      (sign, digits) = case T.uncons cleaned of
        Just ('+', rest) -> (1, rest)
        Just ('-', rest) -> (-1, rest)
        _ -> (1, cleaned)
   in case readDecimal digits of
        Just n -> Right (TOMLInt (sign * n))
        Nothing -> Left ("invalid integer: " <> t)

parseHexInt :: Text -> Either Text TOMLValue
parseHexInt t =
  let cleaned = T.filter (/= '_') t
   in case readHexT cleaned of
        Just n -> Right (TOMLInt n)
        Nothing -> Left ("invalid hex integer: " <> t)

parseOctInt :: Text -> Either Text TOMLValue
parseOctInt t =
  let cleaned = T.filter (/= '_') t
   in case readOctT cleaned of
        Just n -> Right (TOMLInt n)
        Nothing -> Left ("invalid octal integer: " <> t)

parseBinInt :: Text -> Either Text TOMLValue
parseBinInt t =
  let cleaned = T.filter (/= '_') t
   in case readBinT cleaned of
        Just n -> Right (TOMLInt n)
        Nothing -> Left ("invalid binary integer: " <> t)

parseFloat :: Text -> Either Text TOMLValue
parseFloat t =
  let cleaned = T.filter (/= '_') t
   in case readDouble cleaned of
        Just d -> Right (TOMLFloat d)
        Nothing -> Left ("invalid float: " <> t)

-- | Read a decimal integer from Text.
readDecimal :: Text -> Maybe Int64
readDecimal t
  | T.null t = Nothing
  | T.all isDigit t = Just (T.foldl' (\acc c -> acc * 10 + fromIntegral (digitToInt c)) 0 t)
  | otherwise = Nothing

readHexT :: Text -> Maybe Int64
readHexT t
  | T.null t = Nothing
  | T.all isHexDigit t = Just (T.foldl' (\acc c -> acc * 16 + fromIntegral (digitToInt c)) 0 t)
  | otherwise = Nothing

readOctT :: Text -> Maybe Int64
readOctT t
  | T.null t = Nothing
  | T.all isOctDigit t =
      Just (T.foldl' (\acc c -> acc * 8 + fromIntegral (digitToInt c)) 0 t)
  | otherwise = Nothing

readBinT :: Text -> Maybe Int64
readBinT t
  | T.null t = Nothing
  | T.all (\c -> c == '0' || c == '1') t =
      Just (T.foldl' (\acc c -> acc * 2 + fromIntegral (digitToInt c)) 0 t)
  | otherwise = Nothing

readDouble :: Text -> Maybe Double
readDouble t = case reads (T.unpack t) of
  [(d, "")] -> Just d
  _ -> Nothing

-- | Parse an inline table: @{ key = val, ... }@.
parseInlineTable :: Text -> Either Text TOMLValue
parseInlineTable t = case T.uncons t of
  Just ('{', rest) ->
    let inner = T.strip (T.dropWhileEnd (== '}') (T.strip rest))
     in if T.null inner
          then Right (TOMLTable Map.empty)
          else do
            pairs <- mapM parseInlineKV (splitCommas inner)
            Right (TOMLTable (Map.fromList (concatMap flattenPair pairs)))
  _ -> Left "expected '{'"
  where
    flattenPair (keys, val) = case keys of
      [] -> []
      [k] -> [(k, val)]
      (k : ks) -> [(k, nestKeys ks val)]
    nestKeys [] v = v
    nestKeys (k : ks) v = TOMLTable (Map.singleton k (nestKeys ks v))

-- | Parse an inline array: @[ val, ... ]@.
parseInlineArray :: Text -> Either Text TOMLValue
parseInlineArray t = case T.uncons t of
  Just ('[', rest) ->
    let inner = T.strip (T.dropWhileEnd (== ']') (T.strip rest))
     in if T.null inner
          then Right (TOMLArray [])
          else do
            vals <- mapM (parseTOMLValue . T.strip) (splitCommas inner)
            Right (TOMLArray vals)
  _ -> Left "expected '['"

-- | Parse a single key=value pair in an inline table.
parseInlineKV :: Text -> Either Text ([Text], TOMLValue)
parseInlineKV t =
  let (keyPart, afterEq) = splitAtEquals (T.strip t)
   in do
        val <- parseTOMLValue (T.strip afterEq)
        Right (parseDottedKey (T.strip keyPart), val)

-- | Split on commas not inside brackets or braces.
-- O(n) via chunk list + T.concat instead of O(n^2) T.snoc.
splitCommas :: Text -> [Text]
splitCommas = go (0 :: Int) []
  where
    finalize chunks =
      let t = T.concat (reverse chunks)
       in [t | not (T.null (T.strip t))]
    go _ !chunks t | T.null t = finalize chunks
    go depth !chunks t = case T.uncons t of
      Nothing -> finalize chunks
      Just (',', rest) | depth == 0 -> T.concat (reverse chunks) : go 0 [] rest
      Just ('[', rest) -> go (depth + 1) ("[" : chunks) rest
      Just ('{', rest) -> go (depth + 1) ("{" : chunks) rest
      Just (']', rest) -> go (max 0 (depth - 1)) ("]" : chunks) rest
      Just ('}', rest) -> go (max 0 (depth - 1)) ("}" : chunks) rest
      Just ('"', rest) ->
        let (str, after) = T.break (== '"') rest
            consumed = "\"" <> str <> T.take 1 after
         in go depth (consumed : chunks) (T.drop 1 after)
      Just (c, rest) -> go depth (T.singleton c : chunks) rest

-- | Insert a value at a nested key path into a table.
insertNested :: [Text] -> TOMLValue -> Map Text TOMLValue -> Map Text TOMLValue
insertNested [] _ m = m
insertNested [k] v m = Map.insert k v m
insertNested (k : ks) v m =
  let sub = case Map.lookup k m of
        Just (TOMLTable inner) -> inner
        _ -> Map.empty
   in Map.insert k (TOMLTable (insertNested ks v sub)) m

-- | Ensure a table path exists (for @[table]@ headers).
ensureTable :: [Text] -> Map Text TOMLValue -> Map Text TOMLValue
ensureTable [] m = m
ensureTable [k] m = case Map.lookup k m of
  Just (TOMLTable _) -> m
  Nothing -> Map.insert k (TOMLTable Map.empty) m
  _ -> m
ensureTable (k : ks) m =
  let sub = case Map.lookup k m of
        Just (TOMLTable inner) -> inner
        _ -> Map.empty
   in Map.insert k (TOMLTable (ensureTable ks sub)) m

-- | Insert an entry into an array-of-tables (@[[table]]@).
insertArrayTable :: [Text] -> Map Text TOMLValue -> Map Text TOMLValue
insertArrayTable [] m = m
insertArrayTable [k] m = case Map.lookup k m of
  Just (TOMLArray xs) -> Map.insert k (TOMLArray (xs ++ [TOMLTable Map.empty])) m
  Nothing -> Map.insert k (TOMLArray [TOMLTable Map.empty]) m
  _ -> Map.insert k (TOMLArray [TOMLTable Map.empty]) m
insertArrayTable (k : ks) m =
  let sub = case Map.lookup k m of
        Just (TOMLTable inner) -> inner
        Just (TOMLArray xs) ->
          -- Descend into the last element of the array
          case reverse xs of
            (TOMLTable inner : _) -> inner
            _ -> Map.empty
        _ -> Map.empty
      updated = insertArrayTable ks sub
   in case Map.lookup k m of
        Just (TOMLArray xs) ->
          case reverse xs of
            (TOMLTable _ : prev) ->
              Map.insert k (TOMLArray (reverse prev ++ [TOMLTable updated])) m
            _ -> Map.insert k (TOMLTable updated) m
        _ -> Map.insert k (TOMLTable updated) m

-- ---------------------------------------------------------------------------
-- Builtin implementations — toXML
-- ---------------------------------------------------------------------------

-- | @builtins.toXML val@ — convert a Nix value to its XML representation.
-- Matches the format defined by the Nix manual: strings, ints, floats,
-- bools, nulls, lists, and attrsets map to their XML counterparts.
builtinToXML :: (MonadEval m) => NixValue -> m NixValue
builtinToXML val = do
  xml <- valueToXML 0 val
  pure (mkStr ("<?xml version='1.0' encoding='utf-8'?>\n<expr>\n" <> xml <> "</expr>\n"))

valueToXML :: (MonadEval m) => Int -> NixValue -> m Text
valueToXML depth val = case val of
  VStr s _ ->
    pure (indent depth <> "<string value=" <> xmlQuote s <> " />\n")
  VInt n ->
    pure (indent depth <> "<int value=\"" <> T.pack (show n) <> "\" />\n")
  VFloat d ->
    pure (indent depth <> "<float value=\"" <> T.pack (show d) <> "\" />\n")
  VBool True ->
    pure (indent depth <> "<bool value=\"true\" />\n")
  VBool False ->
    pure (indent depth <> "<bool value=\"false\" />\n")
  VNull ->
    pure (indent depth <> "<null />\n")
  VPath p ->
    pure (indent depth <> "<path value=" <> xmlQuote p <> " />\n")
  VList cl -> do
    let thunks = map Thunk (clistThunks cl)
    items <- mapM (force >=> valueToXML (depth + 1)) thunks
    pure (indent depth <> "<list>\n" <> T.concat items <> indent depth <> "</list>\n")
  VAttrs attrs -> do
    let pairs = attrSetToAscList attrs
    items <- mapM (attrToXML (depth + 1)) pairs
    pure (indent depth <> "<attrs>\n" <> T.concat items <> indent depth <> "</attrs>\n")
  VLambda {} ->
    pure (indent depth <> "<function />\n")
  VBuiltin _ _ ->
    pure (indent depth <> "<function />\n")
  VDerivation _ ->
    pure (indent depth <> "<derivation />\n")
  VCompiledRegex _ ->
    pure (indent depth <> "<function />\n")
  where
    attrToXML d (name, thunk) = do
      v <- force thunk
      inner <- valueToXML d v
      pure (indent d <> "<attr name=" <> xmlQuote name <> ">\n" <> inner <> indent d <> "</attr>\n")

indent :: Int -> Text
indent n = T.replicate (n * 2) " "

xmlQuote :: Text -> Text
xmlQuote s = "\"" <> T.concatMap escapeChar s <> "\""
  where
    escapeChar '<' = "&lt;"
    escapeChar '>' = "&gt;"
    escapeChar '&' = "&amp;"
    escapeChar '"' = "&quot;"
    escapeChar c = T.singleton c

-- ---------------------------------------------------------------------------
-- Builtin implementations — builtins.path
-- ---------------------------------------------------------------------------

-- | @builtins.path { path; name?; filter?; sha256?; recursive?; }@
--
-- Copy a path to the store and return the store path as a string with
-- context.  @name@ defaults to the basename of @path@.  @filter@ is
-- accepted but not yet applied (copies everything).
builtinPath :: (MonadEval m) => NixValue -> m NixValue
builtinPath (VAttrs attrs) = do
  pathStr <- forceAttrStr "builtins.path" "path" attrs
  nameOverride <- forceOptionalAttrStr attrs "name"
  let name = fromMaybe (extractBaseName pathStr) nameOverride
  storePathText <- copyPathToStore pathStr name
  -- Parse the store path to construct proper SCPlain context
  case parseStorePath defaultStoreDir storePathText of
    Just sp -> pure (VStr storePathText (plainContext sp))
    Nothing -> pure (VStr storePathText emptyContext)
builtinPath other =
  throwEvalError ("builtins.path: expected an attribute set, got " <> typeName other)

-- | Extract the last path component from a path string.
extractBaseName :: Text -> Text
extractBaseName path =
  let stripped = T.dropWhileEnd (\c -> c == '/' || c == '\\') path
   in case T.breakOnEnd "/" stripped of
        ("", _) -> case T.breakOnEnd "\\" stripped of
          ("", _) -> stripped
          (_, name) -> name
        (_, name) -> name

-- ---------------------------------------------------------------------------
-- Builtin implementations — filterSource
-- ---------------------------------------------------------------------------

-- | @builtins.filterSource filter path@ — copy a path to the store,
-- filtering entries via a predicate.  The filter function receives
-- @(path, type)@ where type is @"regular"@, @"directory"@, @"symlink"@,
-- or @"unknown"@.
builtinFilterSource :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinFilterSource _filterFn (VPath _path) =
  throwEvalError "builtins.filterSource: not yet implemented (requires store integration)"
builtinFilterSource _filterFn (VStr _path _) =
  throwEvalError "builtins.filterSource: not yet implemented (requires store integration)"
builtinFilterSource _ other =
  throwEvalError ("builtins.filterSource: expected a path, got " <> typeName other)

-- ---------------------------------------------------------------------------
-- Builtin stubs — experimental features
-- ---------------------------------------------------------------------------

builtinOutputOf :: (MonadEval m) => NixValue -> NixValue -> m NixValue
builtinOutputOf _ _ =
  throwEvalError "builtins.outputOf: requires experimental feature 'dynamic-derivations'"

builtinFetchTree :: (MonadEval m) => NixValue -> m NixValue
builtinFetchTree _ =
  throwEvalError "builtins.fetchTree: requires experimental feature 'fetch-tree'"

builtinFetchClosure :: (MonadEval m) => NixValue -> m NixValue
builtinFetchClosure _ =
  throwEvalError "builtins.fetchClosure: requires experimental feature 'fetch-closure'"