import { useMemo } from "react";
import { useAtomValue } from "@effect/atom-react";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import {
  OAuthClientSlug,
  type IntegrationSlug,
  type OAuthClientOrigin,
  type Owner,
} from "@executor-js/sdk/shared";
import { getDomain } from "tldts";

import { oauthClientsOptimisticAtom } from "../api/atoms";

// ---------------------------------------------------------------------------
// OAuth client (registered app) selection for an integration's connect flow.
//
// An owner can register MANY apps; each is a distinct owner-scoped `oauth_client`
// row with its own slug. The connect flow lists the MANUAL apps usable for an
// integration and lets the user pick one or register a new one. Matching is by
// recorded intent first (an app registered from this integration's dialog, or
// whose OAuth endpoint HOST exactly matches the integration's declared host),
// then by registrable root domain as a subdued near-miss tier. User-owned apps
// are listed before workspace ones.
//
// DCR (dynamic client registration) clients are plumbing, not apps: they are
// minted automatically and reused per authorization server, so they NEVER appear
// in this picker. They stay visible for management elsewhere (the modal's
// "Auto-registered clients" surface), keyed off `origin.kind`.
// ---------------------------------------------------------------------------

export interface OAuthClientOption {
  readonly owner: Owner;
  readonly slug: OAuthClientSlug;
  readonly grant: "authorization_code" | "client_credentials";
  readonly authorizationUrl: string;
  readonly tokenUrl: string;
  readonly clientId: string;
  /** How the app came to exist. `manual` apps are pickable (with `integration`
   *  recording which dialog registered them); `dynamic_client_registration`
   *  apps are excluded from the picker entirely. */
  readonly origin: OAuthClientOrigin;
}

/** True for the auto-minted DCR clients that must never surface in the app
 *  picker. Legacy null-origin rows are already classified by the backend's
 *  `parseOAuthClientOrigin` heuristic, so this reads the parsed kind directly. */
export const isDcrClient = (app: OAuthClientOption): boolean =>
  app.origin.kind === "dynamic_client_registration";

/** True for host-operated first-party apps (config-declared, `first-party:`
 *  slugs). They rank ABOVE user/workspace apps when they match an integration:
 *  the one-click "nothing to paste" path is the default, BYO the escape hatch. */
export const isFirstPartyClient = (app: OAuthClientOption): boolean =>
  app.origin.kind === "first_party";

/** Mirror the host's first-party scope boundary in the picker. The server is
 *  authoritative; this prevents offering a built-in app for a flow it will
 *  reject. An explicit policy with unknown scopes fails closed. */
const firstPartyClientAllowsScopes = (
  app: OAuthClientOption,
  requestedScopes: readonly string[] | undefined,
  discoversScopes: boolean,
): boolean => {
  if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true;
  // MCP providers discover their scopes at OAuth start. The server intersects
  // that catalog with this same allow-list before redirecting, so an absent
  // static scope list is safe only on the explicit discovery path.
  if (requestedScopes === undefined) return discoversScopes;
  const allowed = new Set(app.origin.allowedScopes);
  return requestedScopes.every((scope) => allowed.has(scope));
};

const hostOf = (url: string): string | undefined => {
  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
  try {
    return new URL(url).host.toLowerCase();
  } catch {
    return undefined;
  }
};

/** The registrable ("tld+1") root domain of a URL, e.g.
 *  `accounts.google.com` → `google.com`. Falls back to the full host for
 *  localhost / IP literals (where `tldts.getDomain` returns null) so local-dev
 *  MCP servers still match by exact host. Returns undefined for unparseable URLs. */
const getRootDomain = (url: string): string | undefined => {
  const root = getDomain(url);
  if (root) return root.toLowerCase();
  return hostOf(url);
};

export interface UseOAuthClientsResult {
  /** Tier 1 — apps that match this integration by RECORDED INTENT (registered
   *  from its dialog) or EXACT endpoint host, user-owned first. These are the
   *  apps that belong to this integration; the picker defaults to the first one.
   *  When an endpoint was declared but nothing matched exactly, this is EMPTY. */
  readonly clients: readonly OAuthClientOption[];
  /** Tier 2 — apps that only match by registrable root domain (a near-miss: same
   *  provider family, different exact host / not stamped to this integration).
   *  Surfaced in a visually separate, subdued "Other apps on this provider"
   *  section — NEVER silently mixed into `clients`. Present whenever there are
   *  such near-misses, even when tier 1 is non-empty. */
  readonly nearMatches: readonly OAuthClientOption[];
  /** Unrelated apps (different root domain), kept only as the opt-in escape hatch
   *  ("use a different registered app") when NOTHING matched the declared
   *  endpoint at all. Empty once anything in tier 1 or tier 2 matched. */
  readonly otherClients: readonly OAuthClientOption[];
  /** True until the clients list has loaded at least once. */
  readonly loading: boolean;
  /**
   * Whether an app is EXACT-matched (tier 1) to the integration.
   *
   * - `true`  — either no endpoint filter was requested (the integration
   *   declares no endpoints), or at least one app matched by recorded intent or
   *   exact endpoint host. `clients` is that tier-1 subset.
   * - `false` — the integration declared endpoint(s) but no app matched exactly.
   *   `clients` is EMPTY (the UI shows an empty state + a register CTA). Any
   *   root-domain near-misses are still offered via `nearMatches`; truly
   *   unrelated apps via `otherClients`.
   */
  readonly endpointMatched: boolean;
  /** Convenience flag for the UI: a register-an-app CTA should be shown because
   *  an endpoint was declared and nothing exact-matched. Equals `!endpointMatched`
   *  once loaded. */
  readonly displayRegisterCTA: boolean;
}

/** Stable empty-list reference so the picker memo doesn't re-run while the
 *  optimistic clients atom is still loading (a fresh `[]` each render would
 *  invalidate the memo key). */
const EMPTY_CLIENTS: readonly OAuthClientOption[] = [];

/** Host/root equality that matches ONLY when both sides parsed to a real value.
 *  `hostOf`/`getRootDomain` return undefined for URLs `new URL()` can't parse,
 *  so a bare `a === b` would treat two unparseable endpoints as equal
 *  (`undefined === undefined`). Every host/root comparison in this module must
 *  go through this so an unparseable value never counts as a match. */
const hostEq = (a: string | undefined, b: string | undefined): boolean =>
  a !== undefined && b !== undefined && a === b;

/** Sort first-party apps first (the one-click default), then user-owned before
 *  shared workspace apps. */
const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>
  [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => {
    const aFirstParty = isFirstPartyClient(a);
    if (aFirstParty !== isFirstPartyClient(b)) return aFirstParty ? -1 : 1;
    return a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1;
  });

/**
 * Pure matcher (no React/atoms) — split owner-visible apps into three honest
 * tiers for an integration's connect picker.
 *
 * FIRST, DCR clients are dropped entirely: they are auto-minted plumbing reused
 * per authorization server, not apps a user picks. They stay manageable through
 * a separate surface, never the picker.
 *
 * Then the remaining MANUAL apps are graded:
 * - `matched` (tier 1): the app was registered from THIS integration's dialog
 *   (`origin.integration === integration`, recorded intent), OR its OAuth
 *   endpoint HOST exactly matches the integration's declared host (token host
 *   when a token endpoint is declared, else the authorize host). This is the
 *   subset that truly belongs to the integration.
 * - `nearMatches` (tier 2): the app shares the integration's REGISTRABLE ROOT
 *   DOMAIN ("tld+1") but is not a tier-1 match — a same-provider near-miss (e.g.
 *   an app on `mcp.cloudflare.com` for a REST integration on `api.cloudflare.com`).
 *   Silently promoting these into tier 1 is the bug this change fixes.
 * - `unmatched`: unrelated provider (different root), offered only as the escape
 *   hatch when nothing matched at all.
 *
 * When the integration declares no endpoints (and no match is required), every
 * manual app is tier 1 (no filter to grade against).
 */
export function selectClientsForEndpoints(
  all: readonly OAuthClientOption[],
  endpoints: {
    readonly tokenUrl?: string;
    readonly authorizationUrl?: string;
    /** The integration whose picker this is. A manual app stamped with this
     *  integration (recorded intent) is a tier-1 match regardless of host. */
    readonly integration?: IntegrationSlug;
    /** Complete scope set declared by the selected OAuth auth method. Used to
     *  hide scope-limited first-party apps that the host will reject. */
    readonly scopes?: readonly string[];
    /** The OAuth service discovers provider scopes at connect time and caps a
     *  first-party app's result to its configured allow-list. */
    readonly discoversScopes?: boolean;
    /** When set, an integration that targets a SPECIFIC server (MCP, whose
     *  endpoints are discovered at connect) must match by endpoint — absent
     *  endpoints mean NO match (show the register CTA), never "every app
     *  matches". Prevents auto-selecting an unrelated provider's app. */
    readonly requireEndpointMatch?: boolean;
  },
): {
  readonly matched: readonly OAuthClientOption[];
  readonly nearMatches: readonly OAuthClientOption[];
  readonly unmatched: readonly OAuthClientOption[];
  readonly endpointMatched: boolean;
} {
  // DCR clients are plumbing, never picker options.
  const manual = all.filter(
    (app) =>
      !isDcrClient(app) &&
      firstPartyClientAllowsScopes(app, endpoints.scopes, endpoints.discoversScopes === true),
  );

  const intent = endpoints.integration;
  const matchesIntent = (app: OAuthClientOption): boolean => {
    if (intent == null) return false;
    // A first-party app declaring this integration is intent-matched the same
    // way a BYO app registered from this dialog is.
    if (app.origin.kind === "first_party") {
      return (app.origin.integrations ?? []).includes(intent);
    }
    return (
      app.origin.kind === "manual" &&
      app.origin.integration != null &&
      app.origin.integration === intent
    );
  };

  const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined;
  const wantedAuthorizationHost = endpoints.authorizationUrl
    ? hostOf(endpoints.authorizationUrl)
    : undefined;
  const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined;
  const wantedAuthorizationRoot = endpoints.authorizationUrl
    ? getRootDomain(endpoints.authorizationUrl)
    : undefined;

  // No declared endpoints. A server-targeting integration must NOT match every
  // app (it would auto-select an unrelated provider); surface the register CTA
  // instead. Otherwise (no endpoint filter at all) every manual app is usable.
  if (!wantedTokenRoot && !wantedAuthorizationRoot) {
    if (endpoints.requireEndpointMatch) {
      // Even with no declared endpoints, an app registered from THIS dialog is a
      // real tier-1 match (the user built it here on purpose).
      const matched = manual.filter(matchesIntent);
      const rest = manual.filter((app) => !matchesIntent(app));
      return {
        matched: sortUserFirst(matched),
        nearMatches: [],
        unmatched: sortUserFirst(rest),
        endpointMatched: matched.length > 0,
      };
    }
    return {
      matched: sortUserFirst(manual),
      nearMatches: [],
      unmatched: [],
      endpointMatched: true,
    };
  }

  const matched: OAuthClientOption[] = [];
  const nearMatches: OAuthClientOption[] = [];
  const unmatched: OAuthClientOption[] = [];
  for (const app of manual) {
    const appTokenHost = hostOf(app.tokenUrl);
    const appAuthorizationHost = hostOf(app.authorizationUrl);
    const appTokenRoot = getRootDomain(app.tokenUrl);
    const appAuthorizationRoot = getRootDomain(app.authorizationUrl);
    // Exact HOST match against the declared endpoint (token host first, since the
    // token endpoint is what the SDK actually calls; authorize host as fallback
    // when no token endpoint was declared).
    const exactHostMatch = wantedTokenHost
      ? hostEq(appTokenHost, wantedTokenHost)
      : hostEq(appAuthorizationHost, wantedAuthorizationHost) ||
        hostEq(appTokenHost, wantedAuthorizationHost);
    // Same registrable root domain (the old, looser heuristic) — now only a
    // tier-2 signal.
    const rootMatch = wantedTokenRoot
      ? hostEq(appTokenRoot, wantedTokenRoot)
      : hostEq(appAuthorizationRoot, wantedAuthorizationRoot) ||
        hostEq(appTokenRoot, wantedAuthorizationRoot);
    if (matchesIntent(app) || exactHostMatch) matched.push(app);
    else if (rootMatch) nearMatches.push(app);
    else unmatched.push(app);
  }
  return {
    matched: sortUserFirst(matched),
    nearMatches: sortUserFirst(nearMatches),
    unmatched: sortUserFirst(unmatched),
    endpointMatched: matched.length > 0,
  };
}

export function useOAuthClientsForIntegration(opts: {
  readonly tokenUrl?: string;
  readonly authorizationUrl?: string;
  readonly integration?: IntegrationSlug;
  readonly scopes?: readonly string[];
  readonly discoversScopes?: boolean;
  readonly requireEndpointMatch?: boolean;
}): UseOAuthClientsResult {
  // Read the optimistic list so a just-registered/edited/removed app paints
  // immediately, instead of flashing the stale server list until the refetch
  // lands. The modal's management menu reads the same optimistic atom, so the
  // picker rows and their actions stay consistent.
  const clientsResult = useAtomValue(oauthClientsOptimisticAtom);
  const loaded = AsyncResult.isSuccess(clientsResult);
  const all = loaded ? (clientsResult.value as readonly OAuthClientOption[]) : EMPTY_CLIENTS;

  // Memoize the grade: `selectClientsForEndpoints` parses every client's URLs
  // with tldts, and the modal passes a FRESH inline options object each render,
  // so without this every keystroke would re-grade all clients. Key on the
  // primitive inputs plus the `all` array reference (a new optimistic list is a
  // new array), NOT on `opts` identity.
  const selection = useMemo(
    () =>
      selectClientsForEndpoints(all, {
        tokenUrl: opts.tokenUrl,
        authorizationUrl: opts.authorizationUrl,
        integration: opts.integration,
        scopes: opts.scopes,
        discoversScopes: opts.discoversScopes,
        requireEndpointMatch: opts.requireEndpointMatch,
      }),
    [
      all,
      opts.tokenUrl,
      opts.authorizationUrl,
      opts.integration,
      opts.scopes,
      opts.discoversScopes,
      opts.requireEndpointMatch,
    ],
  );

  if (!loaded) {
    return {
      clients: [],
      nearMatches: [],
      otherClients: [],
      loading: true,
      endpointMatched: true,
      displayRegisterCTA: false,
    };
  }

  const { matched, nearMatches, unmatched, endpointMatched } = selection;
  // EXPLICIT outcome: `clients` is the tier-1 (exact/intent) subset the picker
  // defaults into. `nearMatches` (root-domain-only) is always surfaced, but in a
  // separate subdued section so it is never mistaken for a real match. When an
  // endpoint was declared but nothing exact-matched, `clients` is EMPTY and a
  // register CTA is flagged; the unrelated `unmatched` apps stay in `otherClients`
  // as the last-resort escape hatch.
  return {
    clients: endpointMatched ? matched : [],
    nearMatches,
    otherClients: endpointMatched ? [] : unmatched,
    loading: false,
    endpointMatched,
    displayRegisterCTA: !endpointMatched,
  };
}

/**
 * The auto-registered (DCR) clients relevant to an integration, for the
 * management surface (list + delete). These are hidden from the app picker but
 * must stay visible SOMEWHERE. A DCR client is relevant when it records this
 * integration as its origin (Part A stamps `origin.integration`), or — for
 * legacy clients minted before the stamp — when its endpoints share the
 * integration's declared/discovered root domain.
 */
export function selectDcrClientsForIntegration(
  all: readonly OAuthClientOption[],
  opts: {
    readonly integration?: IntegrationSlug;
    readonly tokenUrl?: string;
    readonly authorizationUrl?: string;
  },
): readonly OAuthClientOption[] {
  const wantedTokenRoot = opts.tokenUrl ? getRootDomain(opts.tokenUrl) : undefined;
  const wantedAuthorizationRoot = opts.authorizationUrl
    ? getRootDomain(opts.authorizationUrl)
    : undefined;
  const relevant = all.filter((app) => {
    if (!isDcrClient(app)) return false;
    if (
      opts.integration != null &&
      app.origin.kind === "dynamic_client_registration" &&
      app.origin.integration != null &&
      app.origin.integration === opts.integration
    ) {
      return true;
    }
    if (!wantedTokenRoot && !wantedAuthorizationRoot) return false;
    const appTokenRoot = getRootDomain(app.tokenUrl);
    const appAuthorizationRoot = getRootDomain(app.authorizationUrl);
    return (
      hostEq(appTokenRoot, wantedTokenRoot) ||
      hostEq(appAuthorizationRoot, wantedTokenRoot) ||
      hostEq(appAuthorizationRoot, wantedAuthorizationRoot) ||
      hostEq(appTokenRoot, wantedAuthorizationRoot)
    );
  });
  return sortUserFirst(relevant);
}

const slugifyName = (name: string): string =>
  name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");

/** A unique OAuth client slug derived from a display name, deduped against the
 *  owner's existing client slugs. */
export function uniqueClientSlug(name: string, existing: readonly string[]): OAuthClientSlug {
  const base = slugifyName(name) || "oauth-app";
  if (!existing.includes(base)) return OAuthClientSlug.make(base);
  let suffix = 2;
  while (existing.includes(`${base}-${suffix}`)) suffix += 1;
  return OAuthClientSlug.make(`${base}-${suffix}`);
}

/**
 * Optimistic (client-side) DCR client slug derived from the authorization
 * server host, for immediate/placeholder display only.
 *
 * The AUTHORITATIVE slug is computed server-side by `dcrClientSlug` in
 * `packages/core/sdk/src/oauth-service.ts`, which is resource-aware (an AS
 * serving multiple RFC 8707 resources gets distinct, hash-suffixed slugs). This
 * host-only form deliberately ignores resource: it is a best-effort placeholder
 * the UI shows before the server responds, and the server recomputes and
 * persists the real slug on registration. Do NOT rely on it matching the stored
 * slug for a multi-resource server.
 */
export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSlug {
  const host = hostOf(issuerOrEndpoint);
  const base = host === undefined ? "" : slugifyName(host);
  return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`);
}

/** Humanize a client slug for display ("spotify-prod" → "Spotify prod").
 *  First-party slugs drop their namespace prefix ("first-party:github" →
 *  "Github") — the row's badge already says it's the built-in app. */
export function clientDisplayName(slug: string): string {
  const bare = slug.startsWith("first-party:") ? slug.slice("first-party:".length) : slug;
  const text = bare.replace(/[-_]/g, " ").trim();
  return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug;
}

/** The host shown next to an app in the picker (the token endpoint's host). */
export function clientHost(tokenUrl: string): string {
  return hostOf(tokenUrl) ?? tokenUrl;
}
