/**
 * Centralized access decision for an SSH connection.
 *
 * Design rationale (rev 4):
 *   resolveConnection() (in connection-repo) is a raw lookup with NO access
 *   check. This function is the SINGLE gate that callers MUST consult before
 *   exposing or using a connection. Splitting raw lookup from access decision
 *   means a granted user can be enumerated by their grant without first
 *   passing an owner-only getById check.
 *
 * Per-space isolation (Phase 3, spec §11) — model = fully independent
 * (space-only). The job/request carries `spaceId`. A space-scoped connection
 * (space_id NOT NULL) is usable iff it belongs to the SAME space as the job
 * AND the caller can see that space. For a job, `args.spaceId` already IS the
 * job's space (derived from the owning task; for no-auth/local it is the
 * personal space) — so the caller having reached this gate with that spaceId
 * implies space membership, and a space match is sufficient.
 *
 *   `owner_id` is NO LONGER the access key for space-scoped connections — it
 *   only selects the per-user DEK downstream when decrypting the private key.
 *   A user who OWNS a space-B connection is DENIED when their job runs in
 *   space A (cross-space isolation). No per-space DEK is introduced.
 *
 *   Order of decision:
 *     1. connection is disabled            → deny ('disabled')
 *     2. connection is space-scoped
 *        (space_id NOT NULL):
 *          a. job spaceId matches          → allow (via='space')
 *          b. otherwise                    → deny ('space_mismatch')
 *     3. connection is space-less
 *        (space_id IS NULL — legacy/system),
 *        evaluated with LEGACY rules so
 *        non-space jobs keep working:
 *          a. caller owns it               → allow (via='owner')
 *          b. caller is admin AND
 *             adminBypassesGrants           → allow (via='admin')
 *          c. active grant exists           → allow (via='grant')
 *          d. otherwise                     → deny ('no_grant')
 *
 *   Legacy fallback (2026-06-18): a connection with space_id IS NULL combined
 *   with a job whose spaceId is null/absent resolves via the pre-Phase-3
 *   owner/admin/grant rules. This preserves both (a) system/global connections
 *   (owner_id IS NULL, space_id IS NULL) reachable only by no-space jobs, and
 *   (b) any user-owned connection a migration has not yet moved into a space.
 *   A space-less connection is intentionally NOT visible to a space-scoped job
 *   (space jobs see ONLY their space — see 2a/2b), and a space-scoped
 *   connection is NOT visible to a no-space job (2b denies when spaceId differs,
 *   which includes null/undefined).
 *
 *   Disabled connections deny EVERYONE, including the owner — by-design,
 *   so that admin-disabled connections can't be used until an admin re-enables.
 *
 * Plan: docs/superpowers/plans/2026-06-18-space-mcp-ssh-isolation.md (Phase 3).
 *       docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1, base).
 */
import type { SshConnection } from './connection-repo.js';
import type { SshGrant, SshGrantsRepo } from './grants-repo.js';

export type AccessVia = 'owner' | 'admin' | 'grant' | 'space';

export type AccessDenyReason = 'disabled' | 'no_grant' | 'space_mismatch';

export interface AccessDecision {
  allowed: boolean;
  via?: AccessVia;
  reason?: AccessDenyReason;
  grant?: SshGrant;
}

export interface ResolveAccessArgs {
  connection: SshConnection;
  userId: string;
  isAdmin: boolean;
  pieceName: string;
  orgIds: string[];
  /**
   * The space of the job/request asking to use the connection. null/undefined
   * = a no-space (legacy) job. Per the space-only model, a space-scoped
   * connection is usable iff its space_id equals this value; a space-less
   * connection is reachable only by a no-space job (legacy fallback).
   */
  spaceId?: string | null;
  /** Defaults to new Date(). Used to evaluate grant expiry. */
  now?: Date;
}

export interface AccessConfig {
  /** When true, admins bypass the grant check (still audited at use sites). */
  adminBypassesGrants: boolean;
}

export interface SshAccessResolver {
  resolveAccess(args: ResolveAccessArgs): AccessDecision;
}

export function createAccessResolver(
  grantsRepo: SshGrantsRepo,
  config: AccessConfig,
): SshAccessResolver {
  return {
    resolveAccess(args) {
      // 1. Disabled connections deny everyone (incl. owner).
      if (!args.connection.enabled) {
        return { allowed: false, reason: 'disabled' };
      }

      const connSpaceId = args.connection.spaceId ?? null;
      const jobSpaceId = args.spaceId ?? null;

      // 2. Space-scoped connection: access = same-space membership ONLY.
      //    owner_id no longer grants USE access here (it only picks the DEK
      //    downstream). A space-B connection is denied to a space-A job even
      //    when the caller owns it — cross-space isolation.
      if (connSpaceId !== null) {
        if (jobSpaceId !== null && connSpaceId === jobSpaceId) {
          return { allowed: true, via: 'space' };
        }
        return { allowed: false, reason: 'space_mismatch' };
      }

      // 3. Space-less connection (space_id IS NULL — legacy/system). Reachable
      //    only by a no-space (legacy) job, via the original owner/admin/grant
      //    rules. A space-scoped job never sees space-less connections.
      if (jobSpaceId !== null) {
        return { allowed: false, reason: 'space_mismatch' };
      }
      // 3a. Owner.
      if (args.connection.ownerId !== null && args.connection.ownerId === args.userId) {
        return { allowed: true, via: 'owner' };
      }
      // 3b. Admin bypass (if configured).
      if (args.isAdmin && config.adminBypassesGrants) {
        return { allowed: true, via: 'admin' };
      }
      // 3c. Active grant?
      const grant = grantsRepo.findActiveGrant({
        connectionId: args.connection.id,
        userId: args.userId,
        orgIds: args.orgIds,
        pieceName: args.pieceName,
        now: (args.now ?? new Date()).toISOString(),
      });
      if (grant) {
        return { allowed: true, via: 'grant', grant };
      }
      return { allowed: false, reason: 'no_grant' };
    },
  };
}
