# Guide

The [README](README.md) covers installation and a quick example. This guide covers every
feature in depth, one concept at a time.

- [Getting Started](#getting-started)
- [Context](#context)
- [Record](#record)
- [Menu](#menu)
- [Decisions](#decisions)
- [States & Transitions](#states--transitions)
- [Pagination & Truncation](#pagination--truncation)
- [Actions & Conditional Branching](#actions--conditional-branching)
- [Back Navigation](#back-navigation)
- [Resuming Sessions](#resuming-sessions)
- [Configurators](#configurators)
- [Localized Menus](#localized-menus)
- [Encrypted Records](#encrypted-records)
- [Exception Handling](#exception-handling)
- [Gateway Responses](#gateway-responses)
- [Visualizing a Flow](#visualizing-a-flow)
- [Simulating a Session](#simulating-a-session)
- [Linting a Flow](#linting-a-flow)
- [Generators](#generators)
- [Testing](#testing)
- [Session Events](#session-events)
- [Cache Backends](#cache-backends)
- [Configuration Reference](#configuration-reference)

## Getting Started

Every request into a flow is a `Ussd.Context` (who's calling, what they typed) run through
a builder pipeline that ends in `Ussd.run/1`:

```elixir
alias Ussd.Context

context = Context.new(session_id, phone_number, input)

Ussd.build(context)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.run()
```

`Context.new/3` takes three strings:

- `uid` - unique to *this specific dial-in*. Usually your gateway's session id.
- `gid` - shared across a caller's redials. Usually their phone number.
- `input` - what they typed this request. An empty string on the very first request.

`Ussd.run/1` looks up (or starts) the session behind that context, resolves exactly one
screen, and returns whatever `Ussd.use_response/2`'s formatter produces - a string, a map,
whatever your gateway expects.

## Context

`Ussd.Context` is the read-only value every `render/1`, `c:Ussd.Action.execute/1`, and
`transition`/`back`/`paginate` callback receives.

```elixir
Ussd.Context.uid(context)     #=> "session-abc123"
Ussd.Context.gid(context)     #=> "233200000000"
Ussd.Context.input(context)   #=> "1"
```

Attach extra values your states need - the caller's network, a request header, anything
your gateway sent alongside the standard fields - with `with_bag/2`, and read them back
with `get/2`:

```elixir
context =
  Context.new(session_id, phone, input)
  |> Context.with_bag(%{network: params["network"]})

Ussd.Context.get(context, :network) #=> "MTN"
```

`with_bag/2` *replaces* the bag rather than merging into it, so set everything you need in
one call before passing the context to `Ussd.build/1`.

## Record

`Ussd.Record` is per-session storage - the thing that lets a flow remember what the caller
already told it across requests. It's automatically namespaced by the context's `uid`,
backed by a pluggable `Ussd.Cache` (see [Cache Backends](#cache-backends)):

```elixir
def render(context) do
  record = Ussd.Record.new(Ussd.Context.uid(context), Ussd.Context.gid(context))

  Ussd.Record.set(record, "selected_plan", "unlimited")
  Ussd.Record.get(record, "selected_plan")       #=> "unlimited"
  Ussd.Record.get(record, "missing_key", "none") #=> "none"
end
```

Every read/write function accepts options:

- `:ttl` - seconds until the value expires on its own (default: never)
- `:public` - store under `gid` instead of `uid` (default: `false`), so the value is
  visible to a *different* `uid` sharing the same `gid` - this is exactly how "continuing"
  sessions (see [Resuming Sessions](#resuming-sessions)) hand a live session from an
  expired `uid` to the redial's new one

The full API: `get/4`, `set/4`, `get_many/4`, `set_many/3`, `has?/3`, `increment/4`,
`decrement/4`, `forget/3`, `forget_many/3`, plus `locale/1`/`set_locale/2` and
`set_encrypted/4`/`get_encrypted/4` (see [Encrypted Records](#encrypted-records)).

```elixir
Ussd.Record.increment(record, "otp_attempts")        #=> 1
Ussd.Record.increment(record, "otp_attempts")        #=> 2
Ussd.Record.set_many(record, %{"a" => 1, "b" => 2})
Ussd.Record.get_many(record, ["a", "b", "c"])        #=> [1, 2, nil]
```

## Menu

`Ussd.Menu` is the fluent builder every `render/1` returns. Build one with `build/0` and
grow it with the pipe operator:

```elixir
@impl Ussd.State
def render(_context) do
  Ussd.Menu.build()
  |> Ussd.Menu.line("Welcome to Speso Bank")
  |> Ussd.Menu.line("Select an option")
  |> Ussd.Menu.listing(["Check Balance", "Transfer", "Buy Airtime"])
  |> Ussd.Menu.line_break()
  |> Ussd.Menu.text("Powered by Speso")
end
```

| Function | What it does |
|---|---|
| `text/2` | Appends raw text, no newline |
| `line/2` | Appends text followed by a newline |
| `line_break/2` | Appends `n` blank lines (default 1) |
| `listing/3` | Appends a numbered list; see below |
| `format/3` | Appends `:io_lib.format/2`-formatted text, e.g. `format(menu, "Balance: ~s", ["GHS 10"])` |
| `append/2` / `prepend/2` | Splices in another menu, or the menu built by a `(menu -> menu)` function |
| `trans/4` / `trans_line/4` | Appends a Gettext-translated string; see [Localized Menus](#localized-menus) |

`listing/3` takes options:

```elixir
Ussd.Menu.build()
|> Ussd.Menu.listing(
  ["Airtime", "Data Bundle"],
  numbering: &(&1 + 1),  # index -> label, default 1-based
  spacer: ". ",          # between number and item, default "."
  divider: "\n",         # between items, default "\n"
  page: 1,               # slice the list before rendering
  per_page: 5
)
```

A menu implements `String.Chars`, so `to_string(menu)` (or plain interpolation) always
gives you the rendered text - useful when a `truncate/2` decision needs to measure it.

## Decisions

A decision is a struct plus a `Ussd.Decision.decide/2` implementation - something that
looks at the caller's raw input string and says yes or no. They're used everywhere routing
happens: `transition/2`, `back/2`, `paginate/2`, `truncate/2`, and `c:Ussd.ContinueState.confirm/0`.

| Module | Matches when input... |
|---|---|
| `Ussd.Decisions.Equal.new(x)` | equals `x` (numeric-aware: `"7"` matches `7`) |
| `Ussd.Decisions.NotEqual.new(x)` | doesn't equal `x` |
| `Ussd.Decisions.Between.new(low, high)` | is in `[low, high]` inclusive, numerically |
| `Ussd.Decisions.NotBetween.new(low, high)` | is outside `[low, high]` |
| `Ussd.Decisions.GreaterThan.new(x)` | is greater than `x` |
| `Ussd.Decisions.GreaterThanOrEqualTo.new(x)` | is greater than or equal to `x` |
| `Ussd.Decisions.LessThan.new(x)` | is less than `x` |
| `Ussd.Decisions.LessThanOrEqualTo.new(x)` | is less than or equal to `x` |
| `Ussd.Decisions.In.new([a, b, ...])` | equals any of the list |
| `Ussd.Decisions.NotIn.new([a, b, ...])` | equals none of the list |
| `Ussd.Decisions.IsNumeric.new()` | parses fully as an integer or float |
| `Ussd.Decisions.Length.new(n)` | is exactly `n` characters |
| `Ussd.Decisions.Regex.new(~r/.../)` | matches the given `Regex` |
| `Ussd.Decisions.Fallback.new()` | always matches - use as a catch-all last `transition/2` |

`Equal`, `Between`, and the other comparison decisions coerce both sides to numbers before
comparing when both sides *look* numeric, and fall back to string comparison otherwise -
`Between.new(1, 10)` correctly matches `"9"` and `"10"`, not just `"1"` through `"9"` the
way naive string comparison would.

Custom decisions are just a struct and an implementation - no macro required:

```elixir
defmodule MyApp.Decisions.EvenLength do
  defstruct []

  def new, do: %__MODULE__{}

  defimpl Ussd.Decision do
    def decide(_decision, actual), do: rem(String.length(actual), 2) == 0
  end
end
```

## States & Transitions

A `Ussd.State` is one screen. `use Ussd.State` gives you five macros to declare how it
routes, and requires you to implement `render/1`:

```elixir
defmodule MyApp.Ussd.States.Welcome do
  use Ussd.State, initial: true

  alias Ussd.Decisions.{Equal, Fallback}

  transition Equal.new("1"), to: MyApp.Ussd.States.Airtime
  transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle
  transition Fallback.new(), to: __MODULE__

  @impl Ussd.State
  def render(_context) do
    Ussd.Menu.build()
    |> Ussd.Menu.line("Welcome")
    |> Ussd.Menu.listing(["Airtime", "Data Bundle"])
  end
end
```

`transition/2` is repeatable - the *first* one whose decision matches wins, so put your
catch-all `Fallback` last. Each also accepts `callback:` (a 0- or 1-arity function run when
it matches, before moving on):

```elixir
transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle, callback: fn context ->
  Analytics.track(Ussd.Context.uid(context), "selected_data_bundle")
end
```

`initial: true` is advisory metadata - `mix ussd.lint` uses it to auto-discover flow entry
points when you don't pass one explicitly. It is *not* enforced by
`Ussd.use_initial_state/2`; any `Ussd.State` or `Ussd.Action` works there.

A state reached with input that matches nothing raises
`Ussd.Exceptions.NextStateNotFoundError` - run `mix ussd.lint` to find these before your
users do (see [Linting a Flow](#linting-a-flow)).

## Pagination & Truncation

**Pagination** pages a *structured* list (like `listing/3`) across screens. Mix in
`Ussd.Pagination` and declare `paginate/1`:

```elixir
defmodule MyApp.Ussd.States.Products do
  use Ussd.State
  use Ussd.Pagination, per_page: 5

  alias Ussd.Decisions.{Equal, Fallback}

  paginate next: Equal.new("#"), previous: Equal.new("0")
  transition Fallback.new(), to: MyApp.Ussd.States.ProductDetail

  @impl Ussd.Pagination
  def get_items(_context), do: Catalog.list_products()

  @impl Ussd.State
  def render(context) do
    Ussd.Menu.build()
    |> Ussd.Menu.listing(get_items(context), page: current_page(context), per_page: 5)
    |> Ussd.Menu.line(if has_next_page?(context), do: "#. More")
  end
end
```

`use Ussd.Pagination` injects `current_page/1`, `last_page/1`, `first_page?/1`,
`last_page?/1`, `has_next_page?/1`, and `has_previous_page?/1`, all backed by a counter in
`Ussd.Record`. You must implement `get_items/1`; `per_page/1` is either given via the
`per_page:` option or implemented yourself (e.g. if it varies per caller).

Pressing `next`/`previous` past the first/last page falls through to your other
`transition/2`s rather than looping - so a `Fallback` transition after `paginate/1` acts as
"anything else, including an exhausted next/previous, moves on".

**Truncation** caps how much of a *single long string* comes back per screen (some gateways
have a hard character limit per response):

```elixir
truncate limit: 160, ending: "\n0. More", more: Equal.new("0")
```

The rendered content is wrapped and shown a chunk at a time; pressing the `more:` decision's
key advances to the next chunk of the *same* state. Once exhausted, further input falls
through to `transition`/`back` normally, same as pagination.

## Actions & Conditional Branching

Sometimes the next state can't be a static `transition/2` - it depends on an HTTP call, a
database lookup, or anything else that has to run before you know where to go. A
`Ussd.Action` is a one-shot decision point with no screen shown to the caller:

```elixir
defmodule MyApp.Ussd.Actions.LoadAccount do
  @behaviour Ussd.Action

  @impl Ussd.Action
  def execute(context) do
    case Accounts.find(Ussd.Context.get(context, :phone)) do
      {:ok, _account} -> MyApp.Ussd.States.Dashboard
      :error -> MyApp.Ussd.States.Registration
    end
  end
end
```

Point a `transition/2` (or `Ussd.use_initial_state/2`) at an action just like a state - the
engine keeps calling `execute/1` while the result is itself an action, so actions can chain,
and stops once it reaches a real `Ussd.State` to render.

## Back Navigation

`back/2` returns to whatever state most recently transitioned *into* the current one, via an
automatic per-session stack - no manual bookkeeping:

```elixir
defmodule MyApp.Ussd.States.Airtime do
  use Ussd.State

  alias Ussd.Decisions.{Equal, Fallback}

  back Equal.new("0")
  transition Fallback.new(), to: MyApp.Ussd.States.Confirm

  @impl Ussd.State
  def render(_context), do: Ussd.Menu.build() |> Ussd.Menu.line("Enter amount") |> Ussd.Menu.line("0. Back")
end
```

Every `transition/2` that moves to a *different* state pushes the state being left onto the
stack; `back/2` pops it. If the stack is empty (nothing to go back to), `back/2` simply
doesn't match, and resolution falls through to `transition/2` as normal.

## Resuming Sessions

By default (`:start`, the implicit default), every dial is a brand-new session. Two other
modes let a redial pick back up after a dropped call, configured via
`Ussd.use_continuing_state/4`:

```elixir
Ussd.build(context)
|> Ussd.use_continuing_state(:continue, 300)   # resume silently, session valid 300s
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.run()
```

- **`:continue`** - a redial under a new session id (but the same `gid`/phone number)
  silently resumes exactly where the previous session left off, as long as it's within the
  given TTL.
- **`:confirm`** - instead of resuming silently, the redial is routed to a `continuing_state`
  - a module declared with `use Ussd.State, continue: true` and a `confirm/0` callback -
  which asks the caller whether they want to resume:

  ```elixir
  defmodule MyApp.Ussd.States.ConfirmResume do
    use Ussd.State, continue: true

    @impl Ussd.ContinueState
    def confirm, do: Ussd.Decisions.Equal.new("1")

    @impl Ussd.State
    def render(_context) do
      Ussd.Menu.build() |> Ussd.Menu.line("Resume your previous session?") |> Ussd.Menu.line("1. Yes")
    end
  end

  Ussd.build(context)
  |> Ussd.use_continuing_state(:confirm, 300, MyApp.Ussd.States.ConfirmResume)
  |> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
  |> Ussd.run()
  ```

  A reply matching `confirm/0`'s decision resumes the previous session; anything else starts
  a brand-new one at `initial_state`.

Both modes rely on `Ussd.Record`'s `public: true` scoping (see [Record](#record)) to hand
the live session from the expired `uid` to the redial's new one under the shared `gid`.

## Configurators

Group repeated `Ussd.use_*/2` setup so it isn't copy-pasted across every entry point:

```elixir
defmodule MyApp.UssdConfigurator do
  @behaviour Ussd.Configurator

  @impl Ussd.Configurator
  def configure(ussd) do
    ussd
    |> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
    |> Ussd.use_exception_handler(&MyApp.UssdErrors.handle/1)
  end
end

Ussd.build(context)
|> Ussd.use_configurator(MyApp.UssdConfigurator)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.run()
```

## Localized Menus

`Menu.trans/4` and `trans_line/4` translate via a configured Gettext backend:

```elixir
# mix.exs
{:gettext, "~> 0.20"}

# config/config.exs
config :ussd, :gettext_backend, MyApp.Gettext
```

```elixir
Ussd.Menu.build() |> Ussd.Menu.trans_line("greeting", %{name: caller_name})
```

The engine also persists a session's chosen locale (via `Ussd.Record.set_locale/2`) and
applies it automatically on every subsequent request in that session, so a caller who picks
French once stays in French for the rest of the conversation.

## Encrypted Records

Store sensitive values (PINs, account numbers) encrypted at rest instead of in plain text:

```elixir
# config/config.exs
config :ussd, :encryption_key, System.fetch_env!("USSD_ENCRYPTION_KEY")
```

```elixir
Ussd.Record.set_encrypted(record, "pin", "1234")
Ussd.Record.get_encrypted(record, "pin") #=> "1234"
Ussd.Record.get(record, "pin")           #=> the ciphertext, not "1234"
```

Encryption is AES-256-GCM; the configured key is hashed down to 256 bits, so it can be any
length/format convenient for you to manage as a secret.

## Exception Handling

An unhandled exception during `Ussd.run/1` doesn't crash the request - it's caught and
turned into a message via whatever's configured with `Ussd.use_exception_handler/2`:

```elixir
defmodule MyApp.UssdErrors do
  @behaviour Ussd.ExceptionHandler

  @impl Ussd.ExceptionHandler
  def handle(_exception), do: "Sorry, something went wrong. Please try again."
end

Ussd.build(context)
|> Ussd.use_exception_handler(MyApp.UssdErrors)
# or inline: |> Ussd.use_exception_handler(fn exception -> Exception.message(exception) end)
|> Ussd.run()
```

The session always ends (`terminating?: true`) after an exception - there's no live state
left to safely resume from.

## Gateway Responses

`Ussd.use_response/2` shapes the resolved `{message, terminating?}` into whatever your
gateway's HTTP contract expects. Six formatters ship out of the box:

| Module | Shape |
|---|---|
| `Ussd.Responses.AfricasTalking` | `"CON message"` / `"END message"` |
| `Ussd.Responses.Speso` | `%{"message" => ..., "action" => "prompt" \| "end"}` |
| `Ussd.Responses.Nalo` | `%{"USERID" => uid, "MSISDN" => gid, "USERDATA" => input, "MSG" => ..., "MSGTYPE" => bool}` |
| `Ussd.Responses.Nsano` | `%{"USSDResp" => %{"action" => ..., "menus" => "", "title" => ...}}` |
| `Ussd.Responses.Moolre` | `%{"message" => ..., "reply" => bool}` |
| `Ussd.Responses.Arkesel` | `%{"sessionID" => ..., "userID" => ..., "msisdn" => ..., "message" => ..., "continueSession" => bool}` |

```elixir
Ussd.build(context)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.run()
```

Or write your own - a module implementing `Ussd.Response`, or just an inline 3-arity
function `(context, message, terminating? -> term)`:

```elixir
mix ussd.gen.response MyGateway
```

## Visualizing a Flow

```
mix ussd.graph MyApp.Ussd.States.Welcome
mix ussd.graph MyApp.Ussd.States.Welcome --output flow.mmd
```

Walks every `transition/2` from the given state and renders a Mermaid `stateDiagram-v2`.
States only reachable via `back/2` collapse into a single "Previous state" node, matching
how the back-stack actually behaves at runtime; states resolved dynamically (via
`Ussd.Action`) are shown but not expanded further.

## Simulating a Session

```
mix ussd.simulate MyApp.Ussd.States.Welcome
mix ussd.simulate MyApp.Ussd.States.Welcome --phone 233200000000 --response Ussd.Responses.AfricasTalking
```

Walks a flow interactively in the terminal like a real handset - no gateway or phone
required. Useful flags: `--with KEY=VALUE` (repeatable, extra context bag values),
`--configurator MODULE` (repeatable), `--continuing-mode start|continue|confirm`,
`--continuing-state MODULE`, `--continuing-ttl SECONDS`, `--store MODULE`. Type `:restart`
to drop the call without ending the session, `:exit` to hang up.

## Linting a Flow

```
mix ussd.lint
mix ussd.lint MyApp.Ussd.States.Welcome
mix ussd.lint --strict
```

Walks the flow from its `initial: true` states (or the ones you pass explicitly) and
reports:

- **Errors** - a `transition/2` target that doesn't exist or isn't a `Ussd.State`/
  `Ussd.Action`; a state with no `transition`, `back`, or `terminate` at all (it would
  always raise `NextStateNotFoundError` if reached).
- **Warnings** - a state only reachable via `back/2` with no `transition`/`terminate` of its
  own; a duplicate `transition/2` match (the second one can never trigger); a state that
  exists but is never reached from any root.

`--strict` makes warnings fail the command too, so you can wire it into CI.

## Generators

```
mix ussd.gen.state Welcome
mix ussd.gen.state Welcome --initial
mix ussd.gen.state Confirm --continue
mix ussd.gen.action LoadAccount
mix ussd.gen.response MyGateway
mix ussd.gen.decision EvenLength
mix ussd.gen.configurator Default
mix ussd.gen.exception_handler Default
```

Each scaffolds a starting-point module under `lib/<namespace>/<kind>/<name>.ex`, where
`<namespace>` defaults to `<YourApp>.Ussd` (override with `config :ussd, :namespace`).

## Testing

`Ussd.Test` is a fluent ExUnit helper for walking a flow screen by screen:

```elixir
import Ussd.Test

test "buying airtime" do
  build(MyApp.Ussd.States.Welcome)
  |> start()
  |> assert_see("Welcome")
  |> input("1")
  |> assert_see("Enter amount")
  |> input("5")
  |> assert_terminated()
end
```

Available assertions: `assert_see/2`, `assert_terminated/1`, `assert_not_terminated/1`,
`assert_context_has/3`, `assert_context_missing/2`, `assert_record_has/3`,
`assert_record_missing/2` (the `/3` versions take an optional expected value or a 1-arity
predicate function). `acting_as/2` switches which simulated caller is dialing in - each
name gets its own session id and its own remembered screen, so you can test multi-party
scenarios and switch back and forth without losing either side's state. `timeout/3`
simulates a redial after a real sleep, for testing `:continue`/`:confirm` TTL expiry.

## Session Events

Two `:telemetry` events fire during `Ussd.run/1`, for logging or analytics without
touching core modules:

```elixir
:telemetry.attach("log-ussd-states", [:ussd, :state, :entered], fn _event, _measurements, %{state: state, context: context}, _config ->
  Logger.info("#{Ussd.Context.uid(context)} entered #{inspect(state)}")
end, nil)
```

- `[:ussd, :state, :entered]` - metadata `%{state: module, context: Ussd.Context.t()}`,
  fired every time a screen is about to render.
- `[:ussd, :session, :terminated]` - metadata `%{context: Ussd.Context.t()}`, fired when a
  response comes back with `terminating?: true`.

## Cache Backends

`Ussd.Record` reads/writes through a pluggable `Ussd.Cache` behaviour. The default,
`Ussd.Cache.ETS`, is process-local (a supervised GenServer + ETS table) - fine for a single
node, with real TTL expiry via both lazy reads and a periodic sweep. Anything running on
more than one node needs a shared backend (Redis, `:cachex` with a distributed adapter,
Mnesia, ...) - implement the six `Ussd.Cache` callbacks and point at it:

```elixir
# config/config.exs
config :ussd, :cache, MyApp.RedisCache

# or per-request:
Ussd.build(context) |> Ussd.use_store(MyApp.RedisCache) |> ...
```

## Configuration Reference

```elixir
config :ussd,
  namespace: "MyApp.Ussd",                            # mix ussd.gen.* / mix ussd.lint auto-discovery
  cache: Ussd.Cache.ETS,                               # or your own Ussd.Cache implementation
  cache_sweep_interval: :timer.minutes(1),             # Ussd.Cache.ETS's expired-entry sweep
  session_ttl: 300,                                    # seconds a mid-flow session survives with no input
  encryption_key: System.fetch_env!("USSD_ENCRYPTION_KEY"), # required for set_encrypted/get_encrypted
  gettext_backend: MyApp.Gettext,                      # required for Menu.trans/trans_line
  arkesel_user_id: System.get_env("ARKESEL_USER_ID")   # used by Ussd.Responses.Arkesel
```
