// An named symbol/brand for detecting Signal instances even when they weren't
// created using the same signals library version.
const BRAND_SYMBOL = Symbol.for("preact-signals");

// Flags for Computed and Effect.
const RUNNING = 1 << 0;
const NOTIFIED = 1 << 1;
const OUTDATED = 1 << 2;
const DISPOSED = 1 << 3;
const HAS_ERROR = 1 << 4;
const TRACKING = 1 << 5;

// A linked list node used to track dependencies (sources) and dependents (targets).
// Also used to remember the source's last version number that the target saw.
type Node = {
	// A source whose value the target depends on.
	_source: Signal;
	_prevSource?: Node;
	_nextSource?: Node;

	// A target that depends on the source and should be notified when the source changes.
	_target: Computed | Effect;
	_prevTarget?: Node;
	_nextTarget?: Node;

	// The version number of the source that target has last seen. We use version numbers
	// instead of storing the source value, because source values can take arbitrary amount
	// of memory, and computeds could hang on to them forever because they're lazily evaluated.
	// Use the special value -1 to mark potentially unused but recyclable nodes.
	_version: number;

	// Used to remember & roll back the source's previous `._node` value when entering &
	// exiting a new evaluation context.
	_rollbackNode?: Node;
};

function startBatch() {
	batchDepth++;
}

function endBatch() {
	if (batchDepth > 1) {
		batchDepth--;
		return;
	}

	let error: unknown;
	let hasError = false;
	reconcileBatchSnapshots();

	while (batchedEffect !== undefined) {
		let effect: Effect | undefined = batchedEffect;
		batchedEffect = undefined;

		batchIteration++;

		while (effect !== undefined) {
			const next: Effect | undefined = effect._nextBatchedEffect;
			effect._nextBatchedEffect = undefined;
			effect._flags &= ~NOTIFIED;

			if (!(effect._flags & DISPOSED) && needsToRecompute(effect)) {
				try {
					effect._callback();
				} catch (err) {
					if (!hasError) {
						error = err;
						hasError = true;
					}
				}
			}
			effect = next;
		}
	}
	batchIteration = 0;
	batchDepth--;

	if (hasError) {
		throw error;
	}
}

/**
 * Combine multiple value updates into one "commit" at the end of the provided callback.
 *
 * Batches can be nested and changes are only flushed once the outermost batch callback
 * completes.
 *
 * Accessing a signal that has been modified within a batch will reflect its updated
 * value.
 *
 * @param fn The callback function.
 * @returns The value returned by the callback.
 */
function batch<T>(fn: () => T): T {
	if (batchDepth > 0) {
		return fn();
	}
	currentBatchSnapshotVersion = ++batchSnapshotVersion;
	/*@__INLINE__**/ startBatch();
	try {
		return fn();
	} finally {
		endBatch();
	}
}

// Currently evaluated computed or effect.
let evalContext: Computed | Effect | undefined = undefined;

// Effects captured while constructing a model instance.
let capturedEffects: Effect[] | undefined;

/**
 * Run a callback function that can access signal values without
 * subscribing to the signal updates.
 *
 * When called inside a `createModel` factory, this also suppresses
 * model-owned effect capture. Effects created inside the callback will not
 * be owned by the surrounding model and must be disposed manually. Nested
 * `createModel` calls inside the callback still capture their own effects.
 *
 * @param fn The callback function.
 * @returns The value returned by the callback.
 */
function untracked<T>(fn: () => T): T {
	const prevContext = evalContext;
	const prevCapturedEffects = capturedEffects;

	evalContext = undefined;
	// Model effect capture is another kind of ambient tracking. Suppress it in
	// untracked callbacks while still allowing nested createModel() calls to
	// establish their own capture scope.
	capturedEffects = undefined;
	try {
		return fn();
	} finally {
		evalContext = prevContext;
		capturedEffects = prevCapturedEffects;
	}
}

// Effects collected into a batch.
let batchedEffect: Effect | undefined = undefined;
let batchDepth = 0;
let batchIteration = 0;

type BatchSnapshot = {
	_source: Signal;
	_value: unknown;
	_version: number;
	_next?: BatchSnapshot;
};

let batchSnapshotVersion = 0;
let currentBatchSnapshotVersion = 0;
let batchSnapshots: BatchSnapshot | undefined = undefined;

// A global version number for signals, used for fast-pathing repeated
// computed.peek()/computed.value calls when nothing has changed globally.
let globalVersion = 0;

function recordBatchSnapshot(source: Signal) {
	// Only capture writes during the user-visible batch callback, not during effect flush.
	if (batchDepth === 0 || batchIteration !== 0) {
		return;
	}

	if (source._batchSnapshotVersion !== currentBatchSnapshotVersion) {
		source._batchSnapshotVersion = currentBatchSnapshotVersion;
		batchSnapshots = {
			_source: source,
			_value: source._value,
			_version: source._version,
			_next: batchSnapshots,
		};
	}
}

function reconcileBatchSnapshots() {
	let snapshots = batchSnapshots;
	batchSnapshots = undefined;

	while (snapshots !== undefined) {
		const source = snapshots._source;
		if (source._value === snapshots._value) {
			// The value was reverted to its pre-batch state. Version numbers must
			// stay monotonic: a lazy computed may have observed an intermediate
			// version during the batch, and rolling the version back would let a
			// future write re-mint that observed number for a different value,
			// making the computed treat it as unchanged forever. Instead,
			// fast-forward subscribers that last saw the pre-batch version so
			// they skip recomputing for the no-op change.
			for (
				let node = source._targets;
				node !== undefined;
				node = node._nextTarget
			) {
				if (node._version === snapshots._version) {
					node._version = source._version;
				}
			}
		}
		snapshots = snapshots._next;
	}
}

function addDependency(signal: Signal): Node | undefined {
	if (evalContext === undefined) {
		return undefined;
	}

	let node = signal._node;
	if (node === undefined || node._target !== evalContext) {
		/**
		 * `signal` is a new dependency. Create a new dependency node, and set it
		 * as the tail of the current context's dependency list. e.g:
		 *
		 * { A <-> B       }
		 *         ↑     ↑
		 *        tail  node (new)
		 *               ↓
		 * { A <-> B <-> C }
		 *               ↑
		 *              tail (evalContext._sources)
		 */
		node = {
			_version: 0,
			_source: signal,
			_prevSource: evalContext._sources,
			_nextSource: undefined,
			_target: evalContext,
			_prevTarget: undefined,
			_nextTarget: undefined,
			_rollbackNode: node,
		};

		if (evalContext._sources !== undefined) {
			evalContext._sources._nextSource = node;
		}
		evalContext._sources = node;
		signal._node = node;

		// Subscribe to change notifications from this dependency if we're in an effect
		// OR evaluating a computed signal that in turn has subscribers.
		if (evalContext._flags & TRACKING) {
			signal._subscribe(node);
		}
		return node;
	} else if (node._version === -1) {
		// `signal` is an existing dependency from a previous evaluation. Reuse it.
		node._version = 0;

		/**
		 * If `node` is not already the current tail of the dependency list (i.e.
		 * there is a next node in the list), then make the `node` the new tail. e.g:
		 *
		 * { A <-> B <-> C <-> D }
		 *         ↑           ↑
		 *        node   ┌─── tail (evalContext._sources)
		 *         └─────│─────┐
		 *               ↓     ↓
		 * { A <-> C <-> D <-> B }
		 *                     ↑
		 *                    tail (evalContext._sources)
		 */
		if (node._nextSource !== undefined) {
			node._nextSource._prevSource = node._prevSource;

			if (node._prevSource !== undefined) {
				node._prevSource._nextSource = node._nextSource;
			}

			node._prevSource = evalContext._sources;
			node._nextSource = undefined;

			evalContext._sources!._nextSource = node;
			evalContext._sources = node;
		}

		// We can assume that the currently evaluated effect / computed signal is already
		// subscribed to change notifications from `signal` if needed.
		return node;
	}
	return undefined;
}

//#region Signal

/**
 * The base class for plain and computed signals.
 */
//
// A function with the same name is defined later, so we need to ignore TypeScript's
// warning about a redeclared variable.
//
// The class is declared here, but later implemented with ES5-style prototypes.
// This enables better control of the transpiled output size.
// @ts-ignore: "Cannot redeclare exported variable 'Signal'."
declare class Signal<T = any> {
	/** @internal */
	_value: unknown;

	/**
	 * @internal
	 * Version numbers should always be >= 0, because the special value -1 is used
	 * by Nodes to signify potentially unused but recyclable nodes.
	 */
	_version: number;

	/** @internal */
	_node?: Node;

	/** @internal */
	_targets?: Node;

	/** @internal */
	_batchSnapshotVersion: number;

	constructor(value?: T, options?: SignalOptions<T>);

	/** @internal */
	_refresh(): boolean;

	/** @internal */
	_subscribe(node: Node): void;

	/** @internal */
	_unsubscribe(node: Node): void;

	/** @internal */
	_watched?(this: Signal<T>): void;

	/** @internal */
	_unwatched?(this: Signal<T>): void;

	subscribe(fn: (value: T) => void): () => void;

	name?: string;

	valueOf(): T;

	toString(): string;

	toJSON(): T;

	peek(): T;

	brand: typeof BRAND_SYMBOL;

	get value(): T;
	set value(value: T);
}

export interface SignalOptions<T = any> {
	watched?: (this: Signal<T>) => void;
	unwatched?: (this: Signal<T>) => void;
	name?: string;
}

/** @internal */
// A class with the same name has already been declared, so we need to ignore
// TypeScript's warning about a redeclared variable.
//
// The previously declared class is implemented here with ES5-style prototypes.
// This enables better control of the transpiled output size.
// @ts-ignore: "Cannot redeclare exported variable 'Signal'."
function Signal(this: Signal, value?: unknown, options?: SignalOptions) {
	this._value = value;
	this._version = 0;
	this._node = undefined;
	this._targets = undefined;
	this._batchSnapshotVersion = 0;
	this._watched = options?.watched;
	this._unwatched = options?.unwatched;
	this.name = options?.name;
}

Signal.prototype.brand = BRAND_SYMBOL;

Signal.prototype._refresh = function () {
	return true;
};

Signal.prototype._subscribe = function (node) {
	const targets = this._targets;
	if (targets !== node && node._prevTarget === undefined) {
		node._nextTarget = targets;
		this._targets = node;

		if (targets !== undefined) {
			targets._prevTarget = node;
		} else {
			untracked(() => {
				this._watched?.call(this);
			});
		}
	}
};

Signal.prototype._unsubscribe = function (node) {
	// Only run the unsubscribe step if the signal has any subscribers to begin with.
	if (this._targets !== undefined) {
		const prev = node._prevTarget;
		const next = node._nextTarget;
		if (prev !== undefined) {
			prev._nextTarget = next;
			node._prevTarget = undefined;
		}

		if (next !== undefined) {
			next._prevTarget = prev;
			node._nextTarget = undefined;
		}

		if (node === this._targets) {
			this._targets = next;
			if (next === undefined) {
				untracked(() => {
					this._unwatched?.call(this);
				});
			}
		}
	}
};

Signal.prototype.subscribe = function (fn) {
	return effect(
		() => {
			const value = this.value;
			untracked(() => fn(value));
		},
		{ name: "sub" }
	);
};

Signal.prototype.valueOf = function () {
	return this.value;
};

Signal.prototype.toString = function () {
	return this.value + "";
};

Signal.prototype.toJSON = function () {
	return this.value;
};

Signal.prototype.peek = function () {
	return untracked(() => this.value);
};

Object.defineProperty(Signal.prototype, "value", {
	get(this: Signal) {
		const node = addDependency(this);
		if (node !== undefined) {
			node._version = this._version;
		}
		return this._value;
	},
	set(this: Signal, value) {
		if (value !== this._value) {
			if (batchIteration > 100) {
				throw new Error("Cycle detected");
			}

			recordBatchSnapshot(this);
			this._value = value;
			this._version++;
			globalVersion++;

			/**@__INLINE__*/ startBatch();
			try {
				for (
					let node = this._targets;
					node !== undefined;
					node = node._nextTarget
				) {
					node._target._notify();
				}
			} finally {
				endBatch();
			}
		}
	},
});

/**
 * Create a new plain signal.
 *
 * @param value The initial value for the signal.
 * @returns A new signal.
 */
export function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
export function signal<T = undefined>(): Signal<T | undefined>;
export function signal<T>(value?: T, options?: SignalOptions<T>): Signal<T> {
	return new Signal(value, options);
}

//#endregion Signal

//#region Computed

function needsToRecompute(target: Computed | Effect): boolean {
	// Check the dependencies for changed values. The dependency list is already
	// in order of use. Therefore if multiple dependencies have changed values, only
	// the first used dependency is re-evaluated at this point.
	for (
		let node = target._sources;
		node !== undefined;
		node = node._nextSource
	) {
		if (
			// If the dependency has definitely been updated since its version number
			// was observed, then we need to recompute. This first check is not strictly
			// necessary for correctness, but allows us to skip the refresh call if the
			// dependency has already been updated.
			node._source._version !== node._version ||
			// Refresh the dependency. If there's something blocking the refresh (e.g. a
			// dependency cycle), then we need to recompute.
			!node._source._refresh() ||
			// If the dependency got a new version after the refresh, then we need to recompute.
			node._source._version !== node._version
		) {
			return true;
		}
	}
	// If none of the dependencies have changed values since last recompute then
	// there's no need to recompute.
	return false;
}

function prepareSources(target: Computed | Effect) {
	/**
	 * 1. Mark all current sources as re-usable nodes (version: -1)
	 * 2. Set a rollback node if the current node is being used in a different context
	 * 3. Point 'target._sources' to the tail of the doubly-linked list, e.g:
	 *
	 *    { undefined <- A <-> B <-> C -> undefined }
	 *                   ↑           ↑
	 *                   │           └──────┐
	 * target._sources = A; (node is head)  │
	 *                   ↓                  │
	 * target._sources = C; (node is tail) ─┘
	 */
	for (
		let node = target._sources;
		node !== undefined;
		node = node._nextSource
	) {
		const rollbackNode = node._source._node;
		if (rollbackNode !== undefined) {
			node._rollbackNode = rollbackNode;
		}
		node._source._node = node;
		node._version = -1;

		if (node._nextSource === undefined) {
			target._sources = node;
			break;
		}
	}
}

function cleanupSources(target: Computed | Effect) {
	let node = target._sources;
	let head: Node | undefined = undefined;

	/**
	 * At this point 'target._sources' points to the tail of the doubly-linked list.
	 * It contains all existing sources + new sources in order of use.
	 * Iterate backwards until we find the head node while dropping old dependencies.
	 */
	while (node !== undefined) {
		const prev = node._prevSource;

		/**
		 * The node was not re-used, unsubscribe from its change notifications and remove itself
		 * from the doubly-linked list. e.g:
		 *
		 * { A <-> B <-> C }
		 *         ↓
		 *    { A <-> C }
		 */
		if (node._version === -1) {
			node._source._unsubscribe(node);

			if (prev !== undefined) {
				prev._nextSource = node._nextSource;
			}
			if (node._nextSource !== undefined) {
				node._nextSource._prevSource = prev;
			}
		} else {
			/**
			 * The new head is the last node seen which wasn't removed/unsubscribed
			 * from the doubly-linked list. e.g:
			 *
			 * { A <-> B <-> C }
			 *   ↑     ↑     ↑
			 *   │     │     └ head = node
			 *   │     └ head = node
			 *   └ head = node
			 */
			head = node;
		}

		node._source._node = node._rollbackNode;
		if (node._rollbackNode !== undefined) {
			node._rollbackNode = undefined;
		}

		node = prev;
	}

	target._sources = head;
}

/**
 * The base class for computed signals.
 */
declare class Computed<T = any> extends Signal<T> {
	_fn: () => T;
	_sources?: Node;
	_globalVersion: number;
	_flags: number;

	constructor(fn: () => T, options?: SignalOptions<T>);

	_notify(): void;
	get value(): T;
}

/** @internal */
function Computed(this: Computed, fn: () => unknown, options?: SignalOptions) {
	Signal.call(this, undefined, options);

	this._fn = fn;
	this._sources = undefined;
	this._globalVersion = globalVersion - 1;
	this._flags = OUTDATED;
}

Computed.prototype = new Signal() as Computed;

Computed.prototype._refresh = function () {
	this._flags &= ~NOTIFIED;

	if (this._flags & RUNNING) {
		return false;
	}

	// If this computed signal has subscribed to updates from its dependencies
	// (TRACKING flag set) and none of them have notified about changes (OUTDATED
	// flag not set), then the computed value can't have changed.
	if ((this._flags & (OUTDATED | TRACKING)) === TRACKING) {
		return true;
	}
	this._flags &= ~OUTDATED;

	if (this._globalVersion === globalVersion) {
		return true;
	}
	this._globalVersion = globalVersion;

	// Mark this computed signal running before checking the dependencies for value
	// changes, so that the RUNNING flag can be used to notice cyclical dependencies.
	this._flags |= RUNNING;
	if (this._version > 0 && !needsToRecompute(this)) {
		this._flags &= ~RUNNING;
		return true;
	}

	const prevContext = evalContext;
	try {
		prepareSources(this);
		evalContext = this;
		const value = this._fn();
		if (
			this._flags & HAS_ERROR ||
			this._value !== value ||
			this._version === 0
		) {
			this._value = value;
			this._flags &= ~HAS_ERROR;
			this._version++;
		}
	} catch (err) {
		this._value = err;
		this._flags |= HAS_ERROR;
		this._version++;
	}
	evalContext = prevContext;
	cleanupSources(this);
	this._flags &= ~RUNNING;
	return true;
};

Computed.prototype._subscribe = function (node) {
	if (this._targets === undefined) {
		this._flags |= OUTDATED | TRACKING;

		// A computed signal subscribes lazily to its dependencies when it
		// gets its first subscriber.
		for (
			let node = this._sources;
			node !== undefined;
			node = node._nextSource
		) {
			node._source._subscribe(node);
		}
	}
	Signal.prototype._subscribe.call(this, node);
};

Computed.prototype._unsubscribe = function (node) {
	// Only run the unsubscribe step if the computed signal has any subscribers.
	if (this._targets !== undefined) {
		Signal.prototype._unsubscribe.call(this, node);

		// Computed signal unsubscribes from its dependencies when it loses its last subscriber.
		// This makes it possible for unreferences subgraphs of computed signals to get garbage collected.
		if (this._targets === undefined) {
			this._flags &= ~TRACKING;

			for (
				let node = this._sources;
				node !== undefined;
				node = node._nextSource
			) {
				node._source._unsubscribe(node);
			}
		}
	}
};

Computed.prototype._notify = function () {
	if (!(this._flags & NOTIFIED)) {
		this._flags |= OUTDATED | NOTIFIED;

		for (
			let node = this._targets;
			node !== undefined;
			node = node._nextTarget
		) {
			node._target._notify();
		}
	}
};

Object.defineProperty(Computed.prototype, "value", {
	get(this: Computed) {
		if (this._flags & RUNNING) {
			throw new Error("Cycle detected");
		}
		const node = addDependency(this);
		this._refresh();
		if (node !== undefined) {
			node._version = this._version;
		}
		if (this._flags & HAS_ERROR) {
			throw this._value;
		}
		return this._value;
	},
});

/**
 * An interface for read-only signals.
 */
interface ReadonlySignal<T = any> {
	readonly value: T;
	peek(): T;

	subscribe(fn: (value: T) => void): () => void;
	valueOf(): T;
	toString(): string;
	toJSON(): T;
	brand: typeof BRAND_SYMBOL;
}

/**
 * Create a new signal that is computed based on the values of other signals.
 *
 * The returned computed signal is read-only, and its value is automatically
 * updated when any signals accessed from within the callback function change.
 *
 * @param fn The effect callback.
 * @returns A new read-only signal.
 */
function computed<T>(
	fn: () => T,
	options?: SignalOptions<T>
): ReadonlySignal<T> {
	return new Computed(fn, options);
}

//#endregion Computed

//#region Effect

function cleanupEffect(effect: Effect) {
	const cleanup = effect._cleanup;
	effect._cleanup = undefined;

	if (typeof cleanup === "function") {
		/*@__INLINE__**/ startBatch();

		// Run cleanup functions always outside of any context.
		const prevContext = evalContext;
		evalContext = undefined;
		try {
			cleanup();
		} catch (err) {
			effect._flags &= ~RUNNING;
			effect._flags |= DISPOSED;
			disposeEffect(effect);
			throw err;
		} finally {
			evalContext = prevContext;
			endBatch();
		}
	}
}

function disposeEffect(effect: Effect) {
	for (
		let node = effect._sources;
		node !== undefined;
		node = node._nextSource
	) {
		node._source._unsubscribe(node);
	}
	effect._fn = undefined;
	effect._sources = undefined;

	cleanupEffect(effect);
}

function endEffect(this: Effect, prevContext?: Computed | Effect) {
	if (evalContext !== this) {
		throw new Error("Out-of-order effect");
	}
	cleanupSources(this);
	evalContext = prevContext;

	this._flags &= ~RUNNING;
	if (this._flags & DISPOSED) {
		disposeEffect(this);
	}
	endBatch();
}

type EffectFn =
	| ((this: { dispose: () => void }) => void | (() => void))
	| (() => void | (() => void));

// Avoid hard-requiring the ESNext.Disposable lib in consuming tsconfigs.
// When `Symbol.dispose` is available, this becomes a symbol-keyed disposer type.
type DisposeSymbol = typeof Symbol extends { readonly dispose: infer TDispose }
	? TDispose
	: never;
type DisposableLike = {
	[K in DisposeSymbol & PropertyKey]: () => void;
};
type DisposeFn = (() => void) & DisposableLike;

/**
 * The base class for reactive effects.
 */
declare class Effect {
	_fn?: EffectFn;
	_cleanup?: () => void;
	_sources?: Node;
	_nextBatchedEffect?: Effect;
	_flags: number;
	_debugCallback?: () => void;
	name?: string;

	constructor(fn: EffectFn, options?: EffectOptions);

	_callback(): void;
	_start(): () => void;
	_notify(): void;
	_dispose(): void;
	dispose(): void;
}

export interface EffectOptions {
	name?: string;
}

/** @internal */
function Effect(this: Effect, fn: EffectFn, options?: EffectOptions) {
	this._fn = fn;
	this._cleanup = undefined;
	this._sources = undefined;
	this._nextBatchedEffect = undefined;
	this._flags = TRACKING;
	this.name = options?.name;

	if (capturedEffects) {
		capturedEffects.push(this);
	}
}

Effect.prototype._callback = function () {
	const finish = this._start();
	try {
		if (this._flags & DISPOSED) return;
		if (this._fn === undefined) return;

		const cleanup = this._fn();
		if (typeof cleanup === "function") {
			this._cleanup = cleanup;
		}
	} finally {
		finish();
	}
};

Effect.prototype._start = function () {
	if (this._flags & RUNNING) {
		throw new Error("Cycle detected");
	}
	this._flags |= RUNNING;
	this._flags &= ~DISPOSED;
	cleanupEffect(this);
	prepareSources(this);

	/*@__INLINE__**/ startBatch();
	const prevContext = evalContext;
	evalContext = this;
	return endEffect.bind(this, prevContext);
};

Effect.prototype._notify = function () {
	if (!(this._flags & NOTIFIED)) {
		this._flags |= NOTIFIED;
		this._nextBatchedEffect = batchedEffect;
		batchedEffect = this;
	}
};

Effect.prototype._dispose = function () {
	this._flags |= DISPOSED;

	if (!(this._flags & RUNNING)) {
		disposeEffect(this);
	}
};

Effect.prototype.dispose = function () {
	this._dispose();
};
/**
 * Create an effect to run arbitrary code in response to signal changes.
 *
 * An effect tracks which signals are accessed within the given callback
 * function `fn`, and re-runs the callback when those signals change.
 *
 * The callback may return a cleanup function. The cleanup function gets
 * run once, either when the callback is next called or when the effect
 * gets disposed, whichever happens first.
 *
 * @param fn The effect callback.
 * @returns A function for disposing the effect.
 */
function effect(fn: EffectFn, options?: EffectOptions): DisposeFn {
	const effect = new Effect(fn, options);
	try {
		effect._callback();
	} catch (err) {
		effect._dispose();
		throw err;
	}
	// Return a bound function instead of a wrapper like `() => effect._dispose()`,
	// because bound functions seem to be just as fast and take up a lot less memory.
	const dispose = effect._dispose.bind(effect);
	(dispose as any)[Symbol.dispose] = dispose;
	return dispose as DisposeFn;
}

//#endregion Effect

//#region Action

function action<TArgs extends unknown[], TReturn>(
	fn: (...args: TArgs) => TReturn
): (...args: TArgs) => TReturn {
	return function actionWrapper(this: unknown, ...args: TArgs) {
		return batch(() => untracked(() => fn.apply(this, args)));
	};
}

//#endregion Action

//#region createModel

/** Models should only contain signals, actions, and nested objects containing only signals and actions. */
type ValidateModel<TModel> = {
	[Key in keyof TModel]: TModel[Key] extends ReadonlySignal<unknown>
		? TModel[Key]
		: TModel[Key] extends (...args: any[]) => any
			? TModel[Key]
			: TModel[Key] extends object
				? ValidateModel<TModel[Key]>
				: `Property ${Key extends string ? `'${Key}' ` : ""}is not a Signal, Action, or an object that contains only Signals and Actions.`;
};

export type Model<TModel> = ValidateModel<TModel> & DisposableLike;

export type ModelFactory<TModel, TFactoryArgs extends any[] = []> = (
	...args: TFactoryArgs
) => ValidateModel<TModel>;
export type ModelConstructor<TModel, TFactoryArgs extends any[] = []> = new (
	...args: TFactoryArgs
) => Model<TModel>;

/**
 * The public types for ModelConstructor require using `new` to help
 * disambiguate the function passed into `createModel` and the returned
 * constructor function. It is easier to say that `createModel` accepts
 * a factory and returns a class, then to say it accepts a factory and
 * returns a factory. In other words, this example:
 *
 * ```ts
 * const PersonModel = createModel((name: string) => ({ ... }));
 * const person = new PersonModel("John");
 * ```
 *
 * is easier to understand than this example:
 *
 * ```ts
 * const createPerson = createModel((name: string) => ({ ... }));
 * const person = createPerson("John");
 * ```
 *
 * However, internally we implement `createModel` to return a function
 * that can be called without `new` for simplicity. To bridge the gap
 * between the public types and the internal implementation, we define
 * this internal interface that extends the public interface but also
 * allows calling without `new`.
 *
 * This pattern is used by the Preact & React adapters to make instantiating
 * a model or a function that returns a model easier.
 *
 * @internal
 */
interface InternalModelConstructor<
	TModel,
	TFactoryArgs extends any[],
> extends ModelConstructor<TModel, TFactoryArgs> {
	(...args: TFactoryArgs): Model<TModel>;
}

function startCapturingEffects(): () => Effect[] | undefined {
	let prevCapturedEffects = capturedEffects;
	// Always establish a fresh capture scope, even when `untracked()` has
	// temporarily cleared the parent scope. This lets nested models own their
	// effects without promoting them to a suppressed outer scope.
	capturedEffects = [];

	return function stopCapturingEffects() {
		let modelEffects = capturedEffects;
		if (capturedEffects && prevCapturedEffects) {
			prevCapturedEffects = prevCapturedEffects.concat(capturedEffects);
		}

		capturedEffects = prevCapturedEffects;

		return modelEffects;
	};
}

const wrapInAction = (value: Record<string, unknown>) => {
	for (const key in value) {
		const val = value[key];
		if (typeof val === "function") {
			value[key] = action(val as (...args: unknown[]) => unknown);
		} else if (typeof val === "object" && val !== null && !("brand" in val)) {
			// Recursively wrap nested object properties in actions. This allows users to write
			// nested models without worrying about wrapping their functions in `action`.
			wrapInAction(val as Record<string, unknown>);
		}
	}
};

function createModel<TModel, TFactoryArgs extends any[] = []>(
	modelFactory: ModelFactory<TModel, TFactoryArgs>
): ModelConstructor<TModel, TFactoryArgs> {
	return function SignalModel(...args: TFactoryArgs): Model<TModel> {
		let modelEffects: Effect[] | undefined;
		let model: Model<TModel>;

		const stopCapturingEffects = startCapturingEffects();
		try {
			model = modelFactory(...args) as Model<TModel>;
		} catch (err) {
			// Drop any captured effects on error. Errors from nested models will bubble
			// up here and recursively reset `capturedEffects` to `undefined` preventing
			// any captured effects from leaking
			capturedEffects = undefined;
			throw err;
		} finally {
			modelEffects = stopCapturingEffects();
		}

		wrapInAction(model);

		model[Symbol.dispose] = action(function disposeModel() {
			if (modelEffects) {
				for (let i = 0; i < modelEffects.length; i++) {
					modelEffects[i].dispose();
				}
			}

			modelEffects = undefined;
		});

		return model;
	} as InternalModelConstructor<TModel, TFactoryArgs>;
}

//#endregion createModel

export {
	computed,
	effect,
	batch,
	untracked,
	action,
	createModel,
	Signal,
	ReadonlySignal,
	Effect,
	Computed,
};
