// Wraps the order submit pipeline (`sync/submit.ts`) with the Phase
// 25 transport semantics:
//
//   - **offline detection** via `navigator.onLine` and the browser
//     `online` / `offline` events. While offline, `send()` returns an
//     `offline` outcome immediately and the caller is expected to
//     leave the in-flight commands in their pre-submit state.
//   - **single retry on reconnect** is realised at the consumer
//     level: when the browser fires `online`, the queue invokes the
//     `onOnline` callback the consumer supplied at `start()`. The
//     consumer (`OrderDraftStore`) decides whether to schedule a
//     fresh `runSync()` — that single attempt is the retry budget.
//   - **conflict / paused classification**: a non-`ok` SubmitResult
//     whose `resultCode` or `code` is `turn_already_closed` becomes
//     a `conflict` outcome; `game_paused` becomes a `paused`
//     outcome. Any other non-`ok` reply stays a `rejected` outcome
//     and the consumer keeps the existing per-command behaviour.
//
// The class is dependency-injected so Vitest can drive the
// `online` / `offline` listeners without touching the JSDOM
// globals; production code falls back to `window`/`navigator`.

import type { SubmitFailure, SubmitResult, SubmitSuccess } from "./submit";

/**
 * QueueOutcome is the discriminated union the draft store consumes
 * after asking the queue to submit a snapshot. Each variant tells
 * the consumer exactly which side-effect to apply to the
 * per-command statuses and the banner state.
 */
export type QueueOutcome =
	| { kind: "success"; result: SubmitSuccess }
	| { kind: "rejected"; failure: SubmitFailure }
	| { kind: "conflict"; code: string; message: string }
	| { kind: "paused"; code: string; message: string }
	| { kind: "offline" }
	| { kind: "failed"; reason: string };

/**
 * OrderQueueStartOptions carries the live primitives the queue
 * cannot resolve on its own. Tests inject deterministic stubs;
 * production passes `undefined` for everything except `onOnline`.
 */
export interface OrderQueueStartOptions {
	/**
	 * onOnline is invoked when the browser flips from offline to
	 * online (or when `start()` is called while already online and
	 * the consumer wants an opportunistic flush). The consumer
	 * decides whether a fresh `send()` is appropriate.
	 */
	onOnline: () => void;

	/**
	 * onlineProbe returns the current online state. Defaults to
	 * `navigator.onLine`; tests inject a closure over a mutable flag.
	 */
	onlineProbe?: () => boolean;

	/**
	 * addEventListener / removeEventListener are the hooks the queue
	 * uses to subscribe to the global `online` / `offline` events.
	 * Defaults to `window.addEventListener` / `window.removeEventListener`;
	 * tests inject manual emitters.
	 */
	addEventListener?: (event: string, handler: () => void) => void;
	removeEventListener?: (event: string, handler: () => void) => void;
}

const CODE_TURN_ALREADY_CLOSED = "turn_already_closed";
const CODE_GAME_PAUSED = "game_paused";

/**
 * OrderQueue holds the transport-side policy for the order draft
 * store. One instance per draft store; lifecycle is bound to the
 * store's `init` / `dispose`.
 */
export class OrderQueue {
	/**
	 * online mirrors the latest browser online signal. Tests assert
	 * on this rune to drive their state machine; production code
	 * uses it via the draft store's `syncStatus` projection.
	 */
	online: boolean = $state(true);

	private onlineProbe: () => boolean = defaultOnlineProbe;
	private addEventListener: (event: string, handler: () => void) => void = defaultAddEventListener;
	private removeEventListener: (event: string, handler: () => void) => void = defaultRemoveEventListener;
	private onOnlineCallback: (() => void) | null = null;
	private handleOnline: (() => void) | null = null;
	private handleOffline: (() => void) | null = null;

	/**
	 * start subscribes to the browser online/offline events and
	 * primes `online` from the current probe value. Calling start a
	 * second time without `stop()` between them is a no-op so the
	 * draft store's `init` stays idempotent under double mount.
	 */
	start(opts: OrderQueueStartOptions): void {
		if (this.onOnlineCallback !== null) return;
		this.onOnlineCallback = opts.onOnline;
		if (opts.onlineProbe !== undefined) {
			this.onlineProbe = opts.onlineProbe;
		}
		if (opts.addEventListener !== undefined) {
			this.addEventListener = opts.addEventListener;
		}
		if (opts.removeEventListener !== undefined) {
			this.removeEventListener = opts.removeEventListener;
		}
		this.online = this.onlineProbe();
		this.handleOnline = () => {
			this.online = true;
			this.onOnlineCallback?.();
		};
		this.handleOffline = () => {
			this.online = false;
		};
		this.addEventListener("online", this.handleOnline);
		this.addEventListener("offline", this.handleOffline);
	}

	/**
	 * stop unsubscribes from the browser events and forgets the
	 * consumer callback. Subsequent `send()` calls still classify
	 * an injected `SubmitResult` correctly, but no online flips will
	 * be propagated until `start()` runs again.
	 */
	stop(): void {
		if (this.handleOnline !== null) {
			this.removeEventListener("online", this.handleOnline);
			this.handleOnline = null;
		}
		if (this.handleOffline !== null) {
			this.removeEventListener("offline", this.handleOffline);
			this.handleOffline = null;
		}
		this.onOnlineCallback = null;
	}

	/**
	 * send drives one submit attempt:
	 *
	 *   - If the queue is currently offline, returns `{kind:"offline"}`
	 *     without invoking submitFn. The consumer is expected to
	 *     leave the in-flight commands in their pre-submit state and
	 *     wait for the `onOnline` callback.
	 *   - Otherwise invokes submitFn. Any throw is reclassified:
	 *     a fresh `onlineProbe()` returning false collapses into
	 *     `offline`; otherwise the throw becomes `failed`.
	 *   - A successful `SubmitResult` is classified into `success`,
	 *     `rejected`, `conflict`, or `paused` depending on the
	 *     non-`ok` `resultCode` / `code` fields.
	 *
	 * The queue intentionally does NOT retry inline. The plan's
	 * "retry once on reconnect" budget is realised by the consumer
	 * (the draft store) hooking the `onOnline` callback to
	 * `scheduleSync()` — at most one fresh `send()` per online flip.
	 */
	async send(submitFn: () => Promise<SubmitResult>): Promise<QueueOutcome> {
		if (!this.online) {
			return { kind: "offline" };
		}
		let result: SubmitResult;
		try {
			result = await submitFn();
		} catch (err) {
			if (!this.onlineProbe()) {
				this.online = false;
				return { kind: "offline" };
			}
			return { kind: "failed", reason: errorMessage(err) };
		}
		return classifyResult(result);
	}
}

/**
 * classifyResult maps a `SubmitResult` onto the queue outcome the
 * draft store consumes. Exported for unit-tests; the inline path
 * uses it through `OrderQueue.send`.
 */
export function classifyResult(result: SubmitResult): QueueOutcome {
	if (result.ok) {
		return { kind: "success", result };
	}
	const code = pickCode(result);
	if (code === CODE_TURN_ALREADY_CLOSED) {
		return { kind: "conflict", code, message: result.message };
	}
	if (code === CODE_GAME_PAUSED) {
		return { kind: "paused", code, message: result.message };
	}
	return { kind: "rejected", failure: result };
}

function pickCode(failure: SubmitFailure): string {
	// The gateway sets `resultCode = backendError.Code` for non-ok
	// replies (see `gateway/internal/backendclient/user_commands.go`
	// `projectUserBackendError`). The FBS-encoded payload body is
	// parsed by `submit.ts.decodeError`, which falls back to the
	// `resultCode` when the body cannot be decoded; we therefore
	// prefer `code` only when it differs from the result code, but
	// either field carries the same authoritative value.
	if (failure.code && failure.code !== failure.resultCode) {
		return failure.code;
	}
	return failure.resultCode;
}

function errorMessage(err: unknown): string {
	if (err instanceof Error) return err.message;
	if (typeof err === "string") return err;
	return "submit failed";
}

function defaultOnlineProbe(): boolean {
	if (typeof navigator === "undefined") {
		return true;
	}
	return navigator.onLine !== false;
}

function defaultAddEventListener(event: string, handler: () => void): void {
	if (typeof window === "undefined") return;
	window.addEventListener(event, handler);
}

function defaultRemoveEventListener(event: string, handler: () => void): void {
	if (typeof window === "undefined") return;
	window.removeEventListener(event, handler);
}
