import { useMemo, useState } from "react";
import { useAtomSet } from "@effect/atom-react";
import * as Exit from "effect/Exit";
import { ExternalLink } from "lucide-react";
import {
  isTokenEndpointAuthMethod,
  OAuthClientSlug,
  type IntegrationSlug,
  type OAuthGrant,
  type Owner,
  type TokenEndpointAuthMethod,
} from "@executor-js/sdk/shared";
import { toast } from "sonner";

import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";
import { ownerLabelForHost } from "../api/owner-display";
import { trackEvent } from "../api/analytics";
import { useOrganizationId } from "../api/organization-context";
import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
import { oauthClientWriteKeys } from "../api/reactivity-keys";
import { optimisticDcrClientSlug, uniqueClientSlug } from "../plugins/use-effective-oauth-client";
import { oauthCallbackUrl } from "../plugins/oauth-sign-in";
import {
  ConnectionOwnerDropdown,
  connectionOwnerOptionsForAccess,
  normalizeConnectionOwner,
} from "../plugins/connection-owner";
import { Button } from "./button";
import { CopyButton } from "./copy-button";
import { Input } from "./input";
import { Label } from "./label";
import { oauthAppSetupFor } from "./oauth-app-setup";
import { RadioGroup, RadioGroupItem } from "./radio-group";

// ---------------------------------------------------------------------------
// OAuth client registration form (reusable).
//
// Registers an owner-scoped OAuth app (`oauth.createClient`): the user pastes a
// client id/secret and confirms the endpoints/scopes, which pre-fill from the
// integration's declared OAuth method. The `slug` is a stable per-integration
// client slug passed by the caller — it is NOT user-entered.
//
// The form's owner (CLIENT owner: Personal vs Workspace) is DISTINCT from the
// connection's "saved to" owner — an org-owned app can back a user-owned
// connection so each employee mints their own token against the shared app.
// ---------------------------------------------------------------------------

export interface OAuthClientFormPrefill {
  readonly issuer?: string | null;
  readonly authorizationUrl?: string;
  readonly tokenUrl?: string;
  readonly resource?: string | null;
  /** Template-DECLARED scopes (e.g. an OpenAPI bundle's scope union). Immutable
   *  in the form and authoritative — always sent at registration, surviving
   *  Discover. */
  readonly scopes?: readonly string[];
  /** Scopes already DISCOVERED from a prior server probe (e.g. a DCR fallback).
   *  Seed the form's discovered state; a later in-form Discover replaces them,
   *  and they are only sent when no declared scopes exist. */
  readonly discoveredScopes?: readonly string[];
  readonly grant?: OAuthGrant;
  /** Client id to seed (e.g. when editing an existing app). NOT a secret — the
   *  secret is never returned, so it is always re-entered. */
  readonly clientId?: string;
  /** RFC 7591 registration endpoint. When known (from the integration's OAuth
   *  method or surfaced by Discover), the form offers a one-click "Register
   *  automatically" path that needs no pasted client id/secret. */
  readonly registrationEndpoint?: string;
  /** Token-endpoint auth methods the server advertises (RFC 8414), so a
   *  prefilled "Register automatically" picks the right client-auth method
   *  instead of defaulting to public ("none"). */
  readonly tokenEndpointAuthMethodsSupported?: readonly string[];
  /** Saved manual-client transport. Omitted means client_secret_post. */
  readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
}

export const preferredManualTokenEndpointAuthMethod = (
  advertised: readonly string[] | undefined,
): TokenEndpointAuthMethod =>
  advertised?.includes("client_secret_basic") === true && !advertised.includes("client_secret_post")
    ? "basic"
    : "body";

/** The scopes to register via DCR. The integration's DECLARED (template) scopes
 *  are authoritative and immutable, so they win. Otherwise the DISCOVERED set is
 *  used — seeded from a prior server probe and replaced by any in-form Discover —
 *  so re-discovering a different issuer can't register a stale set. */
export const registrationScopes = (
  declaredScopes: readonly string[],
  discoveredScopes: readonly string[],
): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes);

/** The `originIntegration` to send with a `createClient` payload. When the
 *  caller passes an explicit `intentIntegration` (edit flow: the app's
 *  preserved recorded intent, which may legitimately be `null`), it wins
 *  verbatim — editing must never silently overwrite a stamp. Otherwise this is
 *  a fresh registration from an integration's dialog, which stamps the current
 *  integration as recorded intent (or `null` outside any integration
 *  context). */
export const resolveOriginIntegration = (
  intentIntegration: IntegrationSlug | null | undefined,
  integrationSlug: IntegrationSlug | undefined,
): IntegrationSlug | null =>
  intentIntegration !== undefined ? intentIntegration : (integrationSlug ?? null);

export const canSubmitOAuthClientForm = (input: {
  readonly submitting: boolean;
  readonly name: string;
  readonly grant: OAuthGrant;
  readonly clientId: string;
  readonly clientSecret: string;
  readonly authorizationUrl: string;
  readonly tokenUrl: string;
  readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
}): boolean =>
  !input.submitting &&
  input.name.trim().length > 0 &&
  input.clientId.trim().length > 0 &&
  (input.grant === "authorization_code" || input.clientSecret.trim().length > 0) &&
  (input.tokenEndpointAuthMethod === undefined ||
    input.tokenEndpointAuthMethod === "body" ||
    input.clientSecret.trim().length > 0) &&
  input.tokenUrl.trim().length > 0 &&
  (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0);

/** Preserve the Workspace default as the user's preference while role data is
 * loading. The effective owner is clamped separately on every render, so a
 * member still submits Personal; when an admin role resolves, Workspace is
 * restored unless the user actively selected Personal. */
export const initialOAuthClientOwner = (fixedOwner: Owner | undefined): Owner =>
  fixedOwner ?? "org";

export function OAuthClientForm(props: {
  /** Human label for the integration this app backs (used in toasts + default name). */
  readonly integrationName: string;
  /** Slug of the integration whose dialog this form is registering from, when
   *  known. Stamped onto the created MANUAL app as recorded intent so the picker
   *  matches it to this integration exactly (not by root-domain guess). Omitted
   *  when editing (an existing app's origin is fixed) or when there is no single
   *  integration context. */
  readonly integrationSlug?: IntegrationSlug;
  /** Explicit recorded-intent stamp to send verbatim, overriding the default
   *  derivation from `integrationSlug`. Set this when editing an existing app
   *  so its ALREADY-recorded origin (which may be `null`) is preserved rather
   *  than re-derived from the current dialog's integration context. Omit for a
   *  fresh registration, where the default (stamp `integrationSlug`, or `null`
   *  outside an integration context) is correct. */
  readonly intentIntegration?: IntegrationSlug | null;
  /** Existing client slugs, so the generated slug stays unique across apps. */
  readonly existingSlugs: readonly string[];
  /** Endpoints/scopes declared by the integration's OAuth method. */
  readonly prefill?: OAuthClientFormPrefill;
  /** Reuse this exact slug instead of deriving one from the name. Set when
   *  editing an existing app — `createClient` upserts by `(owner, slug)`, so
   *  editing is re-registering with the same slug. */
  readonly fixedSlug?: OAuthClientSlug;
  /** Lock the client owner instead of letting the user choose. Set when editing
   *  (an app's owner is part of its identity and can't change). */
  readonly fixedOwner?: Owner;
  /** Called with the registered client owner + slug after a successful create. */
  readonly onCreated: (result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => void;
  readonly onCancel?: () => void;
  readonly surface?: "card" | "plain";
  /** When set, the server's automatic (DCR) registration is known to be rejected
   *  for this host (e.g. it refused our redirect URI). The form then suppresses
   *  the "Register automatically" path and leads with manual client entry; the
   *  string is the actionable reason shown to the user. */
  readonly autoRegisterRejectedReason?: string | null;
}) {
  const {
    integrationName,
    integrationSlug,
    intentIntegration,
    existingSlugs,
    prefill,
    fixedSlug,
    fixedOwner,
    onCreated,
    onCancel,
    surface = "card",
    autoRegisterRejectedReason = null,
  } = props;
  // Non-org hosts (local/desktop) have one local workspace. Offer only Local,
  // so the owner dropdown (which hides on a single option) disappears.
  const organizationId = useOrganizationId();
  const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections();
  const ownerOptions = useMemo(
    () => connectionOwnerOptionsForAccess(organizationId, canCreateWorkspaceConnections),
    [canCreateWorkspaceConnections, organizationId],
  );

  // The browser-facing callback the OAuth flow uses (this host's
  // `${origin}/api/oauth/callback`). It is the SAME value handed to `oauth.start`
  // and to DCR registration below, so showing it here is exactly the redirect a
  // user must allow-list on their OAuth app. Resolved from `window.location` so
  // it is automatically correct per platform (cloud / self-host / local).
  const callbackUrl = useMemo(() => oauthCallbackUrl(), []);

  // Explicit create-time choice (no ambient owner). Admins default to Workspace
  // (`org`) on an org host; members are clamped to Personal (`user`); non-org
  // hosts use Local (`org`). Editing may lock the existing owner.
  const [selectedOwner, setSelectedOwner] = useState<Owner>(() =>
    initialOAuthClientOwner(fixedOwner),
  );
  // Role loading and workspace switches can invalidate a prior selection.
  // Derive the effective owner every render so no submit observes stale state.
  const owner = normalizeConnectionOwner(selectedOwner, ownerOptions);
  const [name, setName] = useState(integrationName);
  const [grant, setGrant] = useState<OAuthGrant>(prefill?.grant ?? "authorization_code");
  const [clientId, setClientId] = useState(prefill?.clientId ?? "");
  const [clientSecret, setClientSecret] = useState("");
  const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = useState<TokenEndpointAuthMethod>(
    prefill?.tokenEndpointAuthMethod ??
      preferredManualTokenEndpointAuthMethod(prefill?.tokenEndpointAuthMethodsSupported),
  );
  const [issuerUrl, setIssuerUrl] = useState("");
  const [discoveredIssuer, setDiscoveredIssuer] = useState<string | null>(prefill?.issuer ?? null);
  const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");
  const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");
  const [resource, setResource] = useState(prefill?.resource ?? null);
  // Scopes to register with DCR. The form has no scopes input (DCR sends them
  // verbatim). Declared scopes are the integration's TEMPLATE scopes — immutable
  // and authoritative, so they survive Discover. Discovered scopes are seeded
  // from a prior probe (e.g. a DCR fallback) and replaced by any in-form
  // Discover; they are only sent when nothing is declared.
  const declaredScopes = prefill?.scopes ?? [];
  const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>(
    prefill?.discoveredScopes ?? [],
  );
  const visibleScopes = registrationScopes(declaredScopes, discoveredScopes);
  const visibleScopesSource =
    declaredScopes.length > 0 ? "Declared by integration" : "Discovered from server";
  const [discovering, setDiscovering] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded
  // from the integration's OAuth method and refreshed by the Discover probe, so
  // a user can paste an MCP URL → Discover → Register automatically.
  const [registrationEndpoint, setRegistrationEndpoint] = useState(
    prefill?.registrationEndpoint ?? "",
  );
  const [authMethods, setAuthMethods] = useState<readonly string[] | undefined>(
    prefill?.tokenEndpointAuthMethodsSupported,
  );
  const [registering, setRegistering] = useState(false);

  // Endpoints/scopes usually come prefilled from the integration's declared
  // OAuth method, so collapse them behind an "Edit" — the common case is just
  // client id/secret + owner.
  const endpointsKnown = (prefill?.tokenUrl ?? "").length > 0;
  const [showEndpoints, setShowEndpoints] = useState(!endpointsKnown);

  const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" });
  const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" });
  const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, {
    mode: "promiseExit",
  });

  // Blank means "send no RFC 8707 resource": persist null so every OAuth
  // request (authorize, exchange, refresh, client-credentials) omits the
  // parameter. Clearing must stick — no endpoint is re-derived over an
  // intentional absence (Entra v2 rejects `resource`, #1789).
  const normalizedResource =
    resource == null || resource.trim().length === 0 ? null : resource.trim();

  const canSubmit = canSubmitOAuthClientForm({
    submitting,
    name,
    grant,
    clientId,
    clientSecret,
    authorizationUrl,
    tokenUrl,
    tokenEndpointAuthMethod,
  });

  // DCR is offered when the server advertises a registration endpoint AND we
  // have the interactive-flow endpoints to persist alongside the minted client.
  const canRegisterDynamic =
    registrationEndpoint.trim().length > 0 &&
    authorizationUrl.trim().length > 0 &&
    tokenUrl.trim().length > 0 &&
    grant === "authorization_code";

  // When the server already rejected automatic registration for this host (e.g.
  // it refused our non-loopback redirect URI), don't lead the user back into the
  // path that just failed: suppress the auto CTA and lead with manual entry.
  const showAutoRegister = canRegisterDynamic && autoRegisterRejectedReason === null;
  const appSetup = oauthAppSetupFor({
    authorizationUrl,
    tokenUrl,
    issuer: discoveredIssuer,
    resource: normalizedResource,
  });
  const showAppSetup =
    appSetup !== undefined && grant === "authorization_code" && !showAutoRegister;

  const handleDiscover = async () => {
    const url = issuerUrl.trim();
    if (url.length === 0) {
      toast.error("Enter an issuer URL to discover endpoints");
      return;
    }
    setDiscovering(true);
    // Probe is a pure discovery read — no shared state to invalidate. The empty
    // `reactivityKeys` documents that for the `require-reactivity-keys` rule.
    const exit = await doProbe({ payload: { url }, reactivityKeys: [] });
    setDiscovering(false);
    if (Exit.isFailure(exit)) {
      toast.error("Could not discover OAuth endpoints");
      return;
    }
    const result = exit.value;
    setDiscoveredIssuer(result.issuer ?? null);
    setAuthorizationUrl(result.authorizationUrl);
    setTokenUrl(result.tokenUrl);
    setResource(result.resource ?? null);
    // Record the discovered scopes. Declared scopes (if any) still take
    // precedence at registration, so a re-Discover reflects the latest server
    // without clobbering a declared set.
    setDiscoveredScopes(result.scopesSupported ?? []);
    // Capture DCR availability so the "Register automatically" path shows for a
    // pasted MCP/issuer URL without any client id/secret.
    setRegistrationEndpoint(result.registrationEndpoint ?? "");
    setAuthMethods(result.tokenEndpointAuthMethodsSupported);
    toast.success(
      result.registrationEndpoint
        ? "Discovered OAuth endpoints — automatic registration available"
        : "Discovered OAuth endpoints",
    );
  };

  const handleRegisterDynamic = async () => {
    if (!canRegisterDynamic || registering) return;
    setRegistering(true);
    const slug =
      fixedSlug ?? optimisticDcrClientSlug(discoveredIssuer ?? registrationEndpoint.trim());
    const exit = await doRegisterDynamic({
      payload: {
        owner,
        slug,
        issuer: discoveredIssuer,
        registrationEndpoint: registrationEndpoint.trim(),
        authorizationUrl: authorizationUrl.trim(),
        tokenUrl: tokenUrl.trim(),
        resource: normalizedResource,
        // DCR sends the integration's declared scopes, or the discovered set when
        // none are declared, to the AS at registration (the app stores none).
        scopes: [...registrationScopes(declaredScopes, discoveredScopes)],
        tokenEndpointAuthMethodsSupported: authMethods,
        clientName: name.trim(),
        redirectUri: oauthCallbackUrl(),
      },
      reactivityKeys: oauthClientWriteKeys,
    });
    if (Exit.isFailure(exit)) {
      setRegistering(false);
      trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: false });
      toast.error("Automatic registration failed — enter a client ID and secret instead");
      return;
    }
    trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true });
    toast.success(`Registered ${integrationName} OAuth app`);
    onCreated({ owner, slug: exit.value.client });
  };

  const handleSubmit = async () => {
    if (!canSubmit) return;
    setSubmitting(true);
    const slug = fixedSlug ?? uniqueClientSlug(name, existingSlugs);
    const exit = await doCreate({
      payload: {
        owner,
        slug,
        authorizationUrl: authorizationUrl.trim(),
        tokenUrl: tokenUrl.trim(),
        grant,
        clientId: clientId.trim(),
        clientSecret: clientSecret.trim(),
        tokenEndpointAuthMethod,
        resource: normalizedResource,
        // Editing preserves the app's already-recorded origin (via
        // `intentIntegration`, passed verbatim by the caller); a fresh
        // registration from an integration's dialog stamps recorded intent.
        originIntegration: resolveOriginIntegration(intentIntegration, integrationSlug),
      },
      reactivityKeys: oauthClientWriteKeys,
    });
    if (Exit.isFailure(exit)) {
      setSubmitting(false);
      trackEvent("oauth_client_registered", { owner, grant, via_dcr: false, success: false });
      toast.error("Failed to register OAuth app");
      return;
    }
    trackEvent("oauth_client_registered", { owner, grant, via_dcr: false, success: true });
    toast.success(`Registered ${integrationName} OAuth app`);
    onCreated({ owner, slug });
  };

  return (
    <div
      className={
        surface === "card"
          ? "space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4"
          : "space-y-4"
      }
    >
      <div className="space-y-1">
        <p className="text-sm font-medium">Register an OAuth app</p>
        <p className="text-xs text-muted-foreground">
          {showAutoRegister
            ? "Register automatically below, or enter a client id/secret manually."
            : "Paste a client id and optional secret. We only ask for endpoints when they aren't already known."}
        </p>
      </div>

      {autoRegisterRejectedReason ? (
        <div className="space-y-1 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
          <p className="text-sm font-medium text-destructive">Automatic registration unavailable</p>
          <p className="text-xs text-muted-foreground">{autoRegisterRejectedReason}</p>
        </div>
      ) : null}

      {showAppSetup ? (
        <div className="space-y-3 rounded-lg border border-ring/40 bg-accent/30 p-3">
          <div className="space-y-1">
            <p className="text-sm font-medium">{appSetup.title}</p>
            <p className="text-xs text-muted-foreground">{appSetup.body}</p>
          </div>
          <ol className="list-decimal space-y-1 pl-4 text-xs text-muted-foreground">
            {appSetup.steps.map((step: string) => (
              <li key={step}>{step}</li>
            ))}
          </ol>
          <Button asChild>
            <a href={appSetup.createUrl(callbackUrl)} target="_blank" rel="noreferrer">
              {appSetup.createLabel}
              <ExternalLink aria-hidden="true" className="size-4" />
            </a>
          </Button>
        </div>
      ) : null}

      {/* app name */}
      <div className="space-y-1.5">
        <Label htmlFor="oauth-app-name" className="text-xs text-muted-foreground">
          App name
          <span className="font-normal text-muted-foreground/70">to tell your apps apart</span>
        </Label>
        <Input
          id="oauth-app-name"
          placeholder={integrationName}
          value={name}
          onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
        />
      </div>

      {/* register automatically (RFC 7591 DCR) — the primary path when the
          server advertises a registration endpoint: no client id/secret needed */}
      {showAutoRegister ? (
        <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 p-3">
          <p className="text-sm font-medium">No client ID needed</p>
          <p className="text-xs text-muted-foreground">
            This server supports automatic registration. We register a public app for you and use
            PKCE — you don&apos;t paste any client id or secret.
          </p>
          <Button
            type="button"
            onClick={() => void handleRegisterDynamic()}
            disabled={registering || name.trim().length === 0}
            className="w-full"
          >
            {registering ? "Registering…" : "Register automatically — no client ID needed"}
          </Button>
        </div>
      ) : null}

      {/* grant */}
      <div className="space-y-2">
        <Label className="text-xs text-muted-foreground">Grant type</Label>
        <RadioGroup
          value={grant}
          onValueChange={(next: string) => setGrant(next as OAuthGrant)}
          className="gap-2"
        >
          {(
            [
              {
                value: "authorization_code",
                label: "Authorization code",
                hint: "User signs in",
              },
              {
                value: "client_credentials",
                label: "Client credentials",
                hint: "App-to-app, no user",
              },
            ] as const
          ).map((option) => (
            <Label
              key={option.value}
              htmlFor={`grant-${option.value}`}
              className="flex cursor-pointer items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2 font-normal has-[:checked]:border-ring has-[:checked]:bg-accent/40"
            >
              <RadioGroupItem
                id={`grant-${option.value}`}
                value={option.value}
                className="mt-0.5"
              />
              <span className="min-w-0 flex-1">
                <span className="block text-sm font-medium">{option.label}</span>
                <span className="block text-xs text-muted-foreground">{option.hint}</span>
              </span>
            </Label>
          ))}
        </RadioGroup>
      </div>

      {/* divider before the manual (secondary) path when DCR is available */}
      {showAutoRegister ? (
        <div className="flex items-center gap-2 text-xs text-muted-foreground">
          <span className="h-px flex-1 bg-border/60" />
          or enter a client ID manually
          <span className="h-px flex-1 bg-border/60" />
        </div>
      ) : null}

      {/* callback URL — the redirect the authorization-code flow uses. Show it
          so the user can allow-list it on their OAuth app. Client-credentials
          has no browser redirect, so it is hidden for that grant. */}
      {grant === "authorization_code" ? (
        <div className="space-y-1.5">
          <Label className="text-xs text-muted-foreground">
            Callback URL
            <span className="font-normal text-muted-foreground/70">
              add this to your OAuth app&apos;s allowed redirects
            </span>
          </Label>
          <div className="flex items-center gap-1 rounded-md border border-border bg-background/50 px-2.5 py-1.5">
            <span
              id="oauth-callback-url"
              className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground"
            >
              {callbackUrl}
            </span>
            <CopyButton value={callbackUrl} />
          </div>
        </div>
      ) : null}

      {/* client id / secret */}
      <div className="grid gap-3 sm:grid-cols-2">
        <div className="space-y-1.5">
          <Label htmlFor="oauth-client-id" className="text-xs text-muted-foreground">
            Client ID
          </Label>
          <Input
            id="oauth-client-id"
            placeholder="client id"
            value={clientId}
            onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientId(e.target.value)}
            className="font-mono"
          />
        </div>
        <div className="space-y-1.5">
          <Label htmlFor="oauth-client-secret" className="text-xs text-muted-foreground">
            Client secret
            {grant === "authorization_code" ? (
              <span className="font-normal text-muted-foreground/70">
                optional for public clients
              </span>
            ) : null}
          </Label>
          <Input
            id="oauth-client-secret"
            type="password"
            autoComplete="new-password"
            placeholder={
              grant === "authorization_code" ? "optional client secret" : "required client secret"
            }
            value={clientSecret}
            onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientSecret(e.target.value)}
            className="font-mono"
            data-ph-block
          />
        </div>
      </div>

      <div className="space-y-2">
        <Label className="text-xs text-muted-foreground">Token endpoint authentication</Label>
        <RadioGroup
          value={tokenEndpointAuthMethod}
          onValueChange={(next: string) => {
            if (isTokenEndpointAuthMethod(next)) setTokenEndpointAuthMethod(next);
          }}
          className="grid gap-2 sm:grid-cols-3"
        >
          {(
            [
              {
                value: "body",
                label: "Request body",
                hint: "client_secret_post",
              },
              {
                value: "basic",
                label: "HTTP Basic",
                hint: "client_secret_basic",
              },
              {
                value: "basic_raw",
                label: "HTTP Basic (raw)",
                hint: "provider compatibility",
              },
            ] as const
          ).map((option) => (
            <Label
              key={option.value}
              htmlFor={`token-auth-${option.value}`}
              className="flex cursor-pointer items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2 font-normal has-[:checked]:border-ring has-[:checked]:bg-accent/40"
            >
              <RadioGroupItem
                id={`token-auth-${option.value}`}
                value={option.value}
                className="mt-0.5"
              />
              <span className="min-w-0 flex-1">
                <span className="block text-sm font-medium">{option.label}</span>
                <span className="block font-mono text-xs text-muted-foreground">{option.hint}</span>
              </span>
            </Label>
          ))}
        </RadioGroup>
        {tokenEndpointAuthMethod !== "body" && clientSecret.trim().length === 0 ? (
          <p className="text-xs text-destructive">HTTP Basic requires a client secret.</p>
        ) : null}
      </div>

      {/* endpoints */}
      {endpointsKnown && !showEndpoints ? (
        <Button
          type="button"
          variant="outline"
          onClick={() => setShowEndpoints(true)}
          className="h-auto w-full justify-start gap-2 px-3 py-2 text-xs font-normal text-muted-foreground"
        >
          <span className="text-foreground">✓</span>
          Endpoints set from {integrationName}
          <span className="ml-auto font-medium text-foreground">Edit</span>
        </Button>
      ) : (
        <div className="space-y-3 rounded-lg border border-border/50 bg-background/30 p-3">
          {/* discovery */}
          <div className="space-y-1.5">
            <Label htmlFor="oauth-issuer-url" className="text-xs text-muted-foreground">
              Discover endpoints
              <span className="font-normal text-muted-foreground/70">optional</span>
            </Label>
            <div className="flex gap-2">
              <Input
                id="oauth-issuer-url"
                placeholder="https://issuer.example.com"
                value={issuerUrl}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIssuerUrl(e.target.value)}
              />
              <Button
                type="button"
                variant="outline"
                onClick={() => void handleDiscover()}
                disabled={discovering}
              >
                {discovering ? "Discovering…" : "Discover"}
              </Button>
            </div>
          </div>

          {grant === "authorization_code" ? (
            <div className="space-y-1.5">
              <Label htmlFor="oauth-authorization-url" className="text-xs text-muted-foreground">
                Authorization URL
              </Label>
              <Input
                id="oauth-authorization-url"
                placeholder="https://issuer.example.com/authorize"
                value={authorizationUrl}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setAuthorizationUrl(e.target.value)
                }
                className="font-mono"
              />
            </div>
          ) : null}

          <div className="space-y-1.5">
            <Label htmlFor="oauth-token-url" className="text-xs text-muted-foreground">
              Token URL
            </Label>
            <Input
              id="oauth-token-url"
              placeholder="https://issuer.example.com/token"
              value={tokenUrl}
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)}
              className="font-mono"
            />
          </div>

          {/* RFC 8707 resource indicator. Prefilled for MCP servers; the field
              exists so a user can CLEAR it — some authorization servers
              (Microsoft Entra v2) reject requests that carry `resource`, and a
              cleared value persists as "no resource" on every OAuth request. */}
          <div className="space-y-1.5">
            <Label htmlFor="oauth-resource" className="text-xs text-muted-foreground">
              Resource indicator
              <span className="font-normal text-muted-foreground/70">
                optional — leave empty if the server rejects the resource parameter
              </span>
            </Label>
            <Input
              id="oauth-resource"
              placeholder="https://mcp.example.com/mcp"
              value={resource ?? ""}
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResource(e.target.value)}
              className="font-mono"
            />
          </div>

          {endpointsKnown ? (
            <Button
              type="button"
              variant="ghost"
              size="sm"
              onClick={() => setShowEndpoints(false)}
              className="h-auto px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground"
            >
              Done editing
            </Button>
          ) : null}
        </div>
      )}

      {visibleScopes.length > 0 ? (
        <div className="space-y-2 rounded-lg border border-border/50 bg-background/30 p-3">
          <div className="flex items-center justify-between gap-3">
            <div>
              <p className="text-xs font-medium text-foreground">Required OAuth scopes</p>
              <p className="text-[11px] text-muted-foreground">{visibleScopesSource}</p>
            </div>
            <span className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
              {visibleScopes.length}
            </span>
          </div>
          <ul className="space-y-1">
            {visibleScopes.map((scope: string) => (
              <li
                key={scope}
                className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground"
              >
                {scope}
              </li>
            ))}
          </ul>
        </div>
      ) : null}

      {/* client owner (distinct from the connection's saved-to owner). Locked
          when editing — an app's owner is part of its (owner, slug) identity. */}
      {fixedOwner ? (
        <div className="space-y-1.5">
          <Label className="text-[11px] text-muted-foreground">Owner</Label>
          <p className="text-sm">
            {ownerLabelForHost(owner, organizationId)}
            <span className="ml-2 text-xs text-muted-foreground">
              can&apos;t change after creation
            </span>
          </p>
        </div>
      ) : (
        <ConnectionOwnerDropdown
          value={owner}
          options={ownerOptions}
          onChange={(next: Owner) => setSelectedOwner(next)}
          label="Register app for"
          help={`Personal apps are yours only; Workspace apps are shared with everyone (each ${ownerLabelForHost(
            "user",
            organizationId,
          ).toLowerCase()} still mints their own connection).`}
        />
      )}

      <div className="flex justify-end gap-2 pt-1">
        {onCancel ? (
          <Button type="button" variant="ghost" onClick={onCancel} disabled={submitting}>
            Cancel
          </Button>
        ) : null}
        <Button type="button" onClick={() => void handleSubmit()} disabled={!canSubmit}>
          {submitting ? "Registering…" : "Register app"}
        </Button>
      </div>
    </div>
  );
}
