// Typed client for the mautrix bridgev2 provisioning HTTP API
// (`/_matrix/provision/v3/*`, exposed by Caddy at bootstrap.provisioningUrl).
//
// Wire contract extracted from the bridge sources (mautrix-whatsapp
// v0.2604.0, mautrix-go v0.27.0):
//   maunium.net/go/mautrix bridgev2/matrix/provisioning.go (routes + auth)
//   bridgev2/provisionutil/{listcontacts,resolveidentifier}.go (response shapes)
//   mautrix-whatsapp pkg/connector/login.go                (flow/step/field ids)
//   mautrix-whatsapp pkg/connector/startchat.go            (identifier rules)
//
// Auth: every request carries `Authorization: Bearer openid:<token>` — an
// MSC1960 OpenID token requested from the host. The bridge validates it
// against the homeserver's federation API (AuthMiddleware →
// checkFederatedMatrixAuth) and caches the validation for an hour. The
// `user_id` query param tells the middleware whose identity to verify.

import type { OpenIdCredentials } from './widget-api';

// --- Response types --------------------------------------------------------

export type BridgeStateInfo = {
  state_event?: string;
  error?: string;
  message?: string;
  reason?: string;
};

export type WhoamiLogin = {
  id: string;
  name?: string;
  profile?: {
    phone?: string;
    email?: string;
    username?: string;
    name?: string;
    avatar?: string;
  };
  state?: BridgeStateInfo;
  space_room?: string;
};

export type LoginFlow = { id: string; name?: string; description?: string };

export type Whoami = {
  network?: { displayname?: string };
  login_flows?: LoginFlow[];
  homeserver?: string;
  bridge_bot?: string;
  command_prefix?: string;
  management_room?: string;
  logins?: WhoamiLogin[];
};

export type Contact = {
  /** Network user id — bare phone digits for regular contacts
   * (waid MakeUserID), `lid-…` for hidden-number contacts, `bot-…` for
   * WhatsApp bots. Accepted by resolve_identifier / create_dm as-is. */
  id: string;
  name?: string;
  avatar_url?: string;
  /** URI-style identifiers: `tel:+<digits>`; empty for lid-/bot- contacts
   * (connector userinfo.go contactToUserInfo). */
  identifiers?: string[];
  /** Ghost MXID (`@whatsapp_<id>:vojo.chat`). */
  mxid?: string;
  /** Existing DM portal room — present ⇒ the chat is already in Vojo. */
  dm_room_mxid?: string;
};

export type LoginInputField = {
  type: string; // phone_number | ...
  id: string; // submit-map key
  name?: string;
  description?: string;
  pattern?: string;
};

export type LoginStep = {
  /** Present on /login/start and /login/step responses (RespSubmitLogin). */
  login_id: string;
  type: 'user_input' | 'display_and_wait' | 'cookies' | 'complete';
  step_id: string;
  instructions?: string;
  user_input?: { fields: LoginInputField[] };
  display_and_wait?: {
    type: 'qr' | 'emoji' | 'code' | 'nothing';
    data?: string;
    image_url?: string;
  };
  complete?: { user_login_id?: string };
};

// WhatsApp connector constants (pkg/connector/login.go:30-31). Unlike the
// Telegram connector there are NO `.incorrect` retry step variants — every
// rejected input is a RespError that deletes the login process server-side.
export const WA_FLOW_QR = 'qr';
export const WA_FLOW_PHONE = 'phone';

// --- Errors ----------------------------------------------------------------

export class ProvisioningError extends Error {
  public readonly errcode?: string;

  public readonly httpStatus: number;

  public constructor(httpStatus: number, errcode: string | undefined, message: string) {
    super(message);
    this.name = 'ProvisioningError';
    this.httpStatus = httpStatus;
    this.errcode = errcode;
  }
}

export const isNotFound = (err: unknown): boolean =>
  err instanceof ProvisioningError && err.httpStatus === 404;

// Any 401 means «refresh the OpenID token and retry once» — matching the
// errcodes alone would silently strand flows if the bridge (or a proxy in
// front of it) ever returns a 401 with a different body.
const isAuthError = (err: unknown): boolean =>
  err instanceof ProvisioningError &&
  (err.httpStatus === 401 ||
    err.errcode === 'M_MISSING_TOKEN' ||
    err.errcode === 'M_UNKNOWN_TOKEN');

// --- Client ----------------------------------------------------------------

const DEFAULT_TIMEOUT_MS = 20_000;

// display_and_wait long-polls block server-side until the QR token rotates
// (60 s for the first, 20 s after — connector qrIntervals) or the login
// resolves — no client timeout, only caller aborts.
type RequestOptions = {
  body?: unknown;
  signal?: AbortSignal;
  /** null disables the timeout (long-poll). */
  timeoutMs?: number | null;
};

export class ProvisioningClient {
  private token: string | null = null;

  private tokenExpiresAt = 0;

  private tokenInFlight: Promise<string> | null = null;

  public constructor(
    private readonly baseUrl: string,
    private readonly userId: string,
    private readonly fetchCredentials: () => Promise<OpenIdCredentials>
  ) {}

  // -- auth plumbing --

  private getToken(force = false): Promise<string> {
    if (!force && this.token && Date.now() < this.tokenExpiresAt) {
      return Promise.resolve(this.token);
    }
    // Collapse concurrent refreshes (e.g. contacts + whoami racing on boot)
    // into one host round-trip.
    if (!this.tokenInFlight) {
      this.tokenInFlight = this.fetchCredentials()
        .then((creds) => {
          this.token = creds.accessToken;
          // Refresh a minute early so a token can't expire mid-request.
          this.tokenExpiresAt = Date.now() + Math.max(30, creds.expiresIn - 60) * 1000;
          return this.token;
        })
        .finally(() => {
          this.tokenInFlight = null;
        });
    }
    return this.tokenInFlight;
  }

  private async request<T>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
    const attempt = async (forceToken: boolean): Promise<T> => {
      const token = await this.getToken(forceToken);
      const url = new URL(`${this.baseUrl}${path}`);
      url.searchParams.set('user_id', this.userId);

      const controller = new AbortController();
      const onCallerAbort = () => controller.abort();
      opts.signal?.addEventListener('abort', onCallerAbort);
      if (opts.signal?.aborted) controller.abort();
      const timeoutMs = opts.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : opts.timeoutMs;
      const timer =
        timeoutMs === null ? null : window.setTimeout(() => controller.abort(), timeoutMs);

      try {
        const res = await fetch(url.toString(), {
          method,
          signal: controller.signal,
          headers: {
            Authorization: `Bearer openid:${token}`,
            ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
          },
          body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
        });
        let parsed: unknown;
        try {
          parsed = await res.json();
        } catch {
          parsed = undefined;
        }
        if (!res.ok) {
          const errBody = (parsed ?? {}) as { errcode?: string; error?: string };
          throw new ProvisioningError(
            res.status,
            errBody.errcode,
            errBody.error ?? `HTTP ${res.status}`
          );
        }
        return parsed as T;
      } finally {
        if (timer !== null) window.clearTimeout(timer);
        opts.signal?.removeEventListener('abort', onCallerAbort);
      }
    };

    try {
      return await attempt(false);
    } catch (err) {
      // Token expired/invalidated between cache and use — refresh once and
      // retry. Never retried for long-polls mid-flight aborts (those throw
      // AbortError, not ProvisioningError).
      if (isAuthError(err)) return attempt(true);
      throw err;
    }
  }

  // -- API surface --

  public whoami(): Promise<Whoami> {
    return this.request<Whoami>('GET', '/v3/whoami');
  }

  public async listContacts(): Promise<Contact[]> {
    const resp = await this.request<{ contacts?: Contact[] }>('GET', '/v3/contacts');
    return resp.contacts ?? [];
  }

  /** Resolve a phone number (`+7…`). Returns null when the number is not on
   * WhatsApp (the bridge answers 404 M_NOT_FOUND, startchat.go:84). */
  public async resolveIdentifier(
    identifier: string,
    signal?: AbortSignal
  ): Promise<Contact | null> {
    try {
      return await this.request<Contact>(
        'GET',
        `/v3/resolve_identifier/${encodeURIComponent(identifier)}`,
        { signal }
      );
    } catch (err) {
      if (isNotFound(err)) return null;
      throw err;
    }
  }

  /** Resolve + ensure the DM portal room exists. `dm_room_mxid` in the
   * response is the room to open. */
  public createDm(identifier: string): Promise<Contact> {
    return this.request<Contact>('POST', `/v3/create_dm/${encodeURIComponent(identifier)}`, {
      // Portal creation = room create + initial sync on the bridge side;
      // give it more headroom than a plain GET.
      timeoutMs: 45_000,
    });
  }

  public loginStart(flowId: string): Promise<LoginStep> {
    return this.request<LoginStep>('POST', `/v3/login/start/${encodeURIComponent(flowId)}`, {
      // Phone flow start is instant, QR start connects to WhatsApp and
      // waits for whatsmeow's first QR batch (≤15 s connect deadline in the
      // connector, login.go LoginConnectWait) — allow for a slow handshake.
      timeoutMs: 45_000,
    });
  }

  public loginSubmitInput(
    loginId: string,
    stepId: string,
    fields: Record<string, string>
  ): Promise<LoginStep> {
    return this.request<LoginStep>(
      'POST',
      `/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(stepId)}/user_input`,
      // The phone submit connects to WhatsApp and requests a pairing code
      // (PairPhone IQ round-trip) before responding.
      { body: fields, timeoutMs: 45_000 }
    );
  }

  /** Long-poll a display_and_wait step (QR or pairing code). Resolves with
   * the next step: a rotated QR, or complete. The 200 s cap sits above every
   * legitimate server-side wait (QR rotations ≤60 s, the whole whatsmeow
   * pairing window is 160 s) — hitting it means the server-side handler is
   * wedged (stale lock on an old bridge); the wait loop folds the resulting
   * AbortError into its retry path, which converges to an authoritative
   * answer. */
  public loginWait(loginId: string, stepId: string, signal: AbortSignal): Promise<LoginStep> {
    return this.request<LoginStep>(
      'POST',
      `/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(
        stepId
      )}/display_and_wait`,
      { signal, timeoutMs: 200_000 }
    );
  }

  /** Best-effort: mautrix v0.27.0 (what mautrix-whatsapp v26.04 pins) has NO
   * cancel route — this 404s there and works on newer bridges. The real
   * cancellation path is aborting the display_and_wait long-poll: the
   * server's Wait sees the context cancel and deletes the login process.
   * Callers must swallow rejections. */
  public loginCancel(loginId: string): Promise<void> {
    return this.request<void>('POST', `/v3/login/cancel/${encodeURIComponent(loginId)}`);
  }

  public logout(loginId: string): Promise<void> {
    return this.request<void>('POST', `/v3/logout/${encodeURIComponent(loginId)}`);
  }
}

// --- Identifier helpers (shared by contacts search + probe) -----------------

/** Loose phone shape — digits with optional separators. Normalised to
 * `+<digits>` before hitting the API. WhatsApp has no usernames — phone
 * numbers are the ONLY probe-able identifier (connector validateIdentifer
 * rejects anything with letters as «looks like email»). */
export const PHONE_RE = /^\+?[\d\s\-()]{7,20}$/;

export type ProbeIdentifier = { kind: 'phone'; value: string; display: string };

export const detectIdentifier = (raw: string): ProbeIdentifier | null => {
  const trimmed = raw.trim();
  if (!trimmed) return null;
  if (PHONE_RE.test(trimmed)) {
    const digits = trimmed.replace(/[^\d]/g, '');
    if (digits.length >= 7) {
      return { kind: 'phone', value: `+${digits}`, display: `+${digits}` };
    }
  }
  return null;
};

/** Pull the `+phone` display string out of a contact's URI-style
 * identifiers (`tel:+…`). lid-/bot- contacts have none. */
export const contactHandles = (contact: Contact): { phone?: string } => {
  let phone: string | undefined;
  for (const id of contact.identifiers ?? []) {
    if (id.startsWith('tel:')) phone = id.slice('tel:'.length);
  }
  return { phone };
};
