packages feed

keiki-0.2.0.0: src/Keiki/Builder.hs

-- 'Disjoint' on '(.=)' is the static check itself; GHC otherwise warns.
{-# OPTIONS_GHC -Wno-redundant-constraints #-}

-- | A monadic edge-builder DSL for authoring 'SymTransducer's. The
-- builder is purely additive on top of "Keiki.Core": every edge it
-- produces is a value of the existing 'Keiki.Core.Edge' type, and
-- the resulting 'Keiki.Core.SymTransducer' is consumed unchanged by
-- "Keiki.Acceptor", "Keiki.Composition",
-- "Keiki.Symbolic", and the example-side specs.
--
-- == Why a builder
--
-- A hand-written transducer in the AST surface needs four nested
-- pieces of boilerplate per edge: the 'Edge' record literal, an
-- @'IndexN' \"name\" Regs T@ annotation on every register write, an
-- infix @\`combine\`@ chain stitching the writes together, and an
-- @'OFCons' … 'OFNil'@ chain (plus a 'pack' prefix and a 'Just'
-- wrapper) describing the output. The builder collapses each piece:
--
--   * 'buildTransducer' assembles a 'SymTransducer' from a
--     'VertexBuilder' and three scalar arguments (initial vertex,
--     initial register file, finality predicate).
--   * 'from' tags one source vertex; 'onCmd' / 'onEpsilon' add one
--     edge each.
--   * '(.=)' adds one register write to the edge under construction.
--     The slot name flows through a type-level @(w :: [Symbol])@
--     index so that a duplicated @'(.=)'@ to the same slot fails to
--     type-check at the offending line.
--   * 'emit' takes a 'WireCtor' and a per-event
--     @\<CtorName\>TermFields rs ci@ record (emitted by
--     'Keiki.Generics.TH.deriveWireCtors'); fields read top-to-
--     bottom keyed by the wire side's payload field names. The
--     'InCtor' is recovered from the enclosing 'onCmd'; the lower-
--     level operator-form @(t1 *: t2 *: oNil)@ remains available
--     as an escape hatch via the same overload.
--
-- See @docs\/research\/edge-builder-dsl-shape.md@ for the full
-- design and per-question rationale (carrier monad,
-- distinct-targets enforcement, 'goto' termination semantics, etc).
--
-- == Worked example: the EmailDelivery aggregate
--
-- @
-- import qualified Keiki.Builder as B
-- import Keiki.Builder        ((.=))
-- import qualified Prelude
--
-- emailDelivery
--   :: 'Keiki.Core.SymTransducer' ('Keiki.Core.HsPred' EmailRegs EmailCmd)
--                                  EmailRegs EmailVertex
--                                  EmailCmd EmailEvent
-- emailDelivery = B.'buildTransducer' EmailPending emptyEmailRegs
--                   (\\case EmailSentVertex -> True; _ -> False)
--                   $ Prelude.do        -- VertexBuilder is a plain Monad
--
--     B.'from' EmailPending Prelude.do  -- EdgeListBuilder is plain
--       B.'onCmd' inCtorSendEmail $ \\d -> B.do  -- EdgeBuilder is indexed
--         B.'slot' \@\"emailRecipient\" .= d.recipient
--         B.'slot' \@\"emailSubject\"   .= d.subject
--         B.'slot' \@\"emailSentAt\"    .= d.at
--         B.'emit' wireEmailSent EmailSentTermFields
--           { recipient = d.recipient
--           , subject   = d.subject
--           , at        = d.at
--           }
--         B.'goto' EmailSentVertex
--
--     B.'from' EmailSentVertex (Prelude.pure ())  -- terminal
-- @
--
-- The user's aggregate module needs three pragmas / imports:
--
--   * @{-\# LANGUAGE QualifiedDo \#-}@ — so @B.do@ resolves to this
--     module's indexed bind.
--   * @{-\# LANGUAGE BlockArguments \#-}@ — so a @B.do@ block can
--     appear as a function argument without parentheses.
--   * @import qualified Keiki.Builder as B@ /and/
--     @import Keiki.Builder ((.=))@ — the operator must be in scope
--     unqualified; @B.(.=)@ is unreadable.
--
-- The @\<CtorName\>TermFields@ record (e.g. @EmailSentTermFields@)
-- is generated by 'Keiki.Generics.TH.deriveWireCtors' alongside the
-- existing @wire\<CtorName\>@ value. For ad-hoc cases that do not
-- correspond to a single event ctor, the lower-level operator sugar
-- '(*:)' / 'oNil' (re-exports of 'Keiki.Core.OFCons' /
-- 'Keiki.Core.OFNil') builds the 'OutFields' HList directly:
--
-- @
-- B.'emit' wireEmailSent (d.recipient *: d.subject *: d.at *: B.'oNil')
-- @
--
-- Both shapes resolve through the 'ToOutFields' typeclass; both
-- produce the same 'Keiki.Core.OPack' AST node.
--
-- == Three-layer monad shape
--
-- Three carriers, only the innermost is indexed:
--
--   1. 'VertexBuilder' (plain 'Monad') — the top-level. State is
--      a list @[(v, [Edge ...])]@; 'from' writes one entry.
--   2. 'EdgeListBuilder' (plain 'Monad') — the per-source-vertex
--      layer. State is the list of edges out of that vertex;
--      'onCmd' \/ 'onEpsilon' each prepend one.
--   3. 'EdgeBuilder' (indexed) — the per-edge body. Type-level
--      @(w :: [Symbol])@ tracks the slots written so far; '(.=)'
--      extends @w@ and inherits a 'Disjoint'-driven static check.
--
-- The 'QualifiedDo' machinery only re-binds @(>>=)@/@(>>)@ for the
-- innermost layer; the outer two use 'Prelude.do'.
--
-- == Misuse diagnostics
--
-- * Duplicate '(.=)' to the same slot: caught at compile time via
--   the 'Keiki.Internal.Slots.Disjoint' 'GHC.TypeError.TypeError',
--   which names the duplicated slot.
--
-- * Duplicate names in the register file: caught at compile time by
--   the 'Keiki.Internal.Slots.DistinctNames' constraint on the two
--   build entry points.
--
-- * An 'emit' whose term-fields schema differs from its enclosing
--   'onCmd', or an 'emit' inside 'onEpsilon': caught at compile time.
--
-- * Missing or multiple 'goto's: every declared edge is checked when
--   the transducer returned by 'buildTransducer' is first evaluated,
--   even if its source vertex is never inspected. All defects are
--   reported together with source vertices and edge indices. Use
--   'buildTransducerEither' to receive them as structured values.
--
-- * An 'emitWith' inside 'onCmd' whose explicit input constructor
--   differs in name or slot names from the enclosing constructor:
--   rejected by the same eager validation pass.
--
-- * An edge with neither 'emit' / 'emitWith' nor 'noEmit': rejected
--   by eager validation with a located structured defect directing
--   the user to declare whether the edge emits or is deliberately
--   silent.
--
-- == When to drop down to the AST
--
-- Use the AST directly when:
--
--   * The aggregate has bespoke guard logic the builder does not
--     express (a hand-built 'HsPred' tree the builder cannot
--     accumulate via 'requireEq' / 'requireGuard').
--   * The aggregate composes 'Edge' values from helper functions
--     defined elsewhere (the builder is meant to /author/ edges, not
--     to be a pluggable assembly tool).
--
-- Both directions can coexist in one module: the builder produces
-- @'SymTransducer'@s of the same type the AST does, and
-- "Keiki.Composition" 'Keiki.Composition.compose' takes the
-- builder-produced values without modification.
module Keiki.Builder
  ( -- * Top-level entry point
    buildTransducer,
    buildTransducerEither,
    BuilderDefect (..),
    BuilderError (..),
    renderBuilderErrors,

    -- * Vertex-level builder
    VertexBuilder,
    from,

    -- * Edge-list builder (per source vertex)
    EdgeListBuilder,
    onCmd,
    onEpsilon,

    -- * Edge body builder (per outgoing transition)
    EdgeBuilder,

    -- ** Slot writes
    slot,
    (.=),
    (=:),
    reg,

    -- ** Outputs
    emit,
    emitWith,
    noEmit,

    -- ** Output-fields HList sugar
    (*:),
    oNil,

    -- ** Field-keyed record sugar
    ToOutFields (..),

    -- ** Guards
    requireEq,
    requireGuard,
    Cmp (..),
    requireCmp,
    requireLt,
    requireLe,
    requireGt,
    requireGe,

    -- ** Termination
    goto,

    -- ** Payload projection (OverloadedRecordDot)
    PayloadProj,

    -- * QualifiedDo bind/return exports
    -- $qualifiedDo
    (>>=),
    (>>),
    pure,
    return,
  )
where

import Data.List (intercalate)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Typeable (Typeable)
import GHC.Records (HasField (..))
import GHC.TypeLits (KnownSymbol, Symbol)
import Keiki.Core
  ( Cmp (..),
    Edge (..),
    HsPred (..),
    InCtor (..),
    Index,
    OutFields (..),
    OutTerm (..),
    RegFile,
    Slot,
    SymTransducer (..),
    Term (TReg),
    Update (..),
    WireCtor,
    combine,
    inpCtor,
    matchInCtor,
    oNil,
    pack,
    slotNamesOf,
    (*:),
  )
import Keiki.Core qualified as K
import Keiki.Internal.Slots
  ( Concat,
    Disjoint,
    DistinctNames,
    HasIndexN (..),
    IndexN (..),
    Names,
  )
import Prelude hiding (pure, return, (>>), (>>=))
import Prelude qualified

-- $qualifiedDo
--
-- @QualifiedDo@ desugars @B.do { … }@ to @B.>>=@, @B.>>@, @B.pure@,
-- @B.return@. These exports are the indexed analogues that thread
-- the type-level slot-set through every edge-body step. They are
-- not the right operators for the outer 'VertexBuilder' /
-- 'EdgeListBuilder' layers — those use the regular 'Prelude.do'
-- syntax with the 'Monad' instances declared below.

-- * The per-edge state ----------------------------------------------------

-- | The growing edge state inside an 'EdgeBuilder' body. Lifecycle:
-- 'onCmd' / 'onEpsilon' construct an initial 'PartialEdge' (with
-- 'PTop' or @'matchInCtor' ic@ as guard, 'UKeep' as update, no
-- output, no targets); each step in the body modifies one or more
-- fields; 'finalizeEdge' validates that exactly one 'goto' was
-- called and that output intent was declared, then packages the
-- result into a closed 'Edge'. The
-- existential @w@ on 'Edge''s 'update' field closes here.
data PartialEdge rs ci co v (pin :: Maybe [Slot]) (w :: [Symbol]) = PartialEdge
  { peGuard :: HsPred rs ci,
    peUpdate :: Update rs w ci,
    -- | Output terms accumulated by 'emit' / 'emitWith' calls in
    -- declaration order (snoc-appended). The empty list is an ε-edge
    -- (@output = []@ on the resulting 'Edge'); a single 'emit' yields
    -- a length-1 list (today's letter behaviour); two or more 'emit's
    -- in one body yield a multi-event edge (EP-19).
    peOutput :: [OutTerm rs ci co],
    -- | Reverse-order list of every 'goto' invocation in the body.
    -- Finalization requires exactly one element.
    peTargets :: [v],
    -- | The 'InCtor' bound by the enclosing 'onCmd', indexed by its
    -- real input-field schema. 'PinNone' marks an 'onEpsilon' body.
    pePinned :: Pinned ci pin,
    -- | Whether the body explicitly chose to emit or remain silent.
    -- Any 'emit', 'emitWith', or 'noEmit' call sets this to 'True'.
    peOutputDecided :: Bool
  }

-- | Whether an edge body has an enclosing input constructor. The
-- schema index preserves the equality that 'emit' needs.
data Pinned ci (pin :: Maybe [Slot]) where
  PinNone :: Pinned ci 'Nothing
  PinCtor :: InCtor ci ifs -> Pinned ci ('Just ifs)

-- | The per-edge indexed-state monad. The two phantom slot-set
-- indices @(w :: [Symbol])@ (before this step) and @(w' :: [Symbol])@
-- (after this step) make every '(.=)' visible to the type system,
-- so a duplicated @'(.=)'@ to the same slot fails at the offending
-- line via the 'Keiki.Internal.Slots.Disjoint' constraint that
-- 'Keiki.Core.combine' carries.
--
-- Functor / Applicative / Monad instances are not provided because
-- they would be 'IxFunctor' / 'IxApplicative' / 'IxMonad' (the
-- type-level slot-set changes between operand and result), which
-- requires a separate type-class hierarchy. Instead, this module
-- exports its own @(>>=)@ / @(>>)@ / 'pure' / 'return' for use
-- with @QualifiedDo@.
newtype EdgeBuilder rs ci co v (pin :: Maybe [Slot]) (w :: [Symbol]) (w' :: [Symbol]) a
  = EdgeBuilder
  { runEdgeBuilder ::
      PartialEdge rs ci co v pin w ->
      (a, PartialEdge rs ci co v pin w')
  }

-- * QualifiedDo bind/return exports ----------------------------------------

-- | Indexed bind. The @w@ index of the first argument flows through
-- the second argument's @w@ argument, and the second argument's @w'@
-- index becomes the result's @w'@. Re-export for @QualifiedDo@.
(>>=) ::
  EdgeBuilder rs ci co v pin w1 w2 a ->
  (a -> EdgeBuilder rs ci co v pin w2 w3 b) ->
  EdgeBuilder rs ci co v pin w1 w3 b
EdgeBuilder f >>= k = EdgeBuilder $ \pe ->
  let (a, pe1) = f pe
      EdgeBuilder g = k a
   in g pe1

infixl 1 >>=

-- | Sequence. Defined in terms of '(>>=)'.
(>>) ::
  EdgeBuilder rs ci co v pin w1 w2 a ->
  EdgeBuilder rs ci co v pin w2 w3 b ->
  EdgeBuilder rs ci co v pin w1 w3 b
m >> n = m Keiki.Builder.>>= \_ -> n

infixl 1 >>

-- | Embed a value. Slot-set unchanged.
pure :: a -> EdgeBuilder rs ci co v pin w w a
pure a = EdgeBuilder $ \pe -> (a, pe)

-- | Synonym for 'pure'. Re-exported for @QualifiedDo@.
return :: a -> EdgeBuilder rs ci co v pin w w a
return = Keiki.Builder.pure

-- * Slot writes ----------------------------------------------------------

-- | Lift a slot name (supplied via @TypeApplication@) to its
-- slot-name-tagged register index. Use with '(.=)':
--
-- > slot @"emailRecipient" .= d.recipient
--
-- == Why @slot \@\"name\"@ instead of @\#name@
--
-- The @\#name@ overloaded-label syntax tries to resolve
-- @IsLabel \"name\" (IndexN s rs r)@ against the instance head
-- @IsLabel s (IndexN s rs r)@. GHC will not commit to @s ~ \"name\"@
-- when @name@ is a quantified type variable in the enclosing
-- operator's signature (the pattern-side @s@ appears at two
-- positions in the constraint head; without an explicit annotation,
-- GHC defers commitment). 'slot' pins the symbol via TypeApplication
-- so the inference proceeds without ambiguity. Slot name still
-- appears once.
slot ::
  forall (name :: Symbol) rs r.
  (KnownSymbol name, HasIndexN name rs r) =>
  IndexN name rs r
slot = indexN @name @rs @r

-- | Read a register slot into a 'Keiki.Core.Term', the read-side
-- mirror of 'slot'. The slot name is supplied via @TypeApplication@,
-- so @reg \@\"appCreditScore\"@ needs no @:: 'Keiki.Core.Index' Regs
-- Ty@ annotation:
--
-- > approvalGuard = reg \@\"appCreditScore\" .>= lit 650
--
-- == When to use @reg \@\"name\"@ versus @\#name@
--
-- A bare overloaded label @\#name@ already resolves to a register-read
-- 'Keiki.Core.Term' through the @'GHC.OverloadedLabels.IsLabel' s
-- ('Keiki.Core.Term' rs ci r)@ instance, and is the lighter form
-- wherever GHC can infer the slot list @rs@ and value type @r@ — for
-- example the right-hand side of '(.=)', or an argument of
-- 'Keiki.Core.TApp1'. In positions where inference fails — notably a
-- hand-written guard conjunction, or an 'OutFields' element — @\#name@
-- needs the verbose @'Keiki.Core.proj' (\#name :: 'Keiki.Core.Index'
-- Regs Ty)@ annotation. 'reg' removes exactly that annotation by
-- pinning the name with a type application, the same way 'slot' does on
-- the write side. A consumer whose prelude re-exports @generic-lens@
-- (which ships its own @IsLabel@ instance that shadows keiki's) loses
-- the bare-@\#name@ read path entirely; because 'reg' goes through a
-- type application rather than an overloaded label, it is unaffected.
reg ::
  forall (name :: Symbol) rs ci ifs r.
  (KnownSymbol name, HasIndexN name rs r) =>
  Term rs ci ifs r
reg = TReg (indexNToIndex (indexN @name @rs @r))

-- | Slot assignment. The slot name is supplied by 'slot' (via
-- TypeApplication); the value is a 'Term'. The
-- @'Disjoint' '[name] w@ constraint inherits the type-level
-- distinct-targets check from 'Keiki.Core.combine': a duplicated
-- @'(.=)'@ to the same slot fails to type-check at the offending
-- line, with the existing 'Keiki.Internal.Slots.Disjoint'
-- 'GHC.TypeError.TypeError' naming the slot.
--
-- The RHS is a 'Term' (not a bare value); use
-- 'Keiki.Core.lit' / 'Keiki.Core.proj' / 'Keiki.Core.inpCtor' or
-- @d.fieldName@ via 'PayloadProj' to construct it.
(.=) ::
  forall name r rs ci ifs co v pin w.
  (KnownSymbol name, Disjoint '[name] w) =>
  IndexN name rs r ->
  Term rs ci ifs r ->
  EdgeBuilder rs ci co v pin w (Concat '[name] w) ()
ix .= t = EdgeBuilder $ \pe ->
  ((), pe {peUpdate = USet ix t `combine` peUpdate pe})

infixr 6 .=

-- | Slot assignment, an exact synonym for '(.=)': @slot \@\"x\" =: t@
-- is @slot \@\"x\" .= t@ and produces the identical 'Keiki.Core.Update'.
-- It exists for one reason — to dodge the name clash with
-- @Control.Lens.(.=)@. A module that authors edges /and/ imports
-- "Control.Lens" would otherwise need @import Control.Lens hiding
-- ((.=))@; with '(=:)' it can keep both imports unqualified and use
-- '(=:)' for slot writes. Modules that do not import "Control.Lens"
-- should keep using '(.=)', which matches the @.=@ spelling of @aeson@
-- \/ @lens@ \/ @mtl@. (A colon-prefixed @:=@ is not available: GHC
-- reserves operators beginning with a colon for data constructors, so a
-- value-level synonym must start with another symbol — hence @=:@.)
(=:) ::
  forall name r rs ci ifs co v pin w.
  (KnownSymbol name, Disjoint '[name] w) =>
  IndexN name rs r ->
  Term rs ci ifs r ->
  EdgeBuilder rs ci co v pin w (Concat '[name] w) ()
(=:) = (.=)

infixr 6 =:

-- * Termination -----------------------------------------------------------

-- | Set the edge's target vertex. Required exactly once per edge
-- body; missing or multiple 'goto's are reported by the eager
-- validation pass, with the source vertex and edge index.
goto :: v -> EdgeBuilder rs ci co v pin w w ()
goto v = EdgeBuilder $ \pe ->
  ((), pe {peTargets = v : peTargets pe})

-- * Outputs ---------------------------------------------------------------

-- | Emit an event. Takes the wire-side 'WireCtor' and an output
-- description that resolves to an 'OutFields' via 'ToOutFields' —
-- either a per-event @\<CtorName\>TermFields rs ci@ record (emitted
-- by 'Keiki.Generics.TH.deriveWireCtors') or a bare 'OutFields'
-- HList constructed with '(*:)' / 'oNil'. The input-side 'InCtor'
-- is recovered at its real schema from the enclosing 'onCmd'. An
-- 'emit' inside 'onEpsilon' is a compile error; use 'emitWith' there.
--
-- == Multi-event commands (EP-19)
--
-- Each 'emit' call snoc-appends one 'OutTerm' to the edge's output
-- list. A single 'emit' in the body produces a letter edge
-- (@output = [o]@); two or more 'emit's in the same body produce a
-- multi-event edge (@output = [o1, o2, ...]@) whose semantics is
-- documented at 'Keiki.Core.Edge'. The 'OutTerm's evaluate against
-- the same pre-transition @(regs, ci)@ snapshot; register updates
-- accumulated by '(.=)' apply once at the edge level, not per
-- emitted event.
emit ::
  forall co fs rs ci ifs v w rec.
  (ToOutFields rec rs ci ifs fs) =>
  WireCtor co fs ->
  rec ->
  EdgeBuilder rs ci co v ('Just ifs) w w ()
emit wc rec = EdgeBuilder $ \pe -> case pePinned pe of
  PinCtor ic ->
    ( (),
      pe
        { peOutput =
            peOutput pe
              ++ [pack ic wc (toOutFields rec)],
          peOutputDecided = True
        }
    )

-- | Emit an event with an explicit 'InCtor'. This is the output form
-- for 'onEpsilon' bodies, which have no pinned constructor. Inside
-- 'onCmd', the supplied constructor must have the same name and slot
-- names as the pinned constructor; 'buildTransducer' rejects a
-- mismatch eagerly because it would invert replay to another command.
-- The InCtor-less 'emit' is preferred inside 'onCmd'. Like 'emit',
-- accumulates into the edge's output list — multiple calls produce a
-- multi-event edge.
emitWith ::
  forall co fs rs ci v pin w ifs rec.
  (ToOutFields rec rs ci ifs fs) =>
  InCtor ci ifs ->
  WireCtor co fs ->
  rec ->
  EdgeBuilder rs ci co v pin w w ()
emitWith ic wc rec = EdgeBuilder $ \pe ->
  ( (),
    pe
      { peOutput = peOutput pe ++ [pack ic wc (toOutFields rec)],
        peOutputDecided = True
      }
  )

-- | Declare the edge deliberately silent (an ε-edge with
-- @output = []@). Every body that does not call 'emit' or 'emitWith'
-- must call 'noEmit'; otherwise eager builder validation rejects the
-- edge. Mixing 'noEmit' and 'emit' in one body is allowed, and the
-- emitted terms still populate the output list.
noEmit :: EdgeBuilder rs ci co v pin w w ()
noEmit = EdgeBuilder $ \pe -> ((), pe {peOutputDecided = True})

-- * Field-keyed record sugar ---------------------------------------------

-- | Convert a value of any type bearing the wire-side fields of an
-- event to the 'OutFields' HList that 'pack' (and therefore 'OPack')
-- consumes.
--
-- Two kinds of inhabitant matter:
--
--   * The TH-emitted per-event record type
--     @\<CtorName\>TermFields rs ci@ (one record per event ctor in
--     'Keiki.Generics.TH.deriveWireCtors''s spec list). Its fields
--     are 'Term'-typed mirrors of the payload's fields, so call
--     sites read top-to-bottom keyed by name.
--
--   * The bare 'OutFields' value built with '(*:)' \/ 'oNil', for
--     which the passthrough instance (id) makes the same 'B.emit'
--     overload accept the operator form unchanged.
--
-- The functional dependency @rec -> rs ci fs@ ensures a record type
-- uniquely determines all of @rs@, @ci@, and @fs@, so type
-- inference at call sites is local: GHC propagates them from the
-- record's type alone.
class ToOutFields rec rs ci ifs fs | rec -> rs ci ifs fs where
  toOutFields :: rec -> OutFields rs ci ifs fs

-- | Passthrough: a bare 'OutFields' is its own conversion. Lets
-- 'B.emit' accept either a per-event record or an
-- @(t1 *: t2 *: oNil)@ chain through the same overload.
instance ToOutFields (OutFields rs ci ifs fs) rs ci ifs fs where
  toOutFields = id

-- * Guards ----------------------------------------------------------------

-- | Conjoin an arbitrary 'HsPred' with the edge's existing guard.
-- Use this when the structural sugar of 'requireEq' is not enough
-- (e.g. for negated predicates, disjunctions, or guards constructed
-- by helper functions).
requireGuard :: HsPred rs ci -> EdgeBuilder rs ci co v pin w w ()
requireGuard p = EdgeBuilder $ \pe ->
  ((), pe {peGuard = PAnd (peGuard pe) p})

-- | Conjoin an equality predicate (@a '==' b@) with the edge's
-- existing guard.
requireEq ::
  (Eq r, Typeable r) =>
  Term rs ci ifs1 r ->
  Term rs ci ifs2 r ->
  EdgeBuilder rs ci co v pin w w ()
requireEq a b = requireGuard (PEq a b)

-- | Conjoin an ordering predicate (@a `op` b@ for the relation named
-- by 'Cmp') with the edge's existing guard. Unlike a threshold lifted
-- through 'Keiki.Core.TApp1'\/'TApp2', a 'PCmp' guard is structural and
-- visible to the SBV-backed analyses. The four direction-specific
-- conveniences 'requireLt'\/'requireLe'\/'requireGt'\/'requireGe' wrap
-- this with a fixed 'Cmp'.
requireCmp ::
  (Ord r, Typeable r) =>
  Cmp ->
  Term rs ci ifs1 r ->
  Term rs ci ifs2 r ->
  EdgeBuilder rs ci co v pin w w ()
requireCmp op a b = requireGuard (PCmp op a b)

-- | Require @a < b@. See 'requireCmp'.
requireLt,
  requireLe,
  requireGt,
  requireGe ::
    (Ord r, Typeable r) =>
    Term rs ci ifs1 r ->
    Term rs ci ifs2 r ->
    EdgeBuilder rs ci co v pin w w ()
requireLt = requireCmp CmpLt
requireLe = requireCmp CmpLe
requireGt = requireCmp CmpGt
requireGe = requireCmp CmpGe

-- * Payload projection ----------------------------------------------------

-- | An opaque wrapper around an 'InCtor' that lets the user project
-- the input symbol's fields via 'OverloadedRecordDot' inside an
-- 'onCmd' body. The 'HasField' instance translates @d.fieldName@ to
-- @inpCtor ic (indexN \@fieldName \@ifs \@r)@.
--
-- 'PayloadProj' has no record selectors of its own so the user's
-- @d.fieldName@ never collides with a built-in selector.
data PayloadProj rs ci ifs = PayloadProj (InCtor ci ifs)

-- | OverloadedRecordDot resolution: @d.fieldName@ on a 'PayloadProj'
-- builds a 'TInpCtorField' term that projects the named field of the
-- input symbol's payload.
instance
  (HasIndexN name ifs r) =>
  HasField name (PayloadProj rs ci ifs) (Term rs ci ifs r)
  where
  getField (PayloadProj ic) =
    inpCtor ic (indexNToIndex (indexN @name @ifs @r))

-- | Translate the slot-name-tagged 'IndexN' into the legacy
-- existentially-typed 'Index' that 'Keiki.Core.inpCtor' expects.
-- Both indices have the same runtime structure; the translation is
-- a structural recursion. (M3+ may widen 'inpCtor' to take 'IndexN'
-- directly; this helper keeps the spike's legacy bridge.)
indexNToIndex :: forall name rs r. IndexN name rs r -> Index rs r
indexNToIndex IZ = K.ZIdx
indexNToIndex (IS i) = K.SIdx (indexNToIndex i)

-- * Edge-list builder -----------------------------------------------------

-- | Per-source-vertex builder. Accumulates the list of outgoing
-- edges for the @from@-scope's vertex; each 'onCmd' / 'onEpsilon'
-- call prepends one edge (the list is reversed in 'from' before
-- storage so declaration order is preserved).
newtype EdgeListBuilder rs ci co v a = EdgeListBuilder
  { runEdgeListBuilder ::
      v ->
      [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)] ->
      (a, [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)])
  }

instance Functor (EdgeListBuilder rs ci co v) where
  fmap f (EdgeListBuilder k) = EdgeListBuilder $ \src acc ->
    let (a, acc') = k src acc in (f a, acc')

instance Applicative (EdgeListBuilder rs ci co v) where
  pure a = EdgeListBuilder $ \_ acc -> (a, acc)
  EdgeListBuilder kf <*> EdgeListBuilder ka = EdgeListBuilder $ \src acc ->
    let (f, acc1) = kf src acc
        (a, acc2) = ka src acc1
     in (f a, acc2)

instance Monad (EdgeListBuilder rs ci co v) where
  (>>=) (EdgeListBuilder k) f = EdgeListBuilder $ \src acc ->
    let (a, acc') = k src acc
        EdgeListBuilder k' = f a
     in k' src acc'

-- | Per-edge entry. Wires the InCtor's match-guard, gives the user
-- a 'PayloadProj' handle (so OverloadedRecordDot resolves
-- @d.field@), runs the body to accumulate the edge, and finalizes
-- into a closed 'Edge'.
onCmd ::
  forall ci ifs rs co v w.
  InCtor ci ifs ->
  (PayloadProj rs ci ifs -> EdgeBuilder rs ci co v ('Just ifs) '[] w ()) ->
  EdgeListBuilder rs ci co v ()
onCmd ic body = EdgeListBuilder $ \_src acc ->
  let initial =
        PartialEdge
          { peGuard = matchInCtor ic,
            peUpdate = UKeep,
            peOutput = [],
            peTargets = [],
            pePinned = PinCtor ic,
            peOutputDecided = False
          }
      (_, finalPE) = runEdgeBuilder (body (PayloadProj ic)) initial
      edge = finalizeEdge finalPE
   in ((), edge : acc)

-- | ε-edge entry: no input projection, no input-ctor match-guard.
-- The guard starts at 'PTop' (so any conjuncts the body adds via
-- 'requireEq' / 'requireGuard' constitute the full guard). Inside
-- the body, no 'PayloadProj' is supplied, so 'OverloadedRecordDot'
-- access to the input is unavailable; use 'Keiki.Core.inpCtor'
-- directly with an explicit 'InCtor' if needed.
onEpsilon ::
  forall rs ci co v w.
  EdgeBuilder rs ci co v 'Nothing '[] w () ->
  EdgeListBuilder rs ci co v ()
onEpsilon body = EdgeListBuilder $ \_src acc ->
  let initial =
        PartialEdge
          { peGuard = PTop,
            peUpdate = UKeep,
            peOutput = [],
            peTargets = [],
            pePinned = PinNone,
            peOutputDecided = False
          }
      (_, finalPE) = runEdgeBuilder body initial
      edge = finalizeEdge finalPE
   in ((), edge : acc)

-- | One structural problem found while closing a single edge body.
data BuilderDefect
  = -- | The body never called 'goto'.
    DefectMissingGoto
  | -- | The body called 'goto' more than once; carries the count.
    DefectMultipleGoto Int
  | -- | An output constructor contradicts the enclosing 'onCmd'.
    DefectOutputCtorMismatch String [String] String [String]
  | -- | The body neither emitted nor explicitly declared silence.
    DefectMissingOutputIntent
  deriving stock (Eq, Show)

-- | A defect located at a specific edge of a specific source vertex.
-- The edge index is assigned after duplicate-'from' merging.
data BuilderError v = BuilderError
  { beVertex :: v,
    beEdgeIndex :: Int,
    beDefect :: BuilderDefect
  }
  deriving stock (Eq, Show)

-- | Close a 'PartialEdge' into an 'Edge'. Validation: 'peTargets'
-- must have exactly one entry; missing or duplicated 'goto' calls are
-- returned structurally. A single-target edge must also declare output
-- intent with 'emit', 'emitWith', or 'noEmit'; otherwise finalization
-- returns 'DefectMissingOutputIntent'. 'buildTransducerEither' attaches location.
-- The 'peOutput' list (zero or more 'OutTerm's accumulated by
-- 'emit' / 'emitWith' calls) flows directly into the resulting
-- 'Edge.output' field.
finalizeEdge ::
  PartialEdge rs ci co v pin w ->
  Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)
finalizeEdge pe = case peTargets pe of
  [t] -> case outputCtorMismatch (pePinned pe) (peOutput pe) of
    Just defect -> Left defect
    Nothing
      | not (peOutputDecided pe) -> Left DefectMissingOutputIntent
      | otherwise ->
          Right
            Edge
              { guard = peGuard pe,
                update = peUpdate pe,
                output = peOutput pe,
                target = t
              }
  [] -> Left DefectMissingGoto
  ts@(_ : _ : _) -> Left (DefectMultipleGoto (length ts))

outputCtorMismatch :: Pinned ci pin -> [OutTerm rs ci co] -> Maybe BuilderDefect
outputCtorMismatch PinNone _ = Nothing
outputCtorMismatch (PinCtor pinned) outputs = go outputs
  where
    expectedName = icName pinned
    expectedSlots = slotNamesOf pinned

    go [] = Nothing
    go (OPack actual _ _ : rest)
      | icName actual /= expectedName || slotNamesOf actual /= expectedSlots =
          Just
            ( DefectOutputCtorMismatch
                expectedName
                expectedSlots
                (icName actual)
                (slotNamesOf actual)
            )
      | otherwise = go rest

-- * Vertex builder --------------------------------------------------------

-- | Top-level builder. Accumulates @[(v, [Edge ...])]@ entries, one
-- per 'from' call. 'buildTransducer' converts the result into a
-- 'SymTransducer''s 'edgesOut' function via @lookup@ with @[]@ as
-- default for unmentioned vertices.
newtype VertexBuilder rs ci co v a = VertexBuilder
  { runVertexBuilder ::
      [(v, [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)])] ->
      (a, [(v, [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)])])
  }

instance Functor (VertexBuilder rs ci co v) where
  fmap f (VertexBuilder k) = VertexBuilder $ \vs ->
    let (a, vs') = k vs in (f a, vs')

instance Applicative (VertexBuilder rs ci co v) where
  pure a = VertexBuilder $ \vs -> (a, vs)
  VertexBuilder kf <*> VertexBuilder ka = VertexBuilder $ \vs ->
    let (f, vs1) = kf vs
        (a, vs2) = ka vs1
     in (f a, vs2)

instance Monad (VertexBuilder rs ci co v) where
  (>>=) (VertexBuilder k) f = VertexBuilder $ \vs ->
    let (a, vs') = k vs
        VertexBuilder k' = f a
     in k' vs'

-- | Group edges by source vertex. The argument is an
-- 'EdgeListBuilder' do-block of 'onCmd' / 'onEpsilon' calls; each
-- call adds one outgoing edge to the named vertex.
--
-- A vertex not mentioned in any 'from' block defaults to @[]@
-- (terminal). To assert "this vertex is terminal" explicitly,
-- write @from V (Prelude.pure ())@.
from ::
  v ->
  EdgeListBuilder rs ci co v () ->
  VertexBuilder rs ci co v ()
from v eb = VertexBuilder $ \vs ->
  let (_, accFinal) = runEdgeListBuilder eb v []
      entry = (v, Prelude.reverse accFinal)
   in ((), entry : vs)

-- | Top-level entry. Run the 'VertexBuilder' do-block to produce a
-- list of @(vertex, edges)@ pairs, then assemble a 'SymTransducer'
-- from the initial vertex, initial register file, finality
-- predicate, and a closure over the lookup table.
--
-- Duplicate-vertex entries (which can arise when two 'from' blocks
-- accidentally declare the same vertex) are merged: 'edgesOut'
-- returns the concatenation of every entry's edges in declaration
-- order.
--
-- Every declared edge is validated before the result is returned.
-- Forcing the returned transducer to weak head normal form therefore
-- reports malformed edges even when their source vertices are never
-- otherwise inspected. Use 'buildTransducerEither' for structured
-- diagnostics instead of an exception.
buildTransducer ::
  forall rs ci co v.
  (DistinctNames (Names rs), Eq v, Show v) =>
  v ->
  RegFile rs ->
  (v -> Bool) ->
  VertexBuilder rs ci co v () ->
  SymTransducer (HsPred rs ci) rs v ci co
buildTransducer initS initR isF vb =
  case buildTransducerEither initS initR isF vb of
    Left errs -> error (renderBuilderErrors errs)
    Right tr -> tr

-- | Validating entry point. Runs the complete 'VertexBuilder', merges
-- duplicate vertices in declaration order, assigns stable per-vertex
-- edge indices, and returns every structural defect found.
buildTransducerEither ::
  forall rs ci co v.
  (DistinctNames (Names rs), Eq v) =>
  v ->
  RegFile rs ->
  (v -> Bool) ->
  VertexBuilder rs ci co v () ->
  Either
    (NonEmpty (BuilderError v))
    (SymTransducer (HsPred rs ci) rs v ci co)
buildTransducerEither initS initR isF vb =
  forceBuilderErrors errors `seq`
    forceEdgeTable cleanTable `seq`
      case NonEmpty.nonEmpty errors of
        Just errs -> Left errs
        Nothing ->
          Right
            SymTransducer
              { edgesOut = \v -> maybe [] id (lookup v cleanTable),
                initial = initS,
                initialRegs = initR,
                isFinal = isF
              }
  where
    (_, rawEntries) = runVertexBuilder vb []
    mergedEntries = foldl' mergeEntry [] (Prelude.reverse rawEntries)
    locatedEntries =
      [ (v, zipWith (locate v) [0 ..] results)
      | (v, results) <- mergedEntries
      ]
    errors =
      [ builderError
      | (_, results) <- locatedEntries,
        Left builderError <- results
      ]
    cleanTable =
      [ (v, [edge | Right edge <- results])
      | (v, results) <- locatedEntries
      ]

    mergeEntry acc (v, edges) =
      case break ((== v) . fst) acc of
        (_, []) -> acc ++ [(v, edges)]
        (before, (existingV, existingEdges) : after) ->
          before ++ (existingV, existingEdges ++ edges) : after

    locate v edgeIndex = \case
      Left defect -> Left (BuilderError v edgeIndex defect)
      Right edge -> Right edge

-- | Render structured builder errors in the historical message format.
-- Multiple errors are joined by newlines in declaration order.
renderBuilderErrors :: (Show v) => NonEmpty (BuilderError v) -> String
renderBuilderErrors = intercalate "\n" . fmap renderBuilderError . NonEmpty.toList

renderBuilderError :: (Show v) => BuilderError v -> String
renderBuilderError (BuilderError src n defect) =
  "Keiki.Builder: edge #"
    <> show n
    <> " from "
    <> show src
    <> case defect of
      DefectMissingGoto ->
        ": goto missing. Each onCmd/onEpsilon body must end with exactly one goto V."
      DefectMultipleGoto _ ->
        ": goto called more than once. Each onCmd/onEpsilon body must end with exactly one goto V."
      DefectOutputCtorMismatch expectedName expectedSlots actualName actualSlots ->
        ": emitWith InCtor "
          <> show actualName
          <> " (slots "
          <> renderSlotNames actualSlots
          <> ") contradicts the enclosing onCmd's InCtor "
          <> show expectedName
          <> " (slots "
          <> renderSlotNames expectedSlots
          <> "). An onCmd edge's outputs must pack the command constructor the edge matches on, or replay will invert the event to a different command."
      DefectMissingOutputIntent ->
        ": no emit or noEmit. Each onCmd/onEpsilon body must call 'emit' (or 'emitWith') to produce an event, or 'noEmit' to declare the edge deliberately silent (ε-edge)."

renderSlotNames :: [String] -> String
renderSlotNames names = "[" <> intercalate "," names <> "]"

forceBuilderErrors :: [BuilderError v] -> ()
forceBuilderErrors [] = ()
forceBuilderErrors (builderError : rest) = builderError `seq` forceBuilderErrors rest

forceEdgeTable :: [(v, [Edge p rs ci co v])] -> ()
forceEdgeTable [] = ()
forceEdgeTable ((_, edges) : rest) = forceEdges edges `seq` forceEdgeTable rest
  where
    forceEdges [] = ()
    forceEdges (edge : more) =
      edge `seq` forceOutputSpine (output edge) `seq` forceEdges more

    forceOutputSpine [] = ()
    forceOutputSpine (_ : outputs) = forceOutputSpine outputs