import type { Transport, FlushableTransport } from './transport/base';
import { getLegacyIngressTopic, getMxIngressTopic } from './topics';
import {
  createLegacyRequest,
  serializeLegacyEnvelope,
  deserializeTaggedEnvelope,
  createMxEnvV1,
  serializeMxEnvV1,
  generateCorrelationId,
} from './envelope_codec';
import type { ReplyEnvelopeLegacy, MxEnvelopeV1 } from './envelope';
import { WIRE_PROFILES, type WireProfile } from './wire_profile';
import { parseMatrixUri } from './uri';
import { isRecord } from './util/guards';

function isFlushable(t: Transport): t is FlushableTransport {
  return typeof (t as FlushableTransport).flushSubscriptions === 'function';
}

export interface CrossRealmClientOptions {
  localRoot: string;
  transport: Transport;
  profile?: WireProfile;
  defaultTimeoutMs?: number;
  shard?: string;  // Shard identifier, default '00'
  /** @deprecated Use localRoot */
  localRealm?: string;
}

interface Pending {
  resolve: (value: unknown) => void;
  reject: (reason: unknown) => void;
  timer: ReturnType<typeof setTimeout>;
}

export class CrossRealmClient {
  private readonly localRoot: string;
  private readonly transport: Transport;
  private readonly profile: WireProfile;
  private readonly defaultTimeoutMs: number;
  private readonly _shard: string;

  private started = false;
  private unsubscribeFns: (() => void)[] = [];
  private readyPromise: Promise<void> | null = null;

  private pending: Map<string, Pending> = new Map(); // correlationId -> pending

  constructor(opts: CrossRealmClientOptions) {
    this.localRoot = opts.localRoot ?? opts.localRealm!;
    this.transport = opts.transport;
    this.profile = opts.profile ?? WIRE_PROFILES.mxenv1;
    this.defaultTimeoutMs = opts.defaultTimeoutMs ?? 30000;
    this._shard = opts.shard ?? '00';
  }

  start(): void {
    if (this.started) return;
    this.started = true;

    console.log(`[XRealm] Starting: ${this.localRoot}`);

    if (this.profile.subscribeLegacyIngress) {
      const t = getLegacyIngressTopic(this.localRoot);
      this.unsubscribeFns.push(this.transport.subscribe(t, (topic, payload) => this.onIngress(topic, payload)));
    }
    if (this.profile.subscribeMxIngress) {
      const t = getMxIngressTopic(this.localRoot, this._shard);
      this.unsubscribeFns.push(this.transport.subscribe(t, (topic, payload) => this.onIngress(topic, payload)));
    }

    // CRITICAL: Wait for subscriptions to be confirmed (SUBACK) before allowing publish.
    if (isFlushable(this.transport)) {
      this.readyPromise = this.transport.flushSubscriptions().then(() => {
        console.log(`[XRealm] Ready (SUBACK received)`);
      });
    } else {
      this.readyPromise = Promise.resolve();
    }
  }

  stop(): void {
    for (const u of this.unsubscribeFns.splice(0)) u();
    this.started = false;
  }

  async invoke<T = unknown>(toRoot: string, toPath: string, body: unknown, timeoutMs?: number): Promise<T> {
    this.start();

    // CRITICAL: Wait for subscriptions to be confirmed before publishing.
    // This prevents race conditions where the reply arrives before we're subscribed.
    if (this.readyPromise) {
      await this.readyPromise;
    }

    const correlationId = generateCorrelationId();
    const replyToPath = `_replies.emits.${correlationId}`;

    const tmo = timeoutMs ?? this.defaultTimeoutMs;

    const p = new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pending.delete(correlationId);
        reject(new Error(`timeout waiting for correlationId=${correlationId}`));
      }, tmo);

      // Store untyped resolvers; we cast at the edge.
      this.pending.set(correlationId, {
        resolve: (v: unknown) => resolve(v as T),
        reject,
        timer,
      });
    });

    // Encode outbound based on profile.
    // IMPORTANT: if publish throws synchronously (e.g., transport down), clean up the pending promise.
    console.log(`[XRealm] -> ${toRoot}/${toPath}`);

    // TRACE: Show envelope creation
    const trace = typeof window !== 'undefined' && (window as any).__MATRIX_TRACE;
    if (trace) {
      console.log(`[TRACE:L3:ENV] CrossRealmClient.invoke:`);
      console.log(`[TRACE:L3:ENV]   from root: ${this.localRoot}`);
      console.log(`[TRACE:L3:ENV]   to root: ${toRoot}`);
      console.log(`[TRACE:L3:ENV]   to path: ${toPath}`);
      console.log(`[TRACE:L3:ENV]   correlationId: ${correlationId}`);
      console.log(`[TRACE:L3:ENV]   replyTo: ${replyToPath}`);
    }

    try {
      if (this.profile.encode === 'matrix3') {
        const req = createLegacyRequest({
          toRoot,
          toPath,
          fromRoot: this.localRoot,
          replyTo: replyToPath,
          correlationId,
          body,
          type: 'request',
        });
        const ingress = getLegacyIngressTopic(toRoot);
        this.transport.publish(ingress, serializeLegacyEnvelope(req));
      } else {
        const env = createMxEnvV1({
          msgType: 'Cmd',
          toRoot,
          toPath,
          fromRoot: this.localRoot,
          fromPath: '_client',
          replyToRoot: this.localRoot,
          replyToPath,
          correlationId,
          body,
        });
        const ingress = getMxIngressTopic(toRoot, '00');
        if (trace) {
          console.log(`[TRACE:L2:TOPIC] Publishing to mailbox: ${ingress}`);
          console.log(`[TRACE:L1:WIRE] PUBLISH -> ${ingress}`);
        }
        this.transport.publish(ingress, serializeMxEnvV1(env));
        // Local-broker compatibility: some deployments do not consume mx/in ingress locally.
        // Publish the equivalent direct mailbox envelope so same-broker cross-root calls still resolve.
        this.publishLocalMailboxFallback(toRoot, toPath, body, correlationId);
      }
    } catch (err) {
      const pend = this.pending.get(correlationId);
      if (pend) {
        clearTimeout(pend.timer);
        this.pending.delete(correlationId);
        // Do NOT reject: publish threw, and the promise `p` was never returned.
        // Rejecting would cause an unhandledRejection.
      }
      throw err;
    }

    return p;
  }

  async invokeUri<T = unknown>(uri: string, body: unknown, timeoutMs?: number): Promise<T> {
    const parsed = parseMatrixUri(uri);
    if (!parsed) throw new Error(`invalid matrix uri: ${uri}`);
    const toRoot = parsed.root ?? this.localRoot;
    const toPath = parsed.toPath;

    // Merge query defaults with explicit body if both are objects.
    // Body wins.
    let mergedBody: unknown = body;
    if (parsed.query && Object.keys(parsed.query).length > 0) {
      if (isRecord(body)) {
        mergedBody = { ...parsed.query, ...body };
      } else if (body == null) {
        mergedBody = parsed.query;
      }
    }

    return this.invoke<T>(toRoot, toPath, mergedBody, timeoutMs);
  }

  private onIngress(_topic: string, payload: string): void {
    console.log(`[XRealm] <- reply on ${_topic.substring(0, 40)}`);
    const tagged = deserializeTaggedEnvelope(payload);
    if (!tagged) {
      // Accept direct request/reply envelopes on ingress for local-broker compatibility.
      if (this.tryHandleDirectReply(payload)) {
        return;
      }
      console.error(`[XRealm] Failed to deserialize`);
      return;
    }

    if (tagged.profile === 'matrix3') {
      if (tagged.kind !== 'reply') return;
      const rep = tagged.envelope as ReplyEnvelopeLegacy;
      this.resolvePending(rep.correlationId, rep.ok, rep.body, rep.error?.message);
      return;
    }

    // mxenv1
    const env = tagged.envelope as MxEnvelopeV1;
    if (env.msgType !== 'Result' && env.msgType !== 'Nack') return;
    const cid = env.correlationId;
    if (!cid) return;

    this.resolvePending(cid, !(env.ok === false || env.msgType === 'Nack'), env.body, env.error?.message);
  }

  private publishLocalMailboxFallback(
    toRoot: string,
    toPath: string,
    body: unknown,
    correlationId: string,
  ): void {
    const acceptsMarker = '.accepts.';
    const markerIdx = toPath.indexOf(acceptsMarker);
    if (markerIdx <= 0) return;

    const mount = toPath.slice(0, markerIdx);
    const op = toPath.slice(markerIdx + acceptsMarker.length);
    if (!mount || !op) return;

    const inboxTopic = `${mount}/$inbox`;
    const replyIngressTopic = getMxIngressTopic(this.localRoot, this._shard);
    const envelope = {
      op,
      payload: body ?? {},
      correlationId,
      replyTo: replyIngressTopic,
    };

    this.transport.publish(inboxTopic, JSON.stringify(envelope));
  }

  private tryHandleDirectReply(payload: string): boolean {
    try {
      const parsed = JSON.parse(payload) as {
        correlationId?: string;
        ok?: boolean;
        result?: unknown;
        body?: unknown;
        error?: { message?: string } | string;
      };

      if (!parsed || typeof parsed !== 'object' || !parsed.correlationId) {
        return false;
      }
      if (typeof parsed.ok !== 'boolean') {
        return false;
      }

      const errorMessage =
        typeof parsed.error === 'string'
          ? parsed.error
          : parsed.error?.message;

      this.resolvePending(
        parsed.correlationId,
        parsed.ok,
        parsed.result ?? parsed.body,
        errorMessage
      );
      return true;
    } catch {
      return false;
    }
  }

  private resolvePending(correlationId: string, ok: boolean, body: unknown, errorMessage?: string): void {
    const pend = this.pending.get(correlationId);
    if (!pend) return;

    clearTimeout(pend.timer);
    this.pending.delete(correlationId);

    if (ok) {
      pend.resolve(body);
      return;
    }

    pend.reject(new Error(errorMessage ?? 'unknown error'));
  }
}
