import { createProxy } from "./fetch-client.js";
import { BROKER_DURABLE_PLACEHOLDER, BROKER_PURPOSE_PLACEHOLDER } from "../message-broker/contract.js";

import type { BrokerContractsRPCClient, BrokerPublishOptions, BrokerSubscribeOptions } from "./contract-map.js";
import type {
    BrokerContract,
    BrokerContractDeliveryFulfilled,
    BrokerSubjectTemplate,
} from "../message-broker/contract.js";
import type { BrokerPointToPointGuarantee } from "../message-broker/point-to-point.js";

// Contract-first middleware interfaces
interface IContractMiddlewareContext<TData = unknown, TOptions = unknown, TMetadata = Record<string, unknown>> {
    contract: BrokerContract<`v${number}`, string>;
    subject: string;
    data: TData;
    options: TOptions;
    metadata?: TMetadata;
}

/**
 * Context provided to transformDelivery hook for delivery configuration transformation.
 */
interface IDeliveryTransformContext {
    /**
     * The contract being processed.
     */
    contract: BrokerContract<`v${number}`, string>;
    /**
     * The resolved subject (after parameter replacement, before middleware transformation).
     */
    subject: string;
    /**
     * The service name used for placeholder replacement.
     */
    serviceName: string;
}

interface IContractMiddleware {
    name: string;
    beforePublish?: (context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    afterPublish?: (context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    beforeSubscribe?: (context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    afterSubscribe?: (context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    /**
     * Hook called after a message has been successfully processed by the listener.
     * This is different from afterSubscribe, which is called when the subscription is created.
     * Use this hook for middleware that needs to track successful message processing,
     * such as Circuit Breaker (to close HALF_OPEN circuits) or metrics collection.
     */
    afterMessageProcessed?: (context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    beforeRequest?: (context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    afterRequest?: (context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    onError?: (error: Error, context: IContractMiddlewareContext<unknown, unknown>) => Promise<void>;
    /**
     * Optional hook to transform delivery configuration before validation.
     * This allows middleware to modify delivery properties (like durable names, streams, queues)
     * for purposes like test isolation, environment-specific configs, etc.
     *
     * Called after placeholder replacement but before validation.
     * The delivery object can be mutated directly.
     *

     */
    transformDelivery?: (
        delivery: BrokerContractDeliveryFulfilled,
        context: IDeliveryTransformContext,
    ) => Promise<void>;
}

/**
 * Create a full type-safe broker client from multiple API endpoints defined by contracts:
 *
 * See the `README.md` for more examples and the `createBrokerContract` function.
 */
abstract class AbstractMessageBrokerClient<
    EventFunctions extends Record<string, (...args: any[]) => BrokerContract<any, any>>,
> {
    public readonly broker: BrokerContractsRPCClient<EventFunctions>;

    private readonly serviceName: string;

    private readonly replicaId: string;

    private readonly replicaSlot: string;

    private readonly contracts: Map<string, BrokerContract<any, any>> = new Map();
    private readonly eventFunctions: EventFunctions;

    // Contract-first middleware support
    private middleware: IContractMiddleware[] = [];

    protected readonly logger?: any;

    public constructor(
        serviceName: string,
        replicaId: string,
        replicaSlot: string,
        eventFunctions: EventFunctions,
        logger?: any,
    ) {
        this.serviceName = serviceName;
        this.replicaId = replicaId;
        this.replicaSlot = replicaSlot;
        this.eventFunctions = eventFunctions;
        this.logger = logger;
        this.broker = createProxy(
            this.parseBrokerPath.bind(this),
        ) as unknown as BrokerContractsRPCClient<EventFunctions>;
    }

    // Contract-first middleware management
    public use(middleware: IContractMiddleware): this {
        if (this.middleware.some((m) => m.name === middleware.name)) {
            this.logger?.warn(`Middleware with name '${middleware.name}' already exists`, {
                middlewareName: middleware.name,
            });
            return this;
        }
        this.middleware.push(middleware);
        return this;
    }

    public removeMiddleware(name: string): this {
        const index = this.middleware.findIndex((m) => m.name === name);
        if (index !== -1) {
            this.middleware.splice(index, 1);
        }
        return this;
    }

    public getMiddleware(): readonly IContractMiddleware[] {
        return [...this.middleware];
    }

    public clearMiddleware(): this {
        this.middleware = [];
        return this;
    }

    // Contract-first middleware execution
    private async executePublishMiddleware<TOptions>(
        contract: BrokerContract<`v${number}`, string>,
        data: unknown,
        options: TOptions,
        resolvedSubject: string,
    ): Promise<IContractMiddlewareContext<unknown, TOptions>> {
        const context: IContractMiddlewareContext<unknown, TOptions> = {
            contract,
            subject: resolvedSubject,
            data,
            options,
        };

        // Execute beforePublish middleware
        for (const mw of this.middleware) {
            try {
                if (mw.beforePublish) {
                    await mw.beforePublish(context);
                }
            } catch (error) {
                // Execute onError middleware for all middleware (not just the one that failed)
                // This allows error handlers to log/process errors, but we always rethrow
                // the original error to maintain error propagation
                for (const errorMw of this.middleware) {
                    try {
                        if (errorMw.onError) {
                            await errorMw.onError(error as Error, context);
                        }
                    } catch (onErrorError) {
                        this.logger?.error(`Middleware error in onError`, {
                            middleware: errorMw.name,
                            error: onErrorError instanceof Error ? onErrorError.message : String(onErrorError),
                        });
                    }
                }

                this.logger?.error(`Middleware error in beforePublish`, {
                    middleware: mw.name,
                    error: error instanceof Error ? error.message : String(error),
                });

                // Always rethrow to maintain error propagation
                throw error;
            }
        }

        // Return the (possibly modified) context
        // Note: afterPublish is executed after the actual publish operation in wrappedPublish
        return context;
    }

    /**
     * Execute afterPublish middleware hooks after the publish operation completes.
     * Errors are logged but not thrown to avoid breaking the flow.
     */
    private async executeAfterPublishMiddleware<TOptions>(
        context: IContractMiddlewareContext<unknown, TOptions>,
    ): Promise<void> {
        for (const mw of this.middleware) {
            try {
                if (mw.afterPublish) {
                    await mw.afterPublish(context);
                }
            } catch (error) {
                this.logger?.error(`Middleware error in afterPublish`, {
                    middleware: mw.name,
                    error: error instanceof Error ? error.message : String(error),
                });
                // Don't throw for afterPublish errors to avoid breaking the flow
            }
        }
    }

    /**
     * Execute afterSubscribe middleware hooks after the subscribe operation completes.
     * Errors are logged but not thrown to avoid breaking the flow.
     */
    private async executeAfterSubscribeMiddleware<TOptions>(
        context: IContractMiddlewareContext<unknown, TOptions>,
    ): Promise<void> {
        for (const mw of this.middleware) {
            try {
                if (mw.afterSubscribe) {
                    await mw.afterSubscribe(context);
                }
            } catch (error) {
                this.logger?.error(`Middleware error in afterSubscribe`, {
                    middleware: mw.name,
                    error: error instanceof Error ? error.message : String(error),
                });
                // Don't throw for afterSubscribe errors to avoid breaking the flow
            }
        }
    }

    /**
     * Execute afterMessageProcessed middleware hooks after a message has been successfully processed.
     * This is called after the listener completes successfully, not when the subscription is created.
     * Errors are logged but not thrown to avoid breaking the flow.
     */
    protected async executeAfterMessageProcessed<TOptions>(
        context: IContractMiddlewareContext<unknown, TOptions>,
    ): Promise<void> {
        for (const mw of this.middleware) {
            try {
                if (mw.afterMessageProcessed) {
                    await mw.afterMessageProcessed(context);
                }
            } catch (error) {
                this.logger?.error("Middleware error in afterMessageProcessed", {
                    middleware: mw.name,
                    error: error instanceof Error ? error.message : String(error),
                });
                // Don't throw for afterMessageProcessed errors to avoid breaking the flow
            }
        }
    }

    /**
     * Execute afterRequest middleware hooks after the request operation completes.
     * Errors are logged but not thrown to avoid breaking the flow.
     */
    private async executeAfterRequestMiddleware<TOptions>(
        context: IContractMiddlewareContext<unknown, TOptions>,
    ): Promise<void> {
        for (const mw of this.middleware) {
            try {
                if (mw.afterRequest) {
                    await mw.afterRequest(context);
                }
            } catch (error) {
                this.logger?.error(`Middleware error in afterRequest`, {
                    middleware: mw.name,
                    error: error instanceof Error ? error.message : String(error),
                });
                // Don't throw for afterRequest errors to avoid breaking the flow
            }
        }
    }

    private async executeSubscribeMiddleware<TOptions>(
        contract: BrokerContract<`v${number}`, string>,
        data: unknown,
        options: TOptions,
        resolvedSubject: string,
    ): Promise<IContractMiddlewareContext<unknown, TOptions>> {
        const context: IContractMiddlewareContext<unknown, TOptions> = {
            contract,
            subject: resolvedSubject,
            data,
            options,
        };

        for (const mw of this.middleware) {
            try {
                if (mw.beforeSubscribe) {
                    await mw.beforeSubscribe(context);
                }
            } catch (error) {
                this.logger?.error(`Middleware error in beforeSubscribe`, {
                    middleware: mw.name,
                    error: error instanceof Error ? error.message : String(error),
                });
                throw error;
            }
        }

        // Return the (possibly modified) context
        return context;
    }

    private async executeRequestMiddleware<TOptions>(
        contract: BrokerContract<`v${number}`, string>,
        data: unknown,
        options: TOptions,
        resolvedSubject: string,
    ): Promise<IContractMiddlewareContext<unknown, TOptions>> {
        const context: IContractMiddlewareContext<unknown, TOptions> = {
            contract,
            subject: resolvedSubject,
            data,
            options,
        };

        // Execute beforeRequest middleware
        for (const mw of this.middleware) {
            try {
                if (mw.beforeRequest) {
                    await mw.beforeRequest(context);
                }
            } catch (error) {
                this.logger?.error(`Middleware error in beforeRequest`, {
                    middleware: mw.name,
                    error: error instanceof Error ? error.message : String(error),
                });
                throw error;
            }
        }

        // Return the (possibly modified) context
        return context;
    }

    public abstract publish(
        subject: string,
        delivery: BrokerContractDeliveryFulfilled,
        opts: BrokerPublishOptions<
            BrokerContract<any, any, BrokerSubjectTemplate<string>, BrokerPointToPointGuarantee>
        >,
        originalSubject: string,
    ): Promise<void | { data: unknown }>;

    public abstract subscribe(
        subject: string,
        delivery: BrokerContractDeliveryFulfilled,
        opts: BrokerSubscribeOptions<
            BrokerContract<any, any, BrokerSubjectTemplate<string>, BrokerPointToPointGuarantee>
        >,
        originalSubject: string,
    ): Promise<{ unsubscribe: () => Promise<void> }>;

    private async wrappedPublish(
        subject: string,
        delivery: BrokerContractDeliveryFulfilled,
        opts: BrokerPublishOptions<BrokerContract<any, any>>,
        contract: BrokerContract<any, any>,
    ) {
        // Execute middleware before publish and get the (possibly modified) context
        let finalSubject = subject;
        let finalOpts = opts;
        let context: IContractMiddlewareContext<unknown, unknown> | undefined;

        if (this.middleware.length > 0) {
            // BrokerPublishOptions includes Contract["message"] which is BrokerContractMessage<Data, Headers>
            // This class always has 'data' and 'headers' properties. TypeScript can't infer this
            // when Contract is BrokerContract<any, any>, so we use a type assertion.
            // This is safe because BrokerPublishOptions = params & Contract["message"], and
            // BrokerContractMessage always defines the data property.
            const messageData = (
                opts as BrokerPublishOptions<BrokerContract<any, any>> & {
                    data: unknown;
                }
            ).data;
            context = await this.executePublishMiddleware(contract, messageData, opts, subject);
            finalSubject = context.subject;
            finalOpts = context.options as typeof opts;
        }

        const result = await this.publish(finalSubject, delivery, finalOpts, contract.subject);

        // Execute afterPublish middleware AFTER the actual publish operation
        if (context) {
            await this.executeAfterPublishMiddleware(context);
        }

        return result;
    }

    private async wrappedSubscribe(
        subject: string,
        delivery: BrokerContractDeliveryFulfilled,
        opts: BrokerSubscribeOptions<
            BrokerContract<any, any, BrokerSubjectTemplate<string>, BrokerPointToPointGuarantee>
        >,
        contract: BrokerContract<any, any>,
    ) {
        // Execute middleware before subscribe and get the (possibly modified) context
        let finalSubject = subject;
        let finalOpts = opts;
        let context: IContractMiddlewareContext<unknown, unknown> | undefined;

        if (this.middleware.length > 0) {
            context = await this.executeSubscribeMiddleware(contract, undefined, opts, subject);
            finalSubject = context.subject;
            finalOpts = context.options as typeof opts;
        }

        const result = await this.subscribe(finalSubject, delivery, finalOpts, contract.subject);

        // Execute afterSubscribe middleware AFTER the subscription is set up
        if (context) {
            await this.executeAfterSubscribeMiddleware(context);
        }

        return result;
    }

    private async wrappedRequest(
        subject: string,
        delivery: BrokerContractDeliveryFulfilled,
        opts: BrokerPublishOptions<BrokerContract<any, any>>,
        contract: BrokerContract<any, any>,
    ): Promise<{ data: unknown }> {
        // Execute middleware before request and get the (possibly modified) context
        // Use executeRequestMiddleware to call beforeRequest hooks (not beforePublish)
        let finalSubject = subject;
        let finalOpts = opts;
        let context: IContractMiddlewareContext<unknown, unknown> | undefined;

        if (this.middleware.length > 0) {
            context = await this.executeRequestMiddleware(contract, opts.data, opts, subject);
            finalSubject = context.subject;
            finalOpts = context.options as typeof opts;
        }

        // For request+reply, publish always returns the response data (never void)
        // This is guaranteed by the implementation: publishRequestReply always returns { data: unknown }
        const result = await this.publish(finalSubject, delivery, finalOpts, contract.subject);
        if (!result || typeof result !== "object" || !("data" in result)) {
            throw new Error("Request+reply publish must return response data");
        }

        // Execute afterRequest middleware AFTER the request completes
        if (context) {
            await this.executeAfterRequestMiddleware(context);
        }

        return result;
    }

    private async parseBrokerPath([domain, method]: string[], [subjectWithoutDomain, opts]: any[]) {
        this.probablyCreateIndexFromEvents();

        const subjectString = String(subjectWithoutDomain).toLowerCase();

        // Check if subjectString already contains the domain (e.g., "website.health-check.ping.v1")
        // If so, use it directly as the contract key
        let contractKey: string;
        let contract: BrokerContract<any, any> | undefined;

        if (subjectString.startsWith(`${domain}.`)) {
            // Subject already contains domain, use it directly
            contractKey = subjectString;
            contract = this.contracts.get(contractKey);
        } else {
            // Subject doesn't contain domain, combine with domain (e.g., "health-check.ping.v1" -> "website.health-check.ping.v1")
            contractKey = `${domain}.${subjectString}`;
            contract = this.contracts.get(contractKey);
        }

        // If not found, try to find by function name (for typed API)
        // The typed API uses function names (e.g., "getOrder") instead of subject names (e.g., "get.v1")
        if (!contract) {
            // Look up the contract by function name in eventFunctions
            const functionName = subjectString;
            if (functionName in this.eventFunctions) {
                const contractFromFunction = this.eventFunctions[functionName as keyof typeof this.eventFunctions]();
                // Use the contract's subject (which already contains domain.subject.version)
                contractKey = contractFromFunction.subject.toLowerCase();
                contract = this.contracts.get(contractKey);
            }
        }

        if (!contract) {
            throw new Error(
                `Contract not found for key: ${contractKey}. Available contracts: ${Array.from(this.contracts.keys()).join(", ")}`,
            );
        }
        const { subject, delivery: originalDelivery } = contract;

        // Clone the delivery object to avoid mutating the cached contract
        // This ensures placeholders are replaced correctly on subsequent calls
        // Use structuredClone for proper deep cloning of nested objects (e.g., deadLetterQueue)
        const delivery = structuredClone(originalDelivery) as typeof originalDelivery;

        // Replace the placeholders with the actual values from the params
        const finalSubject = subject.replace(
            /{{(.*?)}}/gi,
            (_, p1) =>
                opts.params[Object.keys(opts.params).find((key) => key.toLowerCase() === p1.toLowerCase()) ?? p1],
        );

        // Replace the placeholder for the durable `{durable}` and the purpose `{purpose}`
        const replacePurpose = new RegExp(BROKER_PURPOSE_PLACEHOLDER, "gi");
        const replaceDurable = new RegExp(BROKER_DURABLE_PLACEHOLDER, "gi");
        const useDurable =
            delivery.type === "broadcast+guarantee"
                ? delivery.stickyDurable
                    ? this.replicaSlot
                    : this.replicaId
                : delivery.type === "broadcast+no-guarantee"
                  ? this.replicaSlot
                  : this.serviceName;
        const purpose = "purpose" in opts && typeof opts.purpose === "string" ? opts.purpose : "";
        if ("durable" in delivery) {
            delivery.durable = delivery.durable.replace(replacePurpose, purpose);
            delivery.durable = delivery.durable.replace(replaceDurable, useDurable);
        }
        if ("queue" in delivery && typeof delivery.queue === "string") {
            delivery.queue = delivery.queue.replace(replacePurpose, purpose);
            delivery.queue = delivery.queue.replace(replaceDurable, useDurable);
        }
        if ("deadLetterQueue" in delivery && delivery.deadLetterQueue) {
            const dlq = delivery.deadLetterQueue as Record<string, unknown>;
            // Only process DLQ properties if DLQ is enabled (default is enabled, so check for explicit false)
            if (dlq.enabled !== false) {
                if (dlq.durable && typeof dlq.durable === "string") {
                    dlq.durable = dlq.durable.replace(replacePurpose, purpose);
                    dlq.durable = (dlq.durable as string).replace(replaceDurable, this.serviceName);
                }
            }
        }

        // Allow middleware to transform delivery configuration before validation
        // This enables test isolation, environment-specific configs, etc.
        // Middleware can modify any delivery properties (durable, stream, queue, etc.)
        for (const mw of this.middleware) {
            if (mw.transformDelivery) {
                try {
                    await mw.transformDelivery(delivery, {
                        contract,
                        subject: finalSubject,
                        serviceName: this.serviceName,
                    });
                } catch (error) {
                    this.logger?.error(`Middleware error in transformDelivery`, {
                        middleware: mw.name,
                        error: error instanceof Error ? error.message : String(error),
                    });
                    throw error;
                }
            }
        }

        // Naming patterns: As we are currently using NATS, we follow their naming patterns
        // Stream and durable:
        // @see https://docs.nats.io/nats-concepts/jetstream/streams#configuration
        // @see https://docs.nats.io/running-a-nats-service/nats_admin/jetstream_admin/naming
        // This currently only covers streams and durable, but we use the same for queue as well
        if ("durable" in delivery) {
            this.probablyThrowErrorAboutNamingPatterns(delivery.durable, /[^A-Za-z0-9_-]/, "durable", 32);
        }
        if ("stream" in delivery) {
            this.probablyThrowErrorAboutNamingPatterns(delivery.stream, /[^A-Za-z0-9_-]/, "stream", 32);
        }
        /*if ("queue" in delivery) { // The queue is not persisted in any way, so we don't need to validate its name
            this.probablyThrowErrorAboutNamingPatterns(delivery.queue, /[^A-Za-z0-9_-]/, "queue", 32);
        }*/
        if ("deadLetterQueue" in delivery && delivery.deadLetterQueue) {
            const dlq = delivery.deadLetterQueue as Record<string, unknown>;
            // Only validate DLQ properties if DLQ is enabled (default is enabled, so check for explicit false)
            if (dlq.enabled !== false) {
                if (dlq.durable && typeof dlq.durable === "string") {
                    this.probablyThrowErrorAboutNamingPatterns(dlq.durable, /[^A-Za-z0-9_-]/, "dlq-durable", 32);
                }
                if (dlq.stream && typeof dlq.stream === "string") {
                    this.probablyThrowErrorAboutNamingPatterns(dlq.stream, /[^A-Za-z0-9_-]/, "dlq-stream", 32);
                }
            }
        }

        switch (method) {
            case "publish":
                return this.wrappedPublish(finalSubject, delivery, opts, contract);
            case "request":
                // For request+reply, we need to return the response
                if (delivery.type === "request+reply") {
                    return this.wrappedRequest(finalSubject, delivery, opts, contract);
                }
                return this.wrappedPublish(finalSubject, delivery, opts, contract);
            case "subscribe":
            case "reply":
                return this.wrappedSubscribe(finalSubject, delivery, opts, contract);
            default:
                return undefined;
        }
    }

    /**
     * Currently, the events are created side-effect free through functions. To make them fast accessible,
     * we create an index of the events by their domain, major version and subject.
     */
    private probablyCreateIndexFromEvents() {
        if (this.contracts.size > 0) {
            return;
        }

        for (const eventFunction of Object.values(this.eventFunctions)) {
            const contract = eventFunction();
            // Contract.subject already contains domain.subject.version (e.g., "orders.get.v1")
            // So we index by the full subject to support versioning
            const contractKey = contract.subject.toLowerCase();
            this.contracts.set(contractKey, contract);
        }
    }

    protected probablyThrowErrorAboutNamingPatterns(
        pattern: string,
        regex: RegExp,
        context: string,
        maxLength: number,
    ) {
        if (regex.test(pattern)) {
            throw new Error(
                `Naming pattern ${pattern} is not valid. It may only contain ${regex.source} in ${context}`,
            );
        }
        if (pattern.length > maxLength) {
            throw new Error(
                `Naming pattern ${pattern} is not valid. It may only be ${maxLength} characters long in ${context}`,
            );
        }
    }

    /**
     * Get all contracts for infrastructure initialization.
     * Concrete implementations can use this to initialize broker-specific resources
     * (e.g., streams, queues, topics) upfront based on contracts.
     *
     */
    protected getContracts(): readonly BrokerContract<any, any>[] {
        this.probablyCreateIndexFromEvents();
        return Array.from(this.contracts.values());
    }

    /**
     * Get a contract by subject. Tries to find the contract by matching the subject.
     * This is used for middleware hooks that need the contract but only have the subject.
     * Handles both unprefixed and prefixed subjects (e.g., "website.health-check.ping.v1" or "testId.website.health-check.ping.v1").
     */
    protected getContractBySubject(subject: string): BrokerContract<any, any> | undefined {
        this.probablyCreateIndexFromEvents();
        const subjectLower = subject.toLowerCase();
        // Try exact match first
        const contract = this.contracts.get(subjectLower);
        if (contract) {
            return contract;
        }
        // Try to find by matching subject pattern (in case subject was prefixed by middleware)
        // Match if subject ends with contract.subject (e.g., "testId.website.health-check.ping.v1" ends with "website.health-check.ping.v1")
        for (const [, contractValue] of this.contracts.entries()) {
            const contractSubjectLower = contractValue.subject.toLowerCase();
            if (subjectLower === contractSubjectLower || subjectLower.endsWith(`.${contractSubjectLower}`)) {
                return contractValue;
            }
        }
        return undefined;
    }
}

export { AbstractMessageBrokerClient };
export type { IContractMiddleware, IContractMiddlewareContext, IDeliveryTransformContext };
