import { useMemo } from 'react';
import { useClientConfig } from '../../hooks/useClientConfig';
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';

// A bot's experience is a discriminated union on `type`:
//  - 'matrix-widget' — a sandboxed iframe widget (the bridge bots). Carries the widget url, the
//    command prefix, and the elevated-capability allowlist.
//  - 'ai-chat' — the native, fully in-client ChatGPT-style conversation surface (AiBotChat). No
//    widget, so no url/capabilities to carry or validate. Only first-party assistant bots use it.
// The two experiences share NO runtime pipeline: 'ai-chat' never touches BotShell / the widget
// embed / the show-chat toggle, and 'matrix-widget' never touches the conversation surface.
export type BotWidgetExperience = {
  type: 'matrix-widget';
  url: string;
  /** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
   * Resolved with the bridgev2 default `!tg` when the operator omits it. */
  commandPrefix: string;
  /** Elevated host verbs this bot is allowed to drive via the
   * `io.vojo.bot-widget` side-channel. Always an array (never `undefined`) so
   * the runtime gate `capabilities.includes(...)` is well-defined — see
   * `normalizeBotCaps`. Filtered against `KNOWN_BOT_CAPS` at load; absent ⇒ `[]`. */
  capabilities: string[];
  /** Base URL of the bridge's provisioning HTTP API (bridgev2 `/_matrix/provision`
   * mount behind the reverse proxy). Forwarded to the widget as a URL param so it
   * can drive the bridge over JSON instead of bot text commands. Absent when the
   * operator hasn't exposed the API; the widget then renders a config-required
   * notice. Validated by `normalizeProvisioningUrl` (https-only in prod). */
  provisioningUrl?: string;
};

export type AiChatExperience = {
  type: 'ai-chat';
};

export type BotExperience = BotWidgetExperience | AiChatExperience;

/** True when the bot is rendered through the sandboxed iframe widget pipeline (bridges). */
export const isWidgetExperience = (
  experience: BotExperience | undefined
): experience is BotWidgetExperience => experience?.type === 'matrix-widget';

/** True when the bot uses the native in-client conversation surface (no widget). */
export const isAiChatExperience = (
  experience: BotExperience | undefined
): experience is AiChatExperience => experience?.type === 'ai-chat';

/** Elevated side-channel verb: lets the widget ask the host to invite the bot
 * (`preset.mxid`, host-substituted) into a user-picked room. The widget never
 * supplies the room or the mxid. Privilege originates from the trusted
 * config.json opt-in, NOT from the widget claiming it. */
export const BOT_CAP_ADD_TO_CHAT = 'vojo.add_to_chat';

/** Grants the widget MSC1960 OpenID credentials (BotWidgetDriver.askOpenID).
 * The OpenID token only proves «this is @user:vojo.chat» to third parties via
 * the federation API — it is NOT a Matrix access token and cannot read or send
 * anything on the homeserver. Widgets use it as the bearer credential for the
 * bridge provisioning API (`openid:` auth in bridgev2 AuthMiddleware). */
export const BOT_CAP_OPENID = 'vojo.openid';

const KNOWN_BOT_CAPS: ReadonlySet<string> = new Set([BOT_CAP_ADD_TO_CHAT, BOT_CAP_OPENID]);

export type BotPreset = {
  /** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
  id: string;
  /** Bot user mxid. The DM with this user IS the bot's control room. */
  mxid: string;
  name: string;
  /** Optional operator override of the localized default. When present takes
   * precedence over the i18n key `Bots.description.<id>`. Resolved at the
   * consumer (see `useBotDescription`). */
  description?: string;
  experience?: BotExperience;
};

const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
const MXID_RE = /^@[^:\s]+:[^\s]+$/;

// Defense-in-depth allowlist of widget origins acceptable in production. The
// BotWidgetDriver capability allowlist (M9) already tightly scopes what a
// widget can do — only m.text/m.notice in the current control DM, no media,
// no OpenID, no cross-room. This origin pin is an additional layer that
// catches operator-config typos or a poisoned config.json that points the
// iframe at an unrelated host. Add new entries when onboarding new widget
// hosts; the dev branch below bypasses this check for `http://localhost:*`.
const PROD_WIDGET_ORIGINS: ReadonlySet<string> = new Set(['https://widgets.vojo.chat']);

// bridgev2's Telegram connector ships `!tg` as DefaultCommandPrefix
// (mautrix/telegram pkg/connector/connector.go:68). Operators can override via
// `bridge.command_prefix` in the mautrix-telegram config; in that case they
// must mirror the override in /config.json so the widget prepends the right
// prefix to every outbound command.
const DEFAULT_BOT_COMMAND_PREFIX = '!tg';

// Reject whitespace and empties — the prefix is concatenated to outbound
// command bodies as `<prefix> <args>`, and bridgev2 strips exactly
// `<prefix>+" "` (queue.go:118). Whitespace inside the prefix would break the
// strip and route the message to the unknown-command fallback.
const COMMAND_PREFIX_RE = /^\S+$/;

const normalizeCommandPrefix = (raw: unknown): string | undefined => {
  if (raw === undefined) return DEFAULT_BOT_COMMAND_PREFIX;
  if (typeof raw !== 'string') return undefined;
  const trimmed = raw.trim();
  if (!COMMAND_PREFIX_RE.test(trimmed)) return undefined;
  return trimmed;
};

// Validate `experience.capabilities` against the known allowlist. Unknown
// strings are dropped (an operator typo or a future cap the host doesn't
// implement must never reach the runtime gate). ALWAYS returns an array —
// never `undefined` — so `preset.experience.capabilities.includes(...)` is a
// live boolean rather than a no-op on `undefined?.includes` (F2: the three
// return literals below have no spread, so the field must be set explicitly in
// each, and a default of `undefined` would silently disarm the gate). Dedupes
// so a doubled config entry can't skew anything downstream.
const normalizeBotCaps = (raw: unknown): string[] => {
  if (!Array.isArray(raw)) return [];
  return [
    ...new Set(raw.filter((c): c is string => typeof c === 'string' && KNOWN_BOT_CAPS.has(c))),
  ];
};

// Validate the operator-supplied provisioning API base URL. Unlike the widget
// URL this is NOT loaded into an iframe — it's a fetch target inside the
// widget — so there is no origin allowlist; https (or localhost http in dev)
// with no embedded credentials is the bar. Invalid values drop to `undefined`
// (the widget renders its config-required notice) instead of rejecting the
// whole experience, so a typo here can't take the login UI down with it.
// Trailing slashes are stripped so the widget can join paths naively.
const normalizeProvisioningUrl = (raw: unknown): string | undefined => {
  if (typeof raw !== 'string') return undefined;
  const trimmed = raw.trim();
  if (!trimmed) return undefined;
  try {
    const parsed = new URL(trimmed);
    if (parsed.username || parsed.password) return undefined;
    const devLocalhost =
      import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost';
    if (parsed.protocol !== 'https:' && !devLocalhost) return undefined;
    return parsed.toString().replace(/\/+$/, '');
  } catch {
    return undefined;
  }
};

const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
  typeof preset?.id === 'string' &&
  BOT_ID_RE.test(preset.id) &&
  typeof preset.mxid === 'string' &&
  MXID_RE.test(preset.mxid) &&
  typeof preset.name === 'string' &&
  preset.name.trim().length > 0;

const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => {
  const type = experience?.type;
  // Native AI chat: no widget url / capabilities to validate, rendered fully in-client.
  if (type === 'ai-chat') return { type: 'ai-chat' };
  const url = experience?.url?.trim();
  if (type !== 'matrix-widget' || !url) return undefined;
  if (url.startsWith('//')) return undefined;

  // Reject the whole experience block when commandPrefix is present but
  // malformed — falling back to the default would silently mask an operator
  // typo in /config.json that the widget then can't recover from at runtime.
  const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix);
  if (commandPrefix === undefined) return undefined;

  // Allowlisted at load so `preset.experience.capabilities` only ever holds
  // known caps; the host gate (BotWidgetEmbed) trusts this value, so the
  // filtering MUST happen here, not at the UI layer. Added to every return
  // literal below (no spread — F2).
  const capabilities = normalizeBotCaps(experience?.capabilities);

  const provisioningUrl = normalizeProvisioningUrl(experience?.provisioningUrl);

  if (url.startsWith('/')) {
    // Resolve once so `/widgets/../admin` collapses before the prefix check —
    // a relative `/widgets/...` survives `new URL(url, base)` only if it does
    // not escape its own segment. Same-origin widgets share the parent's
    // localStorage/cookies under `allow-same-origin`, so /widgets/ is the only
    // path the operator config is allowed to mount.
    //
    // Path MUST resolve to a concrete file: the last segment must contain a
    // dot (e.g. /widgets/foo/index.html, /widgets/foo.js). Bare directory
    // paths or extensionless paths (/widgets/foo, /widgets/foo/) get caught
    // by SPA fallback in Vite dev and any history-fallback static server,
    // which would then serve the parent app's index.html into the iframe —
    // recursive Vojo-in-Vojo sharing localStorage and the matrix client,
    // and the two copies infinite-loop on sync events. The dot-in-last-
    // segment heuristic keeps the validator simple while catching the
    // realistic accident class.
    try {
      const resolved = new URL(url, 'https://vojo.invalid');
      if (resolved.username || resolved.password) return undefined;
      if (!resolved.pathname.startsWith('/widgets/')) return undefined;
      const lastSegment = resolved.pathname.split('/').pop() ?? '';
      if (!lastSegment.includes('.')) return undefined;
      return {
        type,
        url: `${resolved.pathname}${resolved.search}${resolved.hash}`,
        commandPrefix,
        capabilities,
        provisioningUrl,
      };
    } catch {
      return undefined;
    }
  }

  try {
    const parsed = new URL(url);
    // Dev-only escape hatch: accept http://localhost:* widget URLs so a Vite
    // dev server in `apps/widget-<id>/` can be embedded straight from a local
    // config.json edit, no proxy or rewrites needed. Vite's dead-code
    // elimination drops this branch in production builds (`import.meta.env.DEV`
    // collapses to a literal `false`), so it never relaxes the prod validator.
    if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') {
      if (parsed.username || parsed.password) return undefined;
      return { type, url: parsed.toString(), commandPrefix, capabilities, provisioningUrl };
    }
    if (parsed.protocol !== 'https:') return undefined;
    if (parsed.username || parsed.password) return undefined;
    if (!PROD_WIDGET_ORIGINS.has(parsed.origin)) return undefined;
    return { type, url: parsed.toString(), commandPrefix, capabilities, provisioningUrl };
  } catch {
    return undefined;
  }
};

export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
  const seenIds = new Set<string>();
  const seenMxids = new Set<string>();
  const bots: BotPreset[] = [];
  const configuredBots = Array.isArray(clientConfig.bots) ? clientConfig.bots : [];

  configuredBots.forEach((preset) => {
    if (!isValidBotPreset(preset)) return;
    if (seenIds.has(preset.id) || seenMxids.has(preset.mxid)) return;
    seenIds.add(preset.id);
    seenMxids.add(preset.mxid);
    const description =
      typeof preset.description === 'string' && preset.description.trim().length > 0
        ? preset.description.trim()
        : undefined;
    bots.push({
      id: preset.id,
      mxid: preset.mxid,
      name: preset.name.trim(),
      description,
      experience: normalizeBotExperience(preset.experience),
    });
  });

  return bots;
};

// NOTE: description rendering lives at the call site (BotShellHero) via
// `t(\`Bots.description.${preset.id}\`, { defaultValue: preset.description ?? '' })`.
// Catalog stays config-loading-only and doesn't depend on i18next.

export const useBotPresets = (): BotPreset[] => {
  const clientConfig = useClientConfig();
  return useMemo(() => getBotPresets(clientConfig), [clientConfig]);
};

export const findBotPresetById = (
  presets: readonly BotPreset[],
  id: string
): BotPreset | undefined => presets.find((preset) => preset.id === id);
