-- Quantum Arcade ledger: append-only double-entry accounting.
--
-- Amounts are millisatoshis stored as BIGINT. No UPDATE or DELETE is ever
-- issued against these tables; corrections are compensating transactions.
-- The constraints below restate the application's invariants so that a bug in
-- the Go layer cannot corrupt the books.

CREATE TYPE account_kind AS ENUM ('player', 'house', 'lightning_bridge');

CREATE TABLE accounts (
    id          BIGSERIAL PRIMARY KEY,
    kind        account_kind NOT NULL,
    -- Player accounts key on the ed25519 public key; system accounts use a
    -- stable name. Exactly one of these is set.
    pubkey      BYTEA UNIQUE,
    name        TEXT UNIQUE,
    nickname    TEXT,
    -- The Lightning bridge is the boundary with the outside world: its balance
    -- goes negative by exactly the amount owed to players inside the system.
    -- Every other account is strictly non-negative.
    allow_negative BOOLEAN NOT NULL DEFAULT false,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT account_identity CHECK (
        (kind = 'player'  AND pubkey IS NOT NULL AND name IS NULL) OR
        (kind <> 'player' AND pubkey IS NULL     AND name IS NOT NULL)
    )
);

CREATE TABLE transactions (
    id          BIGSERIAL PRIMARY KEY,
    kind        TEXT NOT NULL,           -- 'bet', 'payout', 'deposit', ...
    round_id    BIGINT,                  -- NULL for non-game transactions
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE postings (
    id             BIGSERIAL PRIMARY KEY,
    transaction_id BIGINT NOT NULL REFERENCES transactions(id),
    account_id     BIGINT NOT NULL REFERENCES accounts(id),
    -- Positive credits the account, negative debits it.
    amount_msat    BIGINT NOT NULL,
    balance_before BIGINT NOT NULL,
    balance_after  BIGINT NOT NULL,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT amount_nonzero       CHECK (amount_msat <> 0),
    CONSTRAINT balance_arithmetic   CHECK (balance_after = balance_before + amount_msat)
);

-- A CHECK constraint cannot consult another table, so the non-negative rule is
-- a trigger. It is the last line of defence behind the application's own check.
CREATE OR REPLACE FUNCTION enforce_balance_floor() RETURNS TRIGGER AS $$
DECLARE
    permitted BOOLEAN;
BEGIN
    SELECT allow_negative INTO permitted FROM accounts WHERE id = NEW.account_id;
    IF NOT permitted AND NEW.balance_after < 0 THEN
        RAISE EXCEPTION 'account % may not go negative (balance would be %)',
            NEW.account_id, NEW.balance_after;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER postings_balance_floor
    BEFORE INSERT ON postings
    FOR EACH ROW EXECUTE FUNCTION enforce_balance_floor();

CREATE INDEX postings_account_idx     ON postings (account_id, id DESC);
CREATE INDEX postings_transaction_idx ON postings (transaction_id);
CREATE INDEX transactions_round_idx   ON transactions (round_id) WHERE round_id IS NOT NULL;

-- Current balance is the most recent posting's balance_after.
CREATE VIEW account_balances AS
SELECT DISTINCT ON (account_id)
       account_id, balance_after AS balance_msat
FROM   postings
ORDER  BY account_id, id DESC;

-- Enforce append-only at the database level, not just by convention.
CREATE OR REPLACE FUNCTION reject_mutation() RETURNS TRIGGER AS $$
BEGIN
    RAISE EXCEPTION 'ledger tables are append-only';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER postings_append_only
    BEFORE UPDATE OR DELETE ON postings
    FOR EACH ROW EXECUTE FUNCTION reject_mutation();

CREATE TRIGGER transactions_append_only
    BEFORE UPDATE OR DELETE ON transactions
    FOR EACH ROW EXECUTE FUNCTION reject_mutation();

INSERT INTO accounts (kind, name, allow_negative) VALUES
    ('house', 'house_pot', false),
    ('lightning_bridge', 'lightning_bridge', true);
