/*!
 * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
 * Licensed under the MIT License.
 */

import { strict as assert } from "node:assert";

import { AttachState } from "@fluidframework/container-definitions";
import type {
	IContainerContext,
	IRuntime,
} from "@fluidframework/container-definitions/internal";
import { ConnectionState } from "@fluidframework/container-definitions/internal";
import type {
	ContainerExtension,
	ContainerExtensionFactory,
	ContainerExtensionId,
	ExtensionCompatibilityDetails,
	ExtensionHost,
	ExtensionHostEvents,
	ExtensionInstantiationResult,
	ExtensionRuntimeProperties,
	IContainerRuntimeInternal,
} from "@fluidframework/container-runtime-definitions/internal";
import type { Listenable } from "@fluidframework/core-interfaces";
import { MockLogger } from "@fluidframework/telemetry-utils/internal";
import {
	MockAudience,
	MockDeltaManager,
	MockQuorumClients,
} from "@fluidframework/test-runtime-utils/internal";

import { ContainerRuntime } from "../containerRuntime.js";
import { FluidDataStoreRegistry } from "../dataStoreRegistry.js";

const testExtensionId: ContainerExtensionId = "test:extension";

interface TestExtensionRuntimeProperties extends ExtensionRuntimeProperties {
	SignalMessages: { type: string; content: unknown };
}

const extensionCompatibilityDetails = {
	generation: 1,
	version: "1.0.0",
	capabilities: new Set<string>([]),
} as const satisfies ExtensionCompatibilityDetails;

class TestExtension implements ContainerExtension<TestExtensionRuntimeProperties> {
	public readonly compatibility = extensionCompatibilityDetails;
	public readonly interface: {
		connectedToService: boolean;
		events: Listenable<ExtensionHostEvents>;
	};

	public readonly extension = this;

	constructor(host: ExtensionHost<TestExtensionRuntimeProperties>) {
		this.interface = {
			get connectedToService(): boolean {
				return host.getJoinedStatus() !== "disconnected";
			},
			events: host.events,
		};
	}

	public handleVersionOrCapabilitiesMismatch<_TRequestedInterface>(
		_thisExistingInstantiation: Readonly<
			ExtensionInstantiationResult<TestExtension, TestExtensionRuntimeProperties, []>
		>,
		_newCompatibilityRequest: ExtensionCompatibilityDetails,
	): never {
		throw new Error("compat mismatch with TestExtension is not expected");
	}

	public onNewUse(): void {
		// No-op
	}
}

class TestExtensionFactoryClass
	implements
		ContainerExtensionFactory<
			{ connectedToService: boolean; events: Listenable<ExtensionHostEvents> },
			TestExtensionRuntimeProperties
		>
{
	public readonly hostRequirements = {
		minSupportedGeneration: 1,
		requiredFeatures: [],
	};
	public readonly instanceExpectations = extensionCompatibilityDetails;

	public resolvePriorInstantiation(
		existingEntry: ExtensionInstantiationResult<
			unknown,
			ExtensionRuntimeProperties,
			unknown[]
		>,
	): ExtensionInstantiationResult<
		{ connectedToService: boolean; events: Listenable<ExtensionHostEvents> },
		TestExtensionRuntimeProperties,
		[]
	> {
		throw new Error("compat mismatch with TestExtension is not expected");
	}

	public instantiateExtension(
		host: ExtensionHost<TestExtensionRuntimeProperties>,
	): ExtensionInstantiationResult<
		{ connectedToService: boolean; events: Listenable<ExtensionHostEvents> },
		TestExtensionRuntimeProperties,
		[]
	> {
		return new TestExtension(host);
	}

	[Symbol.hasInstance](
		instance: unknown,
	): instance is ExtensionInstantiationResult<
		{ connectedToService: boolean; events: Listenable<ExtensionHostEvents> },
		TestExtensionRuntimeProperties,
		[]
	> {
		return instance instanceof TestExtension;
	}
}
const TestExtensionFactory = new TestExtensionFactoryClass();

class MockContext implements IContainerContext {
	public readonly deltaManager = new MockDeltaManager();
	public readonly quorum = new MockQuorumClients();
	public readonly storage = {} as unknown as IContainerContext["storage"];
	public readonly baseSnapshot = undefined;
	public readonly options = {};
	public readonly loader = {} as unknown as IContainerContext["loader"];
	public readonly clientDetails = { capabilities: { interactive: true } };
	public readonly scope = {};
	public readonly id = "mockId";
	public readonly taggedLogger = new MockLogger();

	public readonly updateDirtyContainerState = (): void => {};
	public readonly getLoadedFromVersion = (): undefined => undefined;
	public readonly submitBatchFn = (): number => 1;
	public readonly submitSummaryFn = (): number => 1;
	public readonly submitSignalFn = (): void => {};
	public readonly submitFn = (): number => 1;
	public readonly disposeFn = (): void => {};
	public readonly closeFn = (): void => {};
	public readonly getAbsoluteUrl = async (): Promise<string> => "mockUrl";
	public getConnectionState? = (): ConnectionState => this.connectionState;

	public readonly audience = new MockAudience();
	// signalAudience is optional to allow simulating older loader (create mock, then delete this property)
	public signalAudience? = new MockAudience();

	// State for testing purposes
	private connectionState: ConnectionState = ConnectionState.Disconnected;
	public readonly isReadonly: boolean;

	constructor(isReadonly: boolean = false) {
		this.isReadonly = isReadonly;
	}

	public get clientId(): string | undefined {
		return this.audience.getSelf()?.clientId;
	}
	public get connected(): boolean {
		return this.connectionState === ConnectionState.Connected;
	}
	public get attachState(): AttachState {
		return AttachState.Attached;
	}

	public setConnectionState(connectionState: ConnectionState, clientId?: string): void {
		this.connectionState = connectionState;
		if (clientId !== undefined) {
			this.signalAudience?.setCurrentClientId(clientId);
			if (connectionState !== ConnectionState.CatchingUp) {
				this.audience.setCurrentClientId(clientId);
			}
		}
	}
}

async function createRuntimeWithMockContext(isReadonly: boolean = false): Promise<{
	runtime: IContainerRuntimeInternal & Required<IRuntime>;
	context: MockContext;
}> {
	const context = new MockContext(isReadonly);
	const { runtime } = await ContainerRuntime.loadRuntime2({
		context,
		registry: new FluidDataStoreRegistry([]),
		existing: false,
		provideEntryPoint: async () => ({}),
	});
	return { runtime, context };
}

async function createRuntimeWithoutConnectionState(isReadonly: boolean = false): Promise<{
	runtime: IContainerRuntimeInternal & Required<IRuntime>;
	context: MockContext;
}> {
	const context = new MockContext(isReadonly);
	// Delete the getConnectionState method before creating the runtime
	delete context.getConnectionState;
	delete context.signalAudience;
	const { runtime } = await ContainerRuntime.loadRuntime2({
		context,
		registry: new FluidDataStoreRegistry([]),
		existing: false,
		provideEntryPoint: async () => ({}),
	});
	return { runtime, context };
}

function updateConnectionState(
	runtime: IContainerRuntimeInternal & Required<IRuntime>,
	context: MockContext,
	connectionState: ConnectionState.EstablishingConnection | ConnectionState.Disconnected,
	clientId?: undefined,
): void;
function updateConnectionState(
	runtime: IContainerRuntimeInternal & Required<IRuntime>,
	context: MockContext,
	connectionState: ConnectionState.CatchingUp | ConnectionState.Connected,
	clientId: string,
): void;

function updateConnectionState(
	runtime: IContainerRuntimeInternal & Required<IRuntime>,
	context: MockContext,
	connectionState: ConnectionState,
	clientId?: string | undefined,
): void {
	context.setConnectionState(connectionState, clientId);

	switch (connectionState) {
		case ConnectionState.EstablishingConnection: {
			runtime.setConnectionStatus({
				connectionState,
				canSendOps: false,
				readonly: context.isReadonly,
			});

			break;
		}
		case ConnectionState.CatchingUp: {
			assert(clientId !== undefined, "clientId must be provided when catching up");
			runtime.setConnectionStatus({
				connectionState,
				pendingClientConnectionId: clientId,
				canSendOps: false,
				readonly: context.isReadonly,
			});

			break;
		}
		case ConnectionState.Connected: {
			assert(clientId !== undefined, "clientId must be provided when connected");
			runtime.setConnectionStatus({
				connectionState,
				clientConnectionId: clientId,
				canSendOps: !context.isReadonly,
				readonly: context.isReadonly,
			});

			break;
		}
		default: {
			runtime.setConnectionStatus({
				connectionState,
				canSendOps: false,
				readonly: context.isReadonly,
				priorPendingClientConnectionId: context.signalAudience?.getSelf()?.clientId,
				priorConnectedClientConnectionId: context.clientId,
			});
		}
	}
}

function setupExtensionEventListeners(extensionInterface: {
	connectedToService: boolean;
	events: Listenable<ExtensionHostEvents>;
}): {
	type: keyof ExtensionHostEvents;
	clientId?: string;
	canWrite?: boolean;
}[] {
	const events: {
		type: keyof ExtensionHostEvents;
		clientId?: string;
		canWrite?: boolean;
	}[] = [];

	extensionInterface.events.on(
		"joined",
		({ clientId, canWrite }: { clientId: string; canWrite: boolean }) => {
			events.push({ type: "joined", clientId, canWrite });
		},
	);

	extensionInterface.events.on("disconnected", () => {
		events.push({ type: "disconnected" });
	});

	extensionInterface.events.on("operabilityChanged", (canWrite: boolean) => {
		events.push({ type: "operabilityChanged", canWrite });
	});

	return events;
}
describe("Runtime", () => {
	let runtime: IContainerRuntimeInternal & Required<IRuntime>;
	let context: MockContext;
	describe("Container Runtime", () => {
		describe("Container Extension", () => {
			let extension: { connectedToService: boolean; events: Listenable<ExtensionHostEvents> };

			beforeEach(async () => {
				const setup = await createRuntimeWithMockContext();
				runtime = setup.runtime;
				context = setup.context;
				extension = runtime.acquireExtension(testExtensionId, TestExtensionFactory);
			});

			describe("connection status", () => {
				it("should return true when context is `Connected`", async () => {
					updateConnectionState(runtime, context, ConnectionState.Connected, "mockClientId");

					assert.strictEqual(
						extension.connectedToService,
						true,
						"Extension should be connected",
					);
				});

				it("should return false when context is `CatchingUp`", async () => {
					updateConnectionState(runtime, context, ConnectionState.CatchingUp, "mockClientId");

					assert.strictEqual(
						extension.connectedToService,
						true,
						"Extension should be connected during CatchingUp state",
					);
				});

				it("should return false when context is `Disconnected`", async () => {
					updateConnectionState(runtime, context, ConnectionState.Disconnected);

					assert.strictEqual(
						extension.connectedToService,
						false,
						"Extension should not be connected when disconnected",
					);
				});

				it("should return false when context is `EstablishingConnection`", async () => {
					updateConnectionState(runtime, context, ConnectionState.EstablishingConnection);

					assert.strictEqual(
						extension.connectedToService,
						false,
						"Extension should not be connected when establishing connection",
					);
				});

				describe("fallback behavior when getConnectionState is undefined", () => {
					let fallbackRuntime: IContainerRuntimeInternal & Required<IRuntime>;
					let fallbackContext: MockContext;
					let fallbackExtension: {
						connectedToService: boolean;
						events: Listenable<ExtensionHostEvents>;
					};

					beforeEach(async () => {
						// Create runtime without getConnectionState to test fallback behavior
						const setup = await createRuntimeWithoutConnectionState();
						fallbackRuntime = setup.runtime;
						fallbackContext = setup.context;
						fallbackExtension = fallbackRuntime.acquireExtension(
							testExtensionId,
							TestExtensionFactory,
						);
					});

					it("should fallback to canSendOps when context is `Connected`", async () => {
						updateConnectionState(
							fallbackRuntime,
							fallbackContext,
							ConnectionState.Connected,
							"mockClientId",
						);

						assert.strictEqual(
							fallbackExtension.connectedToService,
							true,
							"Extension should fallback to runtime.connected when connectionState is undefined",
						);
					});

					it("should fallback to canSendOps when context is `Disconnected`", async () => {
						updateConnectionState(
							fallbackRuntime,
							fallbackContext,
							ConnectionState.Disconnected,
						);

						assert.strictEqual(
							fallbackExtension.connectedToService,
							false,
							"Extension should fallback to runtime.connected when connectionState is undefined and runtime is disconnected",
						);
					});
				});
			});

			describe("event handling", () => {
				let events: {
					type: keyof ExtensionHostEvents;
					clientId?: string;
					canWrite?: boolean;
				}[];

				beforeEach(() => {
					events = setupExtensionEventListeners(extension);
				});

				it("should handle dynamic connection state transitions - write client", async () => {
					// Initially disconnected
					assert.strictEqual(
						extension.connectedToService,
						false,
						"Extension should initially be disconnected",
					);
					assert.strictEqual(events.length, 0, "Should have no events initially");

					// Transition to EstablishingConnection
					updateConnectionState(runtime, context, ConnectionState.EstablishingConnection);
					assert.strictEqual(
						extension.connectedToService,
						false,
						"Extension should remain disconnected during EstablishingConnection",
					);
					assert.strictEqual(
						events.length,
						0,
						"Should have no events during EstablishingConnection",
					);

					// Transition to CatchingUp
					updateConnectionState(runtime, context, ConnectionState.CatchingUp, "mockClientId");
					assert.strictEqual(events.length, 1, "Should have received one joined event");
					assert.deepStrictEqual(
						events[0],
						{ type: "joined", clientId: "mockClientId", canWrite: false },
						"First event should be joined for reading",
					);
					assert.strictEqual(
						extension.connectedToService,
						true,
						"Extension should be connected during CatchingUp",
					);

					// Transition to Connected
					updateConnectionState(runtime, context, ConnectionState.Connected, "mockClientId");
					assert.strictEqual(events.length, 2, "Should have received two events total");
					assert.deepStrictEqual(
						events[1],
						{ type: "operabilityChanged", canWrite: true },
						"Second event should be operabilityChanged to can write",
					);
					assert.strictEqual(
						extension.connectedToService,
						true,
						"Extension should be connected when fully Connected",
					);

					// Transition back to Disconnected
					updateConnectionState(runtime, context, ConnectionState.Disconnected);
					assert.strictEqual(events.length, 3, "Should have received three events total");
					assert.deepStrictEqual(
						events[2],
						{ type: "disconnected" },
						"Third event should be disconnected",
					);
					assert.strictEqual(
						extension.connectedToService,
						false,
						"Extension should be disconnected after transition back to Disconnected",
					);
				});

				it("should handle dynamic connection state transitions - read client", async () => {
					// Create a read-only runtime setup
					const readOnlySetup = await createRuntimeWithMockContext(true /* readonly */);
					const readOnlyRuntime = readOnlySetup.runtime;
					const readOnlyContext = readOnlySetup.context;

					const readOnlyExtension = readOnlyRuntime.acquireExtension(
						testExtensionId,
						TestExtensionFactory,
					);
					const readOnlyEvents = setupExtensionEventListeners(readOnlyExtension);

					// Initially disconnected
					assert.strictEqual(
						readOnlyExtension.connectedToService,
						false,
						"Extension should initially be disconnected",
					);
					assert.strictEqual(readOnlyEvents.length, 0, "Should have no events initially");

					// Transition to EstablishingConnection
					updateConnectionState(
						readOnlyRuntime,
						readOnlyContext,
						ConnectionState.EstablishingConnection,
					);
					assert.strictEqual(
						readOnlyExtension.connectedToService,
						false,
						"Extension should remain disconnected during EstablishingConnection",
					);
					assert.strictEqual(
						readOnlyEvents.length,
						0,
						"Should have no events during EstablishingConnection",
					);

					// Transition to CatchingUp
					updateConnectionState(
						readOnlyRuntime,
						readOnlyContext,
						ConnectionState.CatchingUp,
						"mockClientId",
					);
					assert.strictEqual(
						readOnlyExtension.connectedToService,
						true,
						"Extension should be connected during CatchingUp",
					);
					assert.strictEqual(
						readOnlyEvents.length,
						1,
						"Should have received one event: joined for reading only",
					);
					assert.deepStrictEqual(
						readOnlyEvents[0],
						{ type: "joined", clientId: "mockClientId", canWrite: false },
						"Event should be joined for reading",
					);

					// Transition to Connected
					updateConnectionState(
						readOnlyRuntime,
						readOnlyContext,
						ConnectionState.Connected,
						"mockClientId",
					);
					assert.strictEqual(readOnlyEvents.length, 1, "Should have received one event total");
					assert.strictEqual(
						readOnlyExtension.connectedToService,
						true,
						"Extension should be connected when fully Connected",
					);

					// Transition back to Disconnected
					updateConnectionState(
						readOnlyRuntime,
						readOnlyContext,
						ConnectionState.Disconnected,
					);
					assert.strictEqual(
						readOnlyEvents.length,
						2,
						"Should have received two events total",
					);
					assert.deepStrictEqual(
						readOnlyEvents[1],
						{ type: "disconnected" },
						"Second event should be disconnected",
					);
					assert.strictEqual(
						readOnlyExtension.connectedToService,
						false,
						"Extension should be disconnected after transition back to Disconnected",
					);
				});

				it("should handle connection type changes", async () => {
					updateConnectionState(runtime, context, ConnectionState.Connected, "mockClientId");

					// Should have initial joined event
					assert.strictEqual(events.length, 1, "Should have received initial joined event");
					assert.deepStrictEqual(
						events[0],
						{ type: "joined", clientId: "mockClientId", canWrite: true },
						"Initial event should be joined with write access",
					);

					// Act: forceReadonly
					// forceReadonly would change the connection type of container
					// This "readonly" event from deltaManager would then trigger
					// setConnectionState to be called with canSendOps being false (Connected && !readonly)
					runtime.setConnectionState(false, context.clientId);
					updateConnectionState(
						runtime,
						context,
						ConnectionState.Connected,
						"newMockClientId",
					);

					assert.strictEqual(
						events.length,
						3,
						"Should have received three events total: joined + two operabilityChanged",
					);
					assert.deepStrictEqual(
						events[1],
						{ type: "operabilityChanged", canWrite: false },
						"Second event should indicate operability changed to read-only",
					);
					assert.deepStrictEqual(
						events[2],
						{ type: "operabilityChanged", canWrite: true },
						"Third event should indicate operability changed to writable",
					);
				});

				it("should still emit joined and disconnected events when getConnectionState is undefined", async () => {
					// Create a new runtime without getConnectionState to test fallback behavior
					const fallbackSetup = await createRuntimeWithoutConnectionState();
					const fallbackRuntime = fallbackSetup.runtime;
					const fallbackContext = fallbackSetup.context;
					const fallbackExtension = fallbackRuntime.acquireExtension(
						testExtensionId,
						TestExtensionFactory,
					);
					const fallbackEvents = setupExtensionEventListeners(fallbackExtension);

					// Connect
					updateConnectionState(
						fallbackRuntime,
						fallbackContext,
						ConnectionState.Connected,
						"mockClientId",
					);
					assert.strictEqual(
						fallbackExtension.connectedToService,
						true,
						"Extension should be connected",
					);
					assert.strictEqual(
						fallbackEvents.length,
						1,
						"Should have received one joined event",
					);
					assert.deepStrictEqual(
						fallbackEvents[0],
						{ type: "joined", clientId: "mockClientId", canWrite: true },
						"Event should be joined with canWrite = true when getConnectionState is undefined",
					);

					// Disconnect
					updateConnectionState(
						fallbackRuntime,
						fallbackContext,
						ConnectionState.Disconnected,
					);
					assert.strictEqual(
						fallbackExtension.connectedToService,
						false,
						"Extension should be disconnected",
					);
					assert.strictEqual(
						fallbackEvents.length,
						2,
						"Should have received two events total",
					);
					assert.deepStrictEqual(
						fallbackEvents[1],
						{ type: "disconnected" },
						"Second event should be disconnected",
					);
				});
			});
		});
	});
});
