/**
 * SessionRouter — the L4 inbound-message dispatch engine (Multi-Machine Session
 * Pool §L4 "Message Routing to Owner" + "Ownership CAS at Dispatch"). For every
 * inbound message the router resolves session ownership and dispatches:
 *
 *   1. Owned + owner alive + owner == self  → handle locally (no MeshRpc hop).
 *   2. Owned + owner alive + owner != self  → forward via deliverMessage over MeshRpc
 *      (signed, recipient-bound, idempotent on messageId; ACK before offset advance).
 *   3. Owned + owner DEAD                    → owner-dead re-placement (re-place + claim).
 *   4. Transient (placing/transferring)      → queue (ownership-contention).
 *   5. Unowned (new)                         → PlacementExecutor.decide() → SYNCHRONOUS
 *      CAS-claim → spawn on the winner (or, on CAS loss, forward to the winner / queue).
 *
 * Per-session ordering: messages for ONE sessionKey are dispatched strictly in
 * inbound order, at-most-one-in-flight (a per-session promise chain). Different
 * sessions dispatch concurrently. All I/O is injected so the dispatch logic itself
 * is deterministic + unit-testable; the deliverMessage dep owns its own per-attempt
 * timeout (it throws on timeout) — the router owns the retry/backoff/fallback loop.
 */
/**
 * Did this route() outcome place/forward the session onto ANOTHER machine — i.e.
 * the inbound dispatch caller must NOT also handle it locally?
 *
 * `'forwarded'`/`'duplicate'` always mean delivered to a remote owner. A fresh
 * placement on a remote machine returns `'spawned'` (self-placement returns
 * `'handled-locally'`, never `'spawned'`), and owner-dead re-placement returns
 * `'owner-dead-replaced'` for EITHER self or remote — so those two are remote only
 * when the resolved owner is not us. Everything else ('handled-locally', a
 * self 'owner-dead-replaced', 'queued', 'placement-blocked') is local/no-op and
 * falls through to local dispatch.
 *
 * Pure + caller-agnostic so the inbound dispatch decision is unit-testable. Before
 * this, the caller only treated 'forwarded'/'duplicate' as remote, so a just-moved
 * topic was spawned on the target AND injected into the stale local session
 * (double-dispatch — the bug the first live transfer test surfaced, 2026-05-31).
 */
export function isRemotelyHandled(outcome, selfMachineId) {
    if (outcome.action === 'forwarded' || outcome.action === 'duplicate')
        return true;
    const remoteOwner = outcome.owner != null && outcome.owner !== selfMachineId;
    if (outcome.action === 'spawned' && remoteOwner)
        return true;
    if (outcome.action === 'owner-dead-replaced' && remoteOwner)
        return true;
    return false;
}
export const DEFAULT_ROUTER_CONFIG = {
    deliverMessageMaxRetries: 3,
    deliverMessageRetryBackoffStartMs: 250,
    deliverMessageRetryBackoffMaxMs: 2000,
    maxReResolveDepth: 3,
};
export class SessionRouter {
    deps;
    cfg;
    chains = new Map();
    constructor(deps, cfg = DEFAULT_ROUTER_CONFIG) {
        this.deps = deps;
        this.cfg = cfg;
    }
    /** Dispatch an inbound message; serialized per-session (in-order, one-in-flight). */
    route(msg) {
        const prior = this.chains.get(msg.sessionKey) ?? Promise.resolve();
        const next = prior.catch(() => undefined).then(() => this.dispatchOne(msg));
        // Track the tail so the next message on this session waits for this one to
        // settle — and DELETE the entry once this tail settles while still current,
        // so the map is bounded by in-flight sessions, not sessions-ever-routed
        // (P19 cap: the map previously grew one settled-promise entry per session
        // forever).
        const tail = next.then(() => undefined, () => undefined);
        this.chains.set(msg.sessionKey, tail);
        void tail.then(() => {
            if (this.chains.get(msg.sessionKey) === tail)
                this.chains.delete(msg.sessionKey);
        });
        return next;
    }
    /**
     * The queue drain's maxAttempts escape hatch (Durable Inbound Message Queue
     * §3.3): ONE forced re-place that bypasses hold/deliver verdicts — a direct
     * placeAndClaim('failover'), serialized on the session's chain like every
     * dispatch. Returns true when the message ended up durably handled
     * (acked outcome that isn't another queued/blocked verdict).
     */
    async forceReplace(msg) {
        const prior = this.chains.get(msg.sessionKey) ?? Promise.resolve();
        const next = prior.catch(() => undefined).then(() => this.placeAndClaim(msg, 'failover', true));
        const tail = next.then(() => undefined, () => undefined);
        this.chains.set(msg.sessionKey, tail);
        void tail.then(() => {
            if (this.chains.get(msg.sessionKey) === tail)
                this.chains.delete(msg.sessionKey);
        });
        const outcome = await next;
        return outcome.acked && outcome.action !== 'queued' && outcome.action !== 'placement-blocked';
    }
    backoff(attempt) {
        return Math.min(this.cfg.deliverMessageRetryBackoffStartMs * 2 ** attempt, this.cfg.deliverMessageRetryBackoffMaxMs);
    }
    async dispatchOne(msg) {
        const own = this.deps.resolveOwnership(msg.sessionKey);
        if (own.status === 'active' && own.owner) {
            if (own.owner === this.deps.selfMachineId) {
                await this.deps.handleLocally(msg);
                return { action: 'handled-locally', owner: own.owner, acked: true };
            }
            if (this.deps.isMachineAlive(own.owner)) {
                // WS1.1 skew gate: a live owner that does NOT advertise the durable
                // receive capability must not be forwarded to (501→retry→failover
                // would STEAL from a live machine). The message waits in OUR durable
                // queue until the owner's flags flip (upgrade / queue re-enabled) or
                // ownership genuinely moves. Unknown (null) proceeds — back-compat.
                const supports = this.deps.ownerSupportsForward?.(own.owner) ?? null;
                if (supports === false) {
                    const q = this.deps.queueMessage(msg, 'owner-lacks-ws11-receive');
                    return { action: 'queued', detail: 'owner-lacks-ws11-receive', acked: q === 'queued' || q === 'already-queued' };
                }
                return this.forwardToOwner(msg, own.owner, own.epoch, 0);
            }
            // Owner is not alive → owner-dead re-placement (§L4 fallback).
            this.deps.markOwnerSuspect?.(own.owner);
            return this.placeAndClaim(msg, 'failover', true);
        }
        if (own.status === 'placing' || own.status === 'transferring') {
            const q = this.deps.queueMessage(msg, 'ownership-contention');
            return { action: 'queued', detail: own.status, acked: q === 'queued' || q === 'already-queued' };
        }
        // Unowned → place + claim.
        return this.placeAndClaim(msg, 'new', false);
    }
    async forwardToOwner(msg, owner, epoch, reResolveDepth) {
        for (let attempt = 0; attempt <= this.cfg.deliverMessageMaxRetries; attempt++) {
            try {
                const ack = await this.deps.deliverMessage(owner, { sessionKey: msg.sessionKey, messageId: msg.messageId, payload: msg.payload, ownershipEpoch: epoch, senderEnvelope: msg.senderEnvelope ?? null });
                // ANY ack (queued/duplicate/stale) proves the peer answered — close its
                // suspect window before interpreting the ack.
                this.deps.onOwnerResponsive?.(owner);
                if (ack.accepted === 'duplicate')
                    return { action: 'duplicate', owner, acked: true };
                if (ack.accepted === 'queued')
                    return { action: 'forwarded', owner, acked: true };
                // sender-rejected: typed authz NACK (§3.4 remote) — the peer answered
                // (healthy, never suspect) and durably REFUSED the sender. Terminal for
                // this message: acked so the offset advances; never retried/re-placed.
                if (ack.accepted === 'sender-rejected') {
                    return { action: 'forwarded', owner, detail: 'sender-rejected', acked: true };
                }
                // stale-ownership → re-resolve (bounded) and route to the current owner.
                if (reResolveDepth >= this.cfg.maxReResolveDepth) {
                    return this.placeAndClaim(msg, 'failover', true);
                }
                const own2 = this.deps.resolveOwnership(msg.sessionKey);
                if (own2.status === 'active' && own2.owner === this.deps.selfMachineId) {
                    await this.deps.handleLocally(msg);
                    return { action: 'handled-locally', owner: own2.owner, detail: 'after-stale', acked: true };
                }
                // Re-forward to the current owner when ownership is still active+alive AND
                // something actually changed — a DIFFERENT owner, or the SAME owner whose
                // epoch advanced (the stale-ownership ACK meant our epoch view was behind;
                // re-deliver at the corrected epoch rather than needlessly re-placing). A
                // same-owner/same-epoch stale ACK is spurious → fall through to re-place.
                // Bounded by maxReResolveDepth. (2026-05-29 pre-merge review #7.)
                if (own2.status === 'active' && own2.owner && this.deps.isMachineAlive(own2.owner) && (own2.owner !== owner || own2.epoch !== epoch)) {
                    return this.forwardToOwner(msg, own2.owner, own2.epoch, reResolveDepth + 1);
                }
                // Ownership moved to a transient/dead state (or a spurious same-epoch stale) → re-place.
                return this.placeAndClaim(msg, 'failover', true);
            }
            catch (err) {
                this.deps.log?.(`deliverMessage attempt ${attempt} to ${owner} failed: ${String(err?.message ?? err)}`);
                if (attempt < this.cfg.deliverMessageMaxRetries) {
                    await this.deps.sleep(this.backoff(attempt));
                    continue;
                }
            }
        }
        // Retries exhausted → owner is unreachable → owner-dead re-placement.
        this.deps.markOwnerSuspect?.(owner);
        return this.placeAndClaim(msg, 'failover', true);
    }
    async placeAndClaim(msg, reason, fromDead) {
        const decision = this.deps.placement.decide({
            sessionKey: msg.sessionKey,
            topicMetadata: msg.topicMetadata ?? {},
            machineRegistry: this.deps.machineRegistry(),
            currentOwner: undefined,
            reason,
            // Thread the channel scope so a Slack channel is never placed on a machine not connected
            // to its workspace (the live-test bug — a FAILOVER placement; both new+failover route through
            // here). Absent → unknown/fail-open (telegram shared, or legacy inbound).
            ...(msg.channel ? { channel: msg.channel } : {}),
        });
        if (decision.outcome === 'placement-blocked') {
            this.deps.raiseAttention('Session placement blocked', `${msg.sessionKey}: ${decision.escalationReason ?? decision.reason}`);
            const q = this.deps.queueMessage(msg, `placement-blocked:${decision.escalationReason ?? decision.reason}`);
            return { action: 'placement-blocked', detail: decision.escalationReason ?? decision.reason, acked: q === 'queued' || q === 'already-queued' };
        }
        if (decision.outcome === 'queued' || !decision.chosenMachine) {
            this.deps.raiseAttention('No machine available for session', `${msg.sessionKey}: ${decision.escalationReason ?? decision.reason}`);
            const q = this.deps.queueMessage(msg, decision.escalationReason ?? 'no-capable-machine');
            return { action: 'queued', detail: decision.escalationReason ?? decision.reason, acked: q === 'queued' || q === 'already-queued' };
        }
        // Synchronous CAS-claim (the dispatch BLOCKS on this — Invariant #2).
        const pre = this.deps.resolveOwnership(msg.sessionKey);
        const cas = this.deps.casClaimOwnership(msg.sessionKey, decision.chosenMachine, pre.epoch);
        if (!cas.ok) {
            // Contention — re-read; route to the confirmed winner or queue if transient.
            const own2 = this.deps.resolveOwnership(msg.sessionKey);
            if (own2.status === 'active' && own2.owner === this.deps.selfMachineId) {
                await this.deps.handleLocally(msg);
                return { action: 'handled-locally', owner: own2.owner, detail: 'cas-lost-self', acked: true };
            }
            if (own2.status === 'active' && own2.owner && this.deps.isMachineAlive(own2.owner)) {
                return this.forwardToOwner(msg, own2.owner, own2.epoch, 0);
            }
            const q = this.deps.queueMessage(msg, 'ownership-contention');
            return { action: 'queued', detail: 'ownership-contention', acked: q === 'queued' || q === 'already-queued' };
        }
        // CAS won → spawn on the winner (or handle locally if that's us).
        const action = fromDead ? 'owner-dead-replaced' : 'spawned';
        if (decision.chosenMachine === this.deps.selfMachineId) {
            await this.deps.handleLocally(msg);
            return { action: fromDead ? 'owner-dead-replaced' : 'handled-locally', owner: decision.chosenMachine, detail: 'placed-self', acked: true };
        }
        await this.deps.spawnOnMachine(decision.chosenMachine, msg);
        // Confirm the remote owner (placing → active). The 'place' above left the record
        // transient; without this the session is owned-but-never-active and every later
        // message routes to the placing/transferring branch → queued forever (bug #11).
        this.deps.confirmClaim?.(msg.sessionKey, decision.chosenMachine);
        return { action, owner: decision.chosenMachine, acked: true };
    }
}
//# sourceMappingURL=SessionRouter.js.map