import type { BrokerBroadcastGuarantee, BrokerBroadcastNoGuarantee } from "./broadcast.js";
import type { BrokerPointToPointGuarantee, BrokerPointToPointNoGuarantee } from "./point-to-point.js";
import type { BrokerRequestReply } from "./request.js";

const BROKER_PURPOSE_PLACEHOLDER = "{purpose}";
const BROKER_DURABLE_PLACEHOLDER = "{durable}";

/**
 * See `createBrokerContract` for more information.
 */
type BrokerContract<
    MajorVersion extends Readonly<BrokerContractVersion>,
    Domain extends Readonly<string>,
    Subject extends BrokerSubjectTemplate<string> = BrokerSubjectTemplate<string>,
    Delivery extends Readonly<BrokerContractDelivery> = BrokerContractDelivery,
    Message extends BrokerContractMessage<BrokerJsonSchema, BrokerJsonSchema> = BrokerContractMessage<
        BrokerJsonSchema,
        BrokerJsonSchema
    >,
> = {
    majorVersion: MajorVersion;
    domain: Domain;
    subject: Subject["subject"];
    delivery: BrokerContractDeliveryFulfilled<Delivery>;
    message: Message;
    /**
     * This property is never exposed by the returned object, it is just for getting the type.
     */
    types?: {
        subject: Subject;
        message: Message;
        response: Delivery extends BrokerRequestReply ? Delivery["response"]["response"] : never;
        ttlMsSeenKey: Delivery["effectivelyOnce"] extends `${string}subscriber${string}` ? number : unknown;
        retention: Delivery extends BrokerRetention ? Delivery["retention"] : "limits";
    };
};

/**
 * Creates a contract that defines the structure of a message broker event.
 * The contract specifies the subject, data schema, headers schema, and response type,
 * but does not contain any implementation logic.
 *
 * In general, as message brokerage can be very complex to developers, this contract-first
 * implementation tries to name the different mechanics and patterns to be more "human readable"
 * and framework agnostic (the developer does not need to know which message broker is used under the hood).
 *
 * Terminology:
 *
 * - **Publisher**: The one who sends the message
 * - **Subscriber**: The one who receives the message
 * - **Broker**: The infrastructure that routes and delivers messages between publishers and subscribers (e.g. NATS Core, JetStream, etc.)
 * - **Stream**: A persistent log of messages that can be replayed (e.g. JetStream streams)
 *    - **Durable**: A named subscription that persists its position in the stream even when the subscriber goes offline. For example, in a microservice
 *                   architecture where an "email-service" (durable) subscribes to "user.created" events, if the email service restarts or crashes, a durable subscription
 *                   ensures it can resume processing from exactly where it left off, preventing missed welcome emails. Without durability, any events published
 *                   while the service was down would be lost.
 * - **Queue**: A temporary storage for messages to ensure load balancing among multiple subscribers (e.g. NATS Core queues)
 * - **Message**: The actual data that is sent between publishers and subscribers
 *    - **Subject**: A subject (like an email subject) that is used to target the event from within the publisher and consumer. Can contain wildcards and variables.
 *    - **Broadcast**: A message that is sent to all subscribers (i.e., all replicas of the same service)
 *    - **Point-to-point**: A message that is sent to a single subscriber / subscriber group (i.e., a single replica of the same service)
 *    - **Purpose**: A purpose for a durable subscription to be able to distinguish between different subscriptions/use cases from the same service (e.g. "send-email", "send-sms", "analytics", etc.).
 *                   It explains the reason for the subscription and enables distinct processing paths. For queue groups, different purposes create separate queue groups, allowing multiple use cases to receive all messages independently.
 *    - **Request-Reply**: A message that is sent to a single subscriber and the subscriber sends a response
 * - **No Guarantee** / **At-Most-Once**: The message is delivered at most once to a subscriber (zero or one delivery, e.g. NATS Core)
 * - **Guarantee** / **At-Least-Once**: The message is delivered at least once to a subscriber (one or more deliveries, e.g. JetStream)
 * - **Effectively Once**: A mechanism to ensure that a message is delivered only once to a subscriber
 *    - **None**: No guarantee that the message is delivered only once
 *    - **No Guarantee**: The publisher and subscriber are responsible for ensuring that the message is delivered only once
 *    - **Guarantee**: The publisher, broker, and subscriber are responsible for ensuring that the message is delivered only once
 *
 * How to get started? Always start with the `type` and `effectivelyOnce` properties as the are the discriminator properties.
 * Afterward, you can add the other properties.
 *
 * **Note:** Every type is well-documented with examples. You can use VSCode intellisense to get more information.
 */
function createBrokerContract<
    MajorVersion extends Readonly<BrokerContractVersion>,
    Domain extends Readonly<string>,
    Subject extends Readonly<string>,
    Delivery extends BrokerContractDelivery,
    Message extends BrokerContractMessage<BrokerJsonSchema, BrokerJsonSchema>,
>({
    majorVersion,
    subject,
    domain,
    delivery,
    message,
}: {
    /**
     * Version of the contract. We do not follow semantic versioning here as the message broker is not
     * published to the public and semver is not compatible with the message broker.
     *
     * We do not versionize the stream, but queue, durable and event: We apply versioning consistently across subjects,
     * queues, and durables. Each major version represents a distinct contract. In general, breaking changes should be avoided,
     * but if one occurs, we treat it as an entirely new contract. After such a change, only the new version will be published.
     * Consumers may still keep older versions active for a limited period to process existing events, after which those versions are phased out.
     * Also worth mentioning, the versioning can never get inconsistent as the contract defines the major version.
     *
     * Example: "v1", "v2", etc. will lead to subject names like "my-app.v1.user-created" and "my-app.v2.user-created".
     *
     * @see https://event-driven.io/en/how_to_do_event_versioning/
     * @see https://theburningmonk.com/2025/04/event-versioning-strategies-for-event-driven-architectures/
     * @see https://stackoverflow.com/questions/72585165/nats-jetstream-naming-conventions-for-commands-subjects
     */
    majorVersion: MajorVersion;
    /**
     * Bounded context for the contract. Do not use your app name here:
     *
     * - Domain Stability: Streams/Subjects outlast rewrites, repo splits, tech stacks.
     *   Service names change more frequently than domains.
     * - Decoupling: Multiple backends can serve the same domain (or one backend multiple domains).
     *   Service names in Subject/Stream create unnecessary coupling.
     * - Migrations: During renaming/refactoring, you would otherwise have to rewire ACLs, bindings,
     *   dashboards, and subscriptions.
     *
     * Example: "orders", "users", "products", etc.
     */
    domain: Domain;
    /**
     * The subject of the message with wildcards support.
     *
     * Example: "user.created", "user.{{id}}.updated", "user.{{id}}.deleted", etc.
     */
    subject: Subject;
    message: Message;
    delivery: Delivery;
}): BrokerContract<MajorVersion, Domain, BrokerSubjectTemplate<Subject>, Delivery, Message> {
    const { type: deliveryType, effectivelyOnce } = delivery;

    // Set default values
    switch (deliveryType) {
        case "broadcast+no-guarantee":
            delivery.queue ??= `q_${domain}_${BROKER_DURABLE_PLACEHOLDER}_${BROKER_PURPOSE_PLACEHOLDER}_${majorVersion}`;
            break;
        case "broadcast+guarantee":
            delivery.retention ??= "limits";
            delivery.deliverPolicy ??= "new";
            delivery.ttlMsOnStream ??= 60 * 60 * 1000;
            delivery.stream ??= `${domain}_events`.toUpperCase();
            delivery.ackPolicy ??= "explicit+wait";
            delivery.durable ??= `c_${domain}_${BROKER_DURABLE_PLACEHOLDER}_${BROKER_PURPOSE_PLACEHOLDER}_${majorVersion}`;
            delivery.stickyDurable ??= true;
            break;
        case "point-to-point+guarantee": {
            delivery.retention ??= "limits";
            delivery.ackWaitMs ??= 30_000;
            delivery.maxDeliver ??= 10;
            delivery.maxAckPending ??= 1000;
            delivery.stream ??= `${domain}_work`.toUpperCase();
            delivery.durable ??=
                delivery.retention === "workqueue"
                    ? `c_${domain}_${BROKER_DURABLE_PLACEHOLDER}_${majorVersion}`
                    : `c_${domain}_${BROKER_DURABLE_PLACEHOLDER}_${BROKER_PURPOSE_PLACEHOLDER}_${majorVersion}`;
            delivery.ackPolicy ??= "explicit+wait";
            // Merge deadLetterQueue defaults properly: user-provided values override defaults
            // If user explicitly sets enabled: false, it will be respected (not overridden to true)
            const existingDlq = delivery.deadLetterQueue ?? {};
            delivery.deadLetterQueue = {
                enabled: existingDlq.enabled ?? true,
                ttlMs: existingDlq.ttlMs ?? 7 * 24 * 60 * 60 * 1000, // 7 days default
                durable: existingDlq.durable ?? `${delivery.durable}_dlq`,
                stream: existingDlq.stream ?? `${delivery.stream}_DLQ`,
            };
            break;
        }
        case "point-to-point+no-guarantee":
            delivery.queue ??= `q_${domain}_${BROKER_DURABLE_PLACEHOLDER}_${BROKER_PURPOSE_PLACEHOLDER}_${majorVersion}`;
            break;
        case "request+reply":
            delivery.timeoutMs ??= 2_000;
            break;
        default:
            break;
    }
    if (effectivelyOnce !== "none") {
        delivery.msgIdHeader ??= "Broker-Msg-Id";
        delivery.ttlMsSeenKey ??= 1000 * 60 * 60 * 24 * 7; // 7 days
        if (effectivelyOnce === "publisher+broker+subscriber") {
            delivery.dedupeWindowMs ??= 1000 * 60 * 10; // 10 minutes
        }
    }

    return {
        majorVersion,
        subject: `${domain}.${subject}.${majorVersion}`.toLowerCase(),
        domain,
        delivery: delivery as unknown as BrokerContractDeliveryFulfilled<Delivery>,
        message,
    };
}

/**
 * Create a response from and for a broker contract.
 *
 * This is morely a workaround as TypeScript does currently not support partial generic types with infer-support.
 *
 * @see https://github.com/microsoft/TypeScript/issues/26242
 */
class BrokerContractMessage<Data extends BrokerJsonSchema, Headers extends BrokerJsonSchema = BrokerJsonSchema> {
    public readonly data: Data;
    public readonly headers: Headers & {
        [key in BrokerMsgIdHeader]: string;
    };
}

type BrokerContractVersion = `v${number}`;

type BrokerSubjectTemplate<T extends string> = T extends `${infer Before}{{${infer Param}}}${infer After}`
    ? {
          subject: T;
          template: `${Before}${string}${BrokerSubjectTemplate<After> extends { template: infer Rest extends string } ? Rest : After}`;
          params: { [K in Param]: string } & (BrokerSubjectTemplate<After> extends { params: infer RestParams }
              ? RestParams
              : // eslint-disable-next-line @typescript-eslint/no-empty-object-type
                {});
      }
    : { subject: T; template: T; params?: unknown };

type BrokerContractDelivery =
    | BrokerBroadcastNoGuarantee
    | BrokerBroadcastGuarantee
    | BrokerPointToPointNoGuarantee
    | BrokerPointToPointGuarantee
    | BrokerRequestReply;

type BrokerContractFulfilled = Required<BrokerContractDelivery>;

type BrokerContractDeliveryFulfilled<Delivery extends BrokerContractDelivery = BrokerContractDelivery> = Required<
    Delivery extends BrokerBroadcastNoGuarantee
        ? BrokerBroadcastNoGuarantee
        : Delivery extends BrokerBroadcastGuarantee
          ? BrokerBroadcastGuarantee
          : Delivery extends BrokerPointToPointNoGuarantee
            ? BrokerPointToPointNoGuarantee
            : Delivery extends BrokerPointToPointGuarantee
              ? BrokerPointToPointGuarantee
              : Delivery extends BrokerRequestReply
                ? BrokerRequestReply
                : never
>;

/**
 * Header name for the message id. Every message needs to be deterministic and unique, and should not be
 * calculated randomly (e.g. using `Math.random()` or `crypto.randomUUID()`).
 */
type BrokerMsgIdHeader = "Broker-Msg-Id";

type BrokerJsonSchema = Record<string, unknown>;

interface BrokerRetention {
    /**
     * JetStream stream retention policy (what determines when stored messages are eligible for deletion).
     *
     * - `limits`: Keep messages until stream limits are exceeded (e.g. max age, max bytes, max messages), then discard oldest.
     *             Use for durable event logs / replay / backfills. Works well even with multiple consumer groups.
     *
     * - `interest`: Keep messages only while there is active interest.
     *               Messages are removed once they are no longer needed by any *durable* consumer (i.e. all relevant
     *               durable consumers have acknowledged / advanced past them). Useful when you want storage to track
     *               consumer progress, but it couples retention to consumer lifecycle.
     *
     * - `workqueue`: Work-queue semantics. Each message is intended to be processed by exactly one consumer group.
     *                Messages are removed as soon as they are acknowledged by that group. Use for task dispatch
     *                (commands/jobs), not for business events you may want to replay.
     *
     * Notes:
     * - Redelivery/timeout behavior is configured on the *consumer* (e.g. `ack_wait`, `max_deliver`, `max_ack_pending`),
     *   not on the stream.
     * - With multiple instances: scale-out is typically done via a single durable consumer group (queue group) per
     *   processing stage.
     *
     * @default "limits"
     */
    retention?: "limits" | "interest" | "workqueue";
}

interface BrokerDeliverPolicy {
    /**
     * Deliver policy for a message stream which can be replayed.
     *
     * - `all`: Deliver all messages
     * - `new`: Deliver only new messages
     * - `last`: Deliver only the last message
     *
     * @default "new"
     */
    deliverPolicy?: "all" | "new" | "last";
}

interface BrokerAckPolicy {
    /**
     * Ack policy for a message stream which can be replayed.
     *
     * - `explicit`: The subscriber acks the message to the broker upon delivery before processing the message
     * - `explicit+wait`: The subscriber acks the message to the broker upon delivery after successfully processing
     *                    the message and waits for the broker to submit the ack to the publisher, otherwise it `nacks`
     *                    the message and a redelivery is triggered.
     * - `none`: The subscriber does not ack the message
     * - `auto`: The subscriber automatically acks the message through the broker upon successful delivery.
     *           Practically, it is the `all` variant in NATS JetStream.
     *
     * @default "explicit+wait"
     */
    ackPolicy?: "explicit" | "explicit+wait" | "none" | "auto";
}

type InferBrokerContract<CreateContractFunction extends (...args: any[]) => BrokerContract<any, any>> =
    ReturnType<CreateContractFunction>;

type InferBrokerContractMessageData<CreateContractFunction extends (...args: any[]) => BrokerContract<any, any>> =
    InferBrokerContract<CreateContractFunction>["types"]["message"]["data"];

export {
    BROKER_PURPOSE_PLACEHOLDER,
    BROKER_DURABLE_PLACEHOLDER,
    createBrokerContract,
    BrokerContractMessage,
    type BrokerContract,
    type BrokerContractDeliveryFulfilled,
    type BrokerContractDelivery,
    type BrokerContractVersion,
    type BrokerSubjectTemplate,
    type BrokerMsgIdHeader,
    type BrokerContractFulfilled,
    type BrokerJsonSchema,
    type BrokerRetention,
    type BrokerDeliverPolicy,
    type BrokerAckPolicy,
    type InferBrokerContract,
    type InferBrokerContractMessageData,
};
