// ---------------------------------------------------------------------------
// @executor-js/sdk health-check vocabulary (browser-safe).
//
// A health check is a single declared, authenticated operation a connection can
// run to answer two questions at once: "is this credential still alive?" and
// "whose account is this?". The plugin owns WHICH operation (it lives in the
// plugin's opaque integration config, picked by the user the same way auth
// methods are configured); core owns the shared vocabulary below so every
// surface (API, React, plugins) speaks the same shapes.
//
// The probe runs an operation with optional pinned `args` (Google's People API
// needs `resourceName=people/me`; there is no zero-arg identity endpoint), maps
// the HTTP status to a `HealthStatus`, and extracts a display `identity` from a
// dot-path on the response body. Everything here is pure Effect/Schema with no
// server/node imports so it is safe to import from React.
// ---------------------------------------------------------------------------

import { Schema } from "effect";

// ---------------------------------------------------------------------------
// Status: the four states a connection can be in. `expired` is the one this
// whole feature exists for (Google's 7-day dev-token revocation): the credential
// authenticated fine yesterday and now returns 401/403. `degraded` covers any
// other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is
// "never checked / no health check configured".
// ---------------------------------------------------------------------------

export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]);
export type HealthStatus = typeof HealthStatus.Type;

// ---------------------------------------------------------------------------
// HealthCheckSpec, the persisted configuration: which operation to run, any
// pinned arguments, and the dot-path to the identity field. Stored inside the
// owning plugin's opaque integration config; core never parses it (the plugin
// reads it back in `checkHealth`).
// ---------------------------------------------------------------------------

export const HealthCheckSpec = Schema.Struct({
  /** The tool / operation name to invoke (the plugin maps this to its binding). */
  operation: Schema.String,
  /** Pinned arguments merged into the probe call. Required for identity
   *  endpoints that take a fixed parameter (e.g. People API `resourceName`). */
  args: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
  /** Dot-path into the successful response body whose value is shown as the
   *  connection's identity (e.g. `emailAddresses.0.value`, `user.login`). */
  identityField: Schema.optional(Schema.String),
});
export type HealthCheckSpec = typeof HealthCheckSpec.Type;

// ---------------------------------------------------------------------------
// HealthCheckResponseSample: one scalar leaf from the actual response body,
// `path` (the dot-path that would resolve it) plus `value` (a truncated string
// rendering). The live preview shows these so the user can see what the chosen
// operation really returns and pick the right identity field. Sampled from the
// real response, so it works even when a spec's response schema is thin/absent.
// ---------------------------------------------------------------------------

export const HealthCheckResponseSample = Schema.Struct({
  path: Schema.String,
  value: Schema.String,
});
export type HealthCheckResponseSample = typeof HealthCheckResponseSample.Type;

// ---------------------------------------------------------------------------
// HealthCheckResult: the outcome of running a probe. `httpStatus` and `detail`
// are diagnostic; `identity` is the extracted display value when the check
// succeeded and an `identityField` was configured (and resolved); `responseSample`
// carries a bounded set of the actual returned fields for the live preview.
// ---------------------------------------------------------------------------

export const HealthCheckResult = Schema.Struct({
  status: HealthStatus,
  /** The HTTP status the probe observed, when the check ran against HTTP. */
  httpStatus: Schema.optional(Schema.Number),
  /** Display identity extracted from `identityField`, when present. */
  identity: Schema.optional(Schema.String),
  /** Epoch ms the check ran. */
  checkedAt: Schema.Number,
  /** Human-readable diagnostic (error message, "no health check configured"). */
  detail: Schema.optional(Schema.String),
  /** Bounded sample of scalar fields from the response body, for the live
   *  preview ("show me what this operation returns"). */
  responseSample: Schema.optional(Schema.Array(HealthCheckResponseSample)),
});
export type HealthCheckResult = typeof HealthCheckResult.Type;

// ---------------------------------------------------------------------------
// HealthCheckCandidate: one operation the user can pick as the health check,
// projected from the plugin's stored operations. The editor lists these ranked
// (non-destructive first, then fewest required args) so the obvious "GET /me"
// style identity endpoint floats to the top.
// ---------------------------------------------------------------------------

export const HealthCheckCandidateParameter = Schema.Struct({
  name: Schema.String,
  /** Where the parameter is carried (e.g. "query", "path", "header"). */
  location: Schema.String,
  required: Schema.Boolean,
  description: Schema.optional(Schema.String),
});
export type HealthCheckCandidateParameter = typeof HealthCheckCandidateParameter.Type;

// ---------------------------------------------------------------------------
// HealthCheckResponseField: one scalar leaf the candidate operation can return,
// projected from its response schema: `path` (the dot-path to set as
// `HealthCheckSpec.identityField`) and a `type` display label ("string",
// "number", "string[]", …). Feeds the typed identity picker.
// ---------------------------------------------------------------------------

export const HealthCheckResponseField = Schema.Struct({
  path: Schema.String,
  type: Schema.String,
});
export type HealthCheckResponseField = typeof HealthCheckResponseField.Type;

export const HealthCheckCandidate = Schema.Struct({
  /** The operation / tool name to store as `HealthCheckSpec.operation`. */
  operation: Schema.String,
  /** HTTP method, lower-cased ("get", "post", …), for display + ranking. */
  method: Schema.String,
  /** How many parameters are required to call it (ranking key: fewer is better). */
  requiredArgCount: Schema.Number,
  /** True for mutating methods (post/put/patch/delete), ranked last and shown
   *  with a warning, since a health check should be safe to run repeatedly. */
  destructive: Schema.Boolean,
  /** Operation summary / description for display, when known. */
  summary: Schema.optional(Schema.String),
  /** The operation's parameters, so the editor can offer pinned-arg inputs. */
  parameters: Schema.optional(Schema.Array(HealthCheckCandidateParameter)),
  /** Scalar leaves from the operation's response schema, for the typed identity
   *  picker. Projected via `projectResponseFields`. */
  responseFields: Schema.optional(Schema.Array(HealthCheckResponseField)),
});
export type HealthCheckCandidate = typeof HealthCheckCandidate.Type;

// ---------------------------------------------------------------------------
// Pure helpers, shared so classification + identity extraction behave
// identically wherever a probe is interpreted.
// ---------------------------------------------------------------------------

/** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth
 *  wall), everything else degraded. */
export const classifyHttpStatus = (status: number): HealthStatus => {
  if (status >= 200 && status < 300) return "healthy";
  if (status === 401 || status === 403) return "expired";
  return "degraded";
};

/** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a
 *  display string. Numeric segments index arrays. Returns undefined when the
 *  path is empty, missing, or lands on a non-scalar. */
export const extractIdentity = (data: unknown, dotpath?: string): string | undefined => {
  if (dotpath == null || dotpath.length === 0) return undefined;
  let current: unknown = data;
  for (const segment of dotpath.split(".")) {
    if (current == null) return undefined;
    if (Array.isArray(current)) {
      const index = Number(segment);
      if (!Number.isInteger(index) || index < 0 || index >= current.length) return undefined;
      current = current[index];
      continue;
    }
    if (typeof current !== "object") return undefined;
    current = (current as Record<string, unknown>)[segment];
  }
  if (typeof current === "string") return current;
  if (typeof current === "number" || typeof current === "boolean") return String(current);
  return undefined;
};

/** The best identity tier among a candidate's response fields, or -1 when its
 *  schema exposes nothing account-naming. Feeds the identity-aware ranking:
 *  a call that returns an email is a better probe than a generic list.
 *
 *  Only SINGULAR paths count: an identity field under an array segment
 *  (`aliases.0.creator.email`) names people in a collection, not the caller,
 *  and must not out-rank the actual whoami call (`user.email`). */
export const candidateIdentityTier = (candidate: HealthCheckCandidate): number => {
  let best = -1;
  for (const field of candidate.responseFields ?? []) {
    if (field.path.split(".").some((segment) => /^\d+$/.test(segment))) continue;
    const tier = identityPathTier(field.path);
    if (tier === -1) continue;
    if (best === -1 || tier < best) best = tier;
  }
  return best;
};

/** Sort candidates identity-first: non-destructive before destructive, then
 *  calls whose response carries an identity field (email beats login beats
 *  name), then the generic order (fewest required args, GET first,
 *  alphabetical). Tiers are computed once per candidate, not per comparison:
 *  the field walk is linear in response fields and the comparator runs
 *  O(n log n) times on Graph-sized specs. */
export const sortHealthCheckCandidatesByIdentity = (
  candidates: readonly HealthCheckCandidate[],
): HealthCheckCandidate[] =>
  candidates
    .map((candidate) => ({ candidate, tier: candidateIdentityTier(candidate) }))
    .sort((a, b) => {
      if (a.candidate.destructive !== b.candidate.destructive)
        return a.candidate.destructive ? 1 : -1;
      if (a.tier !== b.tier) {
        if (a.tier === -1) return 1;
        if (b.tier === -1) return -1;
        return a.tier - b.tier;
      }
      return compareHealthCheckCandidates(a.candidate, b.candidate);
    })
    .map(({ candidate }) => candidate);

/** Stable ranking for the candidate list: non-destructive before destructive,
 *  then fewest required args, then by method (get first), then alphabetical.
 *  Returns a negative/zero/positive comparator value. */
export const compareHealthCheckCandidates = (
  a: HealthCheckCandidate,
  b: HealthCheckCandidate,
): number => {
  if (a.destructive !== b.destructive) return a.destructive ? 1 : -1;
  if (a.requiredArgCount !== b.requiredArgCount) return a.requiredArgCount - b.requiredArgCount;
  if (a.method !== b.method) {
    if (a.method === "get") return -1;
    if (b.method === "get") return 1;
    return a.method < b.method ? -1 : 1;
  }
  return a.operation < b.operation ? -1 : a.operation > b.operation ? 1 : 0;
};

// ---------------------------------------------------------------------------
// Response-field projection: two pure walkers that feed the typed identity
// picker and the live preview. `projectResponseFields` walks a (normalized)
// response SCHEMA to enumerate selectable identity paths; `extractResponseFields`
// walks an actual response BODY so the preview can show what the operation
// really returns even when the schema is thin or absent.
// ---------------------------------------------------------------------------

const SCALAR_TYPE_LABELS: Record<string, string> = {
  string: "string",
  number: "number",
  integer: "number",
  boolean: "boolean",
};

/** Pick the first non-null entry of an OpenAPI `type` (which may be a union like
 *  `["string", "null"]`), as a string or undefined. */
const primaryType = (raw: unknown): string | undefined => {
  if (Array.isArray(raw)) {
    const found = raw.find((x) => typeof x === "string" && x !== "null");
    return typeof found === "string" ? found : undefined;
  }
  return typeof raw === "string" ? raw : undefined;
};

/** Flatten one schema node's composition into a single effective shape: follow
 *  `$ref`s and merge the properties of EVERY `allOf`/`oneOf`/`anyOf` member, so a
 *  discriminated union (or multi-member allOf) contributes the union of all its
 *  branches' fields, not just the first. Returns the merged properties, any array
 *  `items`, a scalar type label (when the node is a leaf), and the set of refs
 *  followed (so children inherit a cycle guard). */
const flattenSchemaShape = (
  node: unknown,
  defs: Record<string, unknown>,
  seenRefs: ReadonlySet<string>,
): {
  readonly props: Map<string, unknown>;
  readonly items: unknown;
  readonly scalarType: string | undefined;
  readonly refs: ReadonlySet<string>;
} => {
  const props = new Map<string, unknown>();
  let items: unknown = undefined;
  let scalarType: string | undefined;
  const refs = new Set<string>(seenRefs);
  // Bounded work queue over composition members (refs + allOf/oneOf/anyOf) at
  // this one level; the guard bounds pathological self-referential composition.
  const stack: unknown[] = [node];
  let guard = 0;
  while (stack.length > 0 && guard++ < 500) {
    const current = stack.pop();
    if (current == null || typeof current !== "object") continue;
    const s = current as Record<string, unknown>;

    const ref = s["$ref"];
    if (typeof ref === "string") {
      const name = ref.startsWith("#/$defs/") ? ref.slice("#/$defs/".length) : undefined;
      if (name !== undefined && !refs.has(name)) {
        refs.add(name);
        const target = defs[name];
        if (target != null && typeof target === "object") stack.push(target);
      }
      continue;
    }

    for (const key of ["allOf", "oneOf", "anyOf"] as const) {
      const members = s[key];
      if (Array.isArray(members)) for (const member of members) stack.push(member);
    }

    const type = primaryType(s["type"]);
    if (type !== undefined && SCALAR_TYPE_LABELS[type] !== undefined)
      scalarType = SCALAR_TYPE_LABELS[type];
    if (items === undefined && s["items"] !== undefined) items = s["items"];
    const ownProps = s["properties"];
    if (ownProps != null && typeof ownProps === "object") {
      for (const [key, child] of Object.entries(ownProps as Record<string, unknown>)) {
        if (!props.has(key)) props.set(key, child);
      }
    }
  }
  return { props, items, scalarType, refs };
};

/**
 * Enumerate the scalar leaves of a response schema as selectable identity paths.
 * Returns dot-paths (`a.b.0.c`) paired with a display type label. Array items use
 * the literal `"0"` segment to match `extractIdentity`'s numeric-index
 * convention. Resolves `#/$defs/X` refs against `defs` with a cycle guard, and
 * merges ALL `allOf`/`oneOf`/`anyOf` members so discriminated-union branches each
 * contribute their fields. Traverses BREADTH-FIRST so shallow scalars (`email`,
 * `id`, `username`) are emitted before deep nested fields and aren't starved by
 * the field cap. Bounded to depth 5 and 50 (deduped) fields.
 */
export const projectResponseFields = (
  schema: unknown,
  defs: Record<string, unknown> = {},
): HealthCheckResponseField[] => {
  const fields: HealthCheckResponseField[] = [];
  const seenPaths = new Set<string>();
  const MAX_DEPTH = 5;
  const MAX_FIELDS = 50;
  const MAX_FRONTIER = 2000;

  type Item = {
    readonly node: unknown;
    readonly path: string;
    readonly depth: number;
    readonly seenRefs: ReadonlySet<string>;
  };

  let frontier: Item[] = [{ node: schema, path: "", depth: 0, seenRefs: new Set<string>() }];
  while (frontier.length > 0 && fields.length < MAX_FIELDS) {
    const nextFrontier: Item[] = [];
    for (const item of frontier) {
      if (fields.length >= MAX_FIELDS) break;
      if (item.node == null || typeof item.node !== "object") continue;
      const { props, items, scalarType, refs } = flattenSchemaShape(item.node, defs, item.seenRefs);

      // Object: queue children one level deeper (shallow fields emit first).
      if (props.size > 0) {
        for (const [key, child] of props) {
          if (nextFrontier.length >= MAX_FRONTIER) break;
          nextFrontier.push({
            node: child,
            path: item.path === "" ? key : `${item.path}.${key}`,
            depth: item.depth + 1,
            seenRefs: refs,
          });
        }
        continue;
      }
      // Array: descend into items with the numeric-index segment.
      if (items !== undefined) {
        nextFrontier.push({
          node: items,
          path: item.path === "" ? "0" : `${item.path}.0`,
          depth: item.depth + 1,
          seenRefs: refs,
        });
        continue;
      }
      // Scalar leaf.
      if (scalarType !== undefined && item.path !== "" && !seenPaths.has(item.path)) {
        seenPaths.add(item.path);
        fields.push({ path: item.path, type: scalarType });
      }
    }
    frontier = nextFrontier.filter((item) => item.depth <= MAX_DEPTH);
  }
  return fields;
};

/**
 * Walk an actual JSON response body and return its scalar leaves as
 * `{ path, value }` rows (value stringified + truncated). Drives the live
 * preview's "show me what this returns" list. Bounded to depth 4, 25 fields,
 * and ~120-char values.
 */
export const extractResponseFields = (data: unknown): HealthCheckResponseSample[] => {
  const out: HealthCheckResponseSample[] = [];
  const MAX_DEPTH = 4;
  const MAX_FIELDS = 25;
  const MAX_VALUE = 120;

  const render = (v: string): string => (v.length > MAX_VALUE ? `${v.slice(0, MAX_VALUE)}...` : v);

  const visit = (node: unknown, path: string, depth: number) => {
    if (out.length >= MAX_FIELDS || node == null) return;

    if (Array.isArray(node)) {
      if (depth >= MAX_DEPTH) return;
      for (let i = 0; i < node.length; i++) {
        if (out.length >= MAX_FIELDS) break;
        visit(node[i], path === "" ? String(i) : `${path}.${i}`, depth + 1);
      }
      return;
    }

    if (typeof node === "object") {
      if (depth >= MAX_DEPTH) return;
      for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
        if (out.length >= MAX_FIELDS) break;
        visit(child, path === "" ? key : `${path}.${key}`, depth + 1);
      }
      return;
    }

    if (
      path !== "" &&
      (typeof node === "string" || typeof node === "number" || typeof node === "boolean")
    ) {
      out.push({ path, value: render(String(node)) });
    }
  };

  visit(data, "", 0);
  return out;
};

// ---------------------------------------------------------------------------
// Identity auto-pick: choose the response field that most likely names the
// account, so the connect flow can default the identity instead of asking.
// Ranked by how account-naming the leaf key is (email > login/username >
// name/displayName > id), shallower paths first within a tier. Returns
// undefined when nothing plausible exists (pure liveness check).
// ---------------------------------------------------------------------------

const IDENTITY_KEY_TIERS: readonly (readonly string[])[] = [
  ["email", "emailaddress", "mail", "userprincipalname"],
  ["login", "username", "handle", "slug"],
  ["displayname", "name", "fullname"],
  ["id", "userid", "accountid"],
];

/** How account-naming a dot-path's leaf key is: tier index (0 = email, best),
 *  or -1 when it doesn't look like an identity at all. Shared by the sample
 *  picker below and the candidate ranking in the connect flow. */
export const identityPathTier = (path: string): number => {
  const segments = path.split(".");
  const named = segments.filter((segment) => !/^\d+$/.test(segment));
  const leaf = (named[named.length - 1] ?? "").toLowerCase();
  return IDENTITY_KEY_TIERS.findIndex((keys) => keys.includes(leaf));
};

/** Order sample rows identity-first: rows whose leaf key names the account
 *  (email > login > name > id) come before the rest, which keep response
 *  order. Every surface that renders a probe response uses this, so the
 *  interesting fields always lead. Stable within tiers. */
export const rankResponseSample = (
  sample: readonly HealthCheckResponseSample[],
): HealthCheckResponseSample[] =>
  sample
    .map((row, index) => ({ row, index, tier: identityPathTier(row.path) }))
    .sort((a, b) => {
      if (a.tier !== b.tier) {
        if (a.tier === -1) return 1;
        if (b.tier === -1) return -1;
        return a.tier - b.tier;
      }
      return a.index - b.index;
    })
    .map(({ row }) => row);
