/**
 * SSH HTTP layer — shared types & helpers.
 *
 * Extracted verbatim from src/bridge/ssh-api.ts (split refactor). Holds the
 * dependency-injection surface (`SshApiDeps`), the tester contracts, and the
 * validation / presentation helpers used by both routers:
 *   ssh-user-api.ts  — createSshUserRouter
 *   ssh-admin-api.ts — createSshAdminRouter
 * The original module remains as a re-export barrel, so external importers
 * are unaffected.
 */

import { Router, type Request, type Response, type NextFunction, type RequestHandler } from 'express';
import type Database from 'better-sqlite3';

import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js';
import type { SshGrant, SshGrantsRepo, SshGrantSubjectType } from '../ssh/grants-repo.js';
import type { SshAuditRepo, SshAuditRow } from '../ssh/audit-repo.js';
import type { SshAbuseRepo } from '../ssh/abuse-repo.js';
import type { SshAccessResolver } from '../ssh/access.js';
import type { MaintenanceController } from '../ssh/maintenance.js';
import type { AdminRateLimiter } from '../ssh/admin-rate-limit.js';
import { logger } from '../logger.js';

// ──────────────────────────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────────────────────────

export interface SshTesterArgs {
  connection: SshConnection;
  decryptedKey: Buffer;
  passphrase: Buffer | null;
  timeoutMs: number;
}

export type SshTesterVerdict = 'first_observe' | 'mismatch' | 'pass' | 'alg_not_allowed';

export interface SshTesterResult {
  /** SHA256 fingerprint of the observed host key. */
  fingerprint: string;
  /** Base64 of the observed host key (wire format). */
  hostKeyB64: string;
  hostKeyType: string;
  verdict: SshTesterVerdict;
}

export interface SshTester {
  test(args: SshTesterArgs): Promise<SshTesterResult>;
}

export interface SshEncryptResult {
  blob: Buffer;
  passphraseBlob: Buffer | null;
  keyVersion: number;
  fingerprint: string;
  publicKey: string;
}

export interface SshApiDeps {
  db: Database.Database;
  requireAuth: RequestHandler;
  requireAdmin: RequestHandler;
  getUserId(req: Request): string | null;
  isAdmin(req: Request): boolean;
  getOrgIds(req: Request): string[];
  /**
   * Spaces foundation (spec §11). Authorizes a `space_id` supplied on create /
   * list against the requesting viewer and resolves the space's existence.
   * When omitted, space-scoped requests are rejected (no way to authorize
   * them) while legacy per-user create/list keeps working. Mirrors mcp-api.
   */
  getSpace?: (spaceId: string, viewer?: Express.User) => Promise<{ id: string } | null>;
  /**
   * Whether the bridge wired the auth subsystem. When `false` (no-auth
   * single-user deployment) requests carry no `req.user`, so the router
   * synthesizes a stable `local` owner — otherwise every endpoint 401s on
   * the null userId and SSH is unusable. Mirrors memory-api /
   * user-folder-api. Defaults to `true` for existing (auth-only) callers.
   */
  authActive?: boolean;

  connectionRepo: SshConnectionRepo;
  grantsRepo: SshGrantsRepo;
  auditRepo: SshAuditRepo;
  abuseRepo: SshAbuseRepo;
  accessResolver: SshAccessResolver;

  maintenance: MaintenanceController;
  forceUnlockLimiter: AdminRateLimiter;

  /**
   * Encrypt a PEM (and optional passphrase) into the envelope-encrypted blobs
   * stored in ssh_connections. ownerId=null = system DEK (global connection),
   * ownerId=<uid> = user DEK.
   */
  encryptKeyMaterial(ownerId: string | null, pem: Buffer, passphrase: Buffer | null, spaceId?: string | null): SshEncryptResult;

  /**
   * Decrypt blob using the correct DEK. Buffer must be cleared by caller.
   * spaceId (Space-as-Principal P2): when given, the space DEK is tried first
   * with a fallback to the owner/system DEK (lets pre-migration owner-encrypted
   * blobs of a now-space-owned connection still decrypt).
   */
  decryptKeyMaterial(ownerId: string | null, blob: Buffer, spaceId?: string | null): Buffer;
  decryptPassphrase(ownerId: string | null, blob: Buffer | null, spaceId?: string | null): Buffer | null;

  /**
   * Generate a fresh keypair (no passphrase). The caller is expected to feed
   * the returned `privateKeyPem` back through `encryptKeyMaterial` for at-rest
   * encryption.
   */
  generateKeypair(keyType: 'ed25519' | 'rsa-4096'): {
    privateKeyPem: Buffer;
    publicKey: string;
  };

  /**
   * Derive the OpenSSH-format public key (`<algo> <base64>`) from the stored
   * private-key blob. Handles decrypt + format + buffer-clear internally.
   */
  derivePublicKey(
    ownerId: string | null,
    blob: Buffer,
    passphraseBlob: Buffer | null,
    spaceId?: string | null,
  ): string;

  /** SSH dial helper — injected so tests don't need a real SSH server. */
  sshTester: SshTester;

  /** Connection test timeout. Defaults to 5s. */
  connectionTestTimeoutMs?: number;

  /**
   * Optional hook called after a grant is revoked / deleted so the caller can
   * kick any active WebSocket viewers that depended on that grant. The hook
   * is a no-op pass-through (returns 0) when the SSH console subsystem isn't
   * wired (e.g. tests, or `ssh.console.enabled=false`).
   *
   * Implementation typically calls `SessionRegistry.revokeAccessFor`.
   */
  onAccessRevoked?: (args: { connectionId: string; userId: string }) => number | void;
}

// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────

export const REASON_MIN_CHARS = 8;

export function validateReason(reason: unknown): string | null {
  if (typeof reason !== 'string') return 'reason is required (string >= 8 chars)';
  if (reason.length < REASON_MIN_CHARS) return `reason must be at least ${REASON_MIN_CHARS} characters`;
  return null;
}

export function maintenance503(maintenance: MaintenanceController, res: Response): boolean {
  if (!maintenance.isActive()) return false;
  res.setHeader('Retry-After', '30');
  res.status(503).json({
    error: 'rotation_in_progress',
    detail: 'SSH subsystem is in maintenance — try again shortly.',
    snapshot: maintenance.snapshot(),
  });
  return true;
}

export function safePort(p: unknown): number | null {
  if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) return null;
  return p;
}

export function safeString(v: unknown, max = 1024): string | null {
  if (typeof v !== 'string') return null;
  if (v.length === 0 || v.length > max) return null;
  return v;
}

export function safeOptionalString(v: unknown, max = 1024): string | null | undefined {
  if (v === undefined || v === null) return undefined;
  if (typeof v !== 'string') return null;
  if (v.length > max) return null;
  return v;
}

export function safePathPrefix(v: unknown): string | null {
  const s = safeString(v, 1024);
  if (s === null) return null;
  if (s.length === 0) return null;
  // Reject any `..` parent-ref segment using either separator. POSIX and
  // Windows-style prefixes (drive letters, UNC, no-leading-slash) are
  // all allowed; the runtime validateRemotePath enforces containment.
  const segments = s.split(/[\\/]/);
  if (segments.includes('..')) return null;
  return s;
}

export function safeFingerprint(v: unknown): string | null {
  if (typeof v !== 'string') return null;
  // SHA256:<base64> — base64 chars + ='s, ~46 chars typical.
  if (!/^SHA256:[A-Za-z0-9+/=]{20,80}$/.test(v)) return null;
  return v;
}

export function safeUuid(v: unknown): string | null {
  if (typeof v !== 'string') return null;
  if (!/^[a-f0-9-]{8,}$/.test(v)) return null;
  return v;
}

export function safeExpiresAt(v: unknown): string | null | undefined {
  if (v === undefined || v === null) return undefined;
  if (typeof v !== 'string') return null;
  // ISO8601 — Date.parse returns NaN for invalid strings.
  const t = Date.parse(v);
  if (Number.isNaN(t)) return null;
  return new Date(t).toISOString();
}

export function safeSubjectType(v: unknown): SshGrantSubjectType | null {
  return v === 'user' || v === 'org' ? v : null;
}

/**
 * Normalize a space id from a request body or query (spec §11). Accepts both
 * `space_id` (snake, wire convention) and `spaceId` (camel). Returns: string
 * (a space), null (explicit global / space-less), or undefined (not given).
 * Mirrors mcp-api.readSpaceId.
 */
export function readSpaceId(src: Record<string, unknown> | undefined): string | null | undefined {
  if (!src) return undefined;
  const raw = (src.space_id ?? src.spaceId) as unknown;
  if (raw === undefined) return undefined;
  if (raw === null || raw === '') return null;
  // 文字列のみ受理（数値等の coerce を避ける）。
  return typeof raw === 'string' ? raw : undefined;
}

export function presentConnection(conn: SshConnection): Record<string, unknown> {
  // Strip the encrypted blob fields and the pending verify token (the token
  // is only surfaced on /test responses; subsequent GETs do not expose it
  // again — the user must hold onto it).
  return {
    id: conn.id,
    ownerId: conn.ownerId,
    label: conn.label,
    host: conn.host,
    port: conn.port,
    username: conn.username,
    keyVersion: conn.keyVersion,
    keyFingerprint: conn.keyFingerprint,
    hostKeyType: conn.hostKeyType,
    hostKeyFingerprint: conn.hostKeyFingerprint,
    hostKeyRecordedAt: conn.hostKeyRecordedAt,
    hostKeyVerifiedAt: conn.hostKeyVerifiedAt,
    hostKeyPending: conn.hostKeyPending,
    hostKeyPendingFingerprint: conn.hostKeyPendingFingerprint,
    hostKeyPendingSource: conn.hostKeyPendingSource,
    commandDenyPatterns: conn.commandDenyPatterns,
    commandAllowPatterns: conn.commandAllowPatterns,
    remotePathPrefix: conn.remotePathPrefix,
    allowRemoteUnrestricted: conn.allowRemoteUnrestricted,
    allowPrivateAddresses: conn.allowPrivateAddresses,
    enabled: conn.enabled,
    disabledByAdmin: conn.disabledByAdmin,
    disabledByAdminReason: conn.disabledByAdminReason,
    disabledByAdminAt: conn.disabledByAdminAt,
    disabledByAdminUserId: conn.disabledByAdminUserId,
    createdAt: conn.createdAt,
    updatedAt: conn.updatedAt,
  };
}

export function presentGrant(g: SshGrant): Record<string, unknown> {
  return { ...g };
}

export function presentAuditRow(a: SshAuditRow): Record<string, unknown> {
  return { ...a };
}

export function safeLimit(v: unknown, def = 50, max = 500): number {
  if (typeof v === 'string') {
    const n = Number(v);
    if (Number.isInteger(n) && n > 0 && n <= max) return n;
  }
  if (typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= max) return v;
  return def;
}

export function jsonError(res: Response, status: number, error: string, detail?: unknown): void {
  if (detail === undefined) {
    res.status(status).json({ error });
  } else {
    res.status(status).json({ error, detail });
  }
}

// ──────────────────────────────────────────────────────────────────────
// User router — /api/ssh/connections + /api/ssh/grants
// ──────────────────────────────────────────────────────────────────────

/**
 * No-auth single-user mode: synthesize a `local` user so the per-user
 * connection store has a stable owner and `getUserId`/`isAdmin` resolve.
 * Role `admin` keeps the connection-management surface fully usable for the
 * lone local operator (in single-user mode the admin/grant checks only ever
 * compare against `local`-owned rows, so this never widens cross-user access).
 * No-op when auth is active (Passport has already populated `req.user`).
 */
export function makeSshLocalUserMiddleware(authActive: boolean): RequestHandler {
  return (req: Request, _res: Response, next: NextFunction) => {
    if (!authActive && !(req as { user?: unknown }).user) {
      (req as { user?: unknown }).user = { id: 'local', role: 'admin', orgIds: [] };
    }
    next();
  };
}
