import { TEAM_ROLES, TEAMS } from '@pig/core';
import type { Database, PlatformSettings } from '@pig/db';
import { invites, platformSettings, teamMemberships, users } from '@pig/db';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { Hono } from 'hono';
import { createHash, randomBytes } from 'node:crypto';
import { z } from 'zod';
import { requireCapability } from '../lib/auth';
import type { Config } from '../lib/config';
import {
  apiError,
  bodylessMutation,
  MutationError,
  mutation,
  type ApiEnv,
} from '../lib/mutation';
import {
  decryptSecret,
  encryptionReady,
  encryptSecret,
  SecretConfigurationError,
} from '../lib/secrets';

const SETTINGS_ID = 'default';

export function normaliseInferenceEndpoint(value: string): string {
  return value.replace(/\/+$/, '');
}

/**
 * Is this endpoint something other than the Prime *compute* API host?
 *
 * A blocklist of exactly one hostname, which is only sound because it is no
 * longer a gate on anything: it used to admit an arbitrary operator-supplied
 * URL into `platform_settings`, and "anything but this one host" is not a safe
 * rule for a URL the server will later call. The writable field is gone (see
 * `platformSettingsSchema`), so this now only reports on `PIGGY_INFERENCE_BASE`
 * — a value that arrives from the deployment environment, where an operator who
 * can set it can already do anything the process can. Kept because pointing
 * inference at the compute host is a real and easy mistake, and the two hosts
 * are genuinely different services.
 */
export function isInferenceEndpoint(value: string): boolean {
  try {
    return new URL(value).hostname !== 'api.primeintellect.ai';
  } catch {
    return false;
  }
}

/**
 * `piggyModel` and `piggyInferenceBase` are deliberately absent.
 *
 * The columns still exist, but nothing reads them: `apps/piggy` loads its model
 * and inference base from `process.env` at boot and never consults
 * `platform_settings`. Accepting writes here gave an admin a field that saved,
 * reported success, and changed nothing about the running agent. The truth is
 * reported instead — see `piggyRuntimeStatus` — and `.strict()` now rejects
 * either key rather than pretending to store it.
 */
export const platformSettingsSchema = z
  .object({
    piggyEnabled: z.boolean().optional(),
    primeApiKey: z.string().trim().min(16).max(1000).optional(),
    clearPrimeApiKey: z.boolean().optional(),
    primeSyncEnabled: z.boolean().optional(),
    primeSyncIntervalMinutes: z.number().int().min(1).max(1440).optional(),
  })
  .strict()
  .refine((value) => Object.values(value).some((item) => item !== undefined), 'No settings supplied.')
  .refine(
    (value) => !(value.primeApiKey && value.clearPrimeApiKey),
    'A credential cannot be set and cleared together.',
  );

export const inviteCreateSchema = z
  .object({
    email: z.string().trim().email().max(200).optional(),
    team: z.enum(TEAMS).optional(),
    role: z.enum(TEAM_ROLES).default('member'),
    expiresAt: z.string().datetime().optional(),
    usesRemaining: z.number().int().min(1).max(100).default(1),
  })
  .strict();

export const memberAccessSchema = z
  .object({
    isPlatformAdmin: z.boolean(),
    memberships: z
      .array(
        z.object({
          team: z.enum(TEAMS),
          role: z.enum(TEAM_ROLES),
        }),
      )
      .max(TEAMS.length)
      .refine(
        (memberships) => new Set(memberships.map(({ team }) => team)).size === memberships.length,
        'Each team may appear only once.',
      ),
  })
  .strict();

function initialSettings(config: Config) {
  return {
    id: SETTINGS_ID,
    // Seeded from the environment so a fresh row is not misleading, then never
    // updated again: these two columns are vestigial, and dropping them is a
    // migration rather than a route change.
    piggyModel: config.PIGGY_MODEL,
    piggyInferenceBase: normaliseInferenceEndpoint(config.PIGGY_INFERENCE_BASE),
    piggyEnabled: config.PIGGY_ENABLED,
    primeSyncEnabled: config.PRIME_SYNC_ENABLED,
    primeSyncIntervalMinutes: config.PRIME_SYNC_INTERVAL_MINUTES,
  };
}

/** Loopback or a Compose neighbour: a probe unanswered in a second is dead. */
const PIGGY_HEALTH_TIMEOUT_MS = 1_500;

export interface PiggyChatServerHealth {
  ok: boolean;
  /**
   * The model the chat server says it is calling. Null when it did not answer,
   * and null rather than the environment's value on purpose: the API container
   * and the Piggy container hold separate copies of `PIGGY_MODEL`, so only the
   * process doing the inference can say what is actually in force.
   */
  model: string | null;
}

/**
 * Ask the Piggy chat server whether it is alive.
 *
 * `/internal/health` is unauthenticated at the other end by design, so no token
 * travels here — which is what makes this answerable for the deployment most
 * worth diagnosing, one whose `PIGGY_INTERNAL_TOKEN` is wrong. It is also the
 * only signal the API has about a missing `PIGGY_INFERENCE_API_KEY`: that key
 * never reaches this container, and Piggy exits at boot without it, so a
 * crash-looping agent shows up here as a refused connection.
 */
export async function probePiggyChatServer(
  baseUrl: string,
  fetchImpl: typeof fetch = fetch,
): Promise<PiggyChatServerHealth> {
  try {
    const response = await fetchImpl(`${baseUrl.replace(/\/+$/, '')}/internal/health`, {
      method: 'GET',
      signal: AbortSignal.timeout(PIGGY_HEALTH_TIMEOUT_MS),
    });
    if (!response.ok) {
      // Cancelled rather than left open: an undrained body holds the socket.
      await response.body?.cancel().catch(() => {});
      return { ok: false, model: null };
    }
    return { ok: true, model: reportedModel(await response.json().catch(() => null)) };
  } catch {
    return { ok: false, model: null };
  }
}

function reportedModel(payload: unknown): string | null {
  if (typeof payload !== 'object' || payload === null) return null;
  const { model } = payload as { model?: unknown };
  return typeof model === 'string' && model.length > 0 ? model : null;
}

/**
 * What is true about Piggy right now, as opposed to what the database was told.
 *
 * Every field here is derived from the environment or from a live probe. The
 * panel this feeds exists because an operator whose Piggy is silently down had
 * nothing to look at: the settings page showed a model, an endpoint and a green
 * toggle, all of which were stored values that no running process reads.
 */
export function piggyRuntimeStatus(
  row: PlatformSettings,
  config: Config,
  health: PiggyChatServerHealth | null,
) {
  const inferenceBase = normaliseInferenceEndpoint(config.PIGGY_INFERENCE_BASE ?? '');
  return {
    /** `PIGGY_ENABLED`. The outer gate; nothing in the UI can open it. */
    enabledByEnvironment: Boolean(config.PIGGY_ENABLED),
    /** The stored toggle. Gates interactive chat only — never the worker. */
    chatEnabled: row.piggyEnabled,
    internalUrlConfigured: Boolean(config.PIGGY_INTERNAL_URL),
    /** Never the token itself: a boolean is the whole of what an admin needs. */
    internalTokenConfigured: Boolean(config.PIGGY_INTERNAL_TOKEN),
    model: config.PIGGY_MODEL ?? null,
    inferenceBase: inferenceBase.length > 0 ? inferenceBase : null,
    /** False means inference is pointed at the compute API, which cannot work. */
    inferenceIsolated: inferenceBase.length > 0 ? isInferenceEndpoint(inferenceBase) : true,
    /** True, false, or null for "not probed in this response". */
    reachable: health === null ? null : health.ok,
    reportedModel: health?.model ?? null,
  };
}

export async function ensurePlatformSettings(config: Config, db: Database): Promise<PlatformSettings> {
  await db.insert(platformSettings).values(initialSettings(config)).onConflictDoNothing();
  const [row] = await db
    .select()
    .from(platformSettings)
    .where(eq(platformSettings.id, SETTINGS_ID))
    .limit(1);
  if (!row) throw new Error('Platform settings row is missing');
  return row;
}

export function platformSettingsResponse(
  row: PlatformSettings,
  config: Config,
  piggyHealth: PiggyChatServerHealth | null = null,
) {
  const storedCredential = Boolean(row.primeApiKeyEncrypted);
  const environmentCredential = Boolean(config.PRIME_API_KEY);
  return {
    piggyEnabled: row.piggyEnabled,
    piggy: piggyRuntimeStatus(row, config, piggyHealth),
    primeComputeBase: config.PRIME_API_BASE,
    primeApiKey: {
      configured: storedCredential || environmentCredential,
      source: storedCredential ? 'database' : environmentCredential ? 'environment' : null,
      updatedAt: storedCredential ? row.primeApiKeyUpdatedAt : null,
      encryptionReady: encryptionReady(config.PIG_SETTINGS_ENCRYPTION_KEY),
    },
    primeSyncEnabled: row.primeSyncEnabled,
    primeSyncIntervalMinutes: row.primeSyncIntervalMinutes,
    updatedAt: row.updatedAt,
  };
}

export async function effectivePrimeSyncConfig(config: Config, db: Database): Promise<Config> {
  const row = await ensurePlatformSettings(config, db);
  const storedKey = row.primeApiKeyEncrypted
    ? decryptSecret(row.primeApiKeyEncrypted, config.PIG_SETTINGS_ENCRYPTION_KEY)
    : undefined;
  return {
    ...config,
    PRIME_API_KEY: storedKey ?? config.PRIME_API_KEY,
    PRIME_SYNC_ENABLED: row.primeSyncEnabled,
    PRIME_SYNC_INTERVAL_MINUTES: row.primeSyncIntervalMinutes,
  };
}

type InviteRow = typeof invites.$inferSelect;

export function inviteMetadata(row: InviteRow, now = new Date()) {
  const status = row.revokedAt
    ? 'revoked'
    : row.usesRemaining < 1
      ? 'used'
      : row.expiresAt && row.expiresAt <= now
        ? 'expired'
        : 'active';
  return {
    id: row.id,
    email: row.email,
    team: row.team,
    role: row.role,
    usesRemaining: row.usesRemaining,
    expiresAt: row.expiresAt,
    redeemedAt: row.redeemedAt,
    revokedAt: row.revokedAt,
    createdAt: row.createdAt,
    status,
  };
}

function generateInviteCode(): string {
  return `pig_inv_${randomBytes(24).toString('base64url')}`;
}

export function createAdminSettingsRoutes(
  config: Config,
  db: Database,
  onSettingsChanged?: () => Promise<void>,
  options: { fetchImpl?: typeof fetch } = {},
) {
  const app = new Hono<ApiEnv>();
  const fetchImpl = options.fetchImpl ?? fetch;

  /**
   * One probe in flight at a time, and deliberately not cached beyond that.
   *
   * The settings page refetches on focus and an operator diagnosing a dead
   * Piggy will press Recheck the moment the container restarts; a Recheck that
   * answers from a cache would be the same class of lie this panel exists to
   * remove. The single-flight guard is enough, because this route is
   * `settings:admin` and rarely called — unlike `/api/piggy/status`, which is
   * hit by a dock on every page and so caches its own copy of the probe.
   */
  let inFlightHealth: Promise<PiggyChatServerHealth> | null = null;
  async function piggyHealth(): Promise<PiggyChatServerHealth | null> {
    const url = config.PIGGY_INTERNAL_URL;
    if (!url) return null;
    inFlightHealth ??= probePiggyChatServer(url, fetchImpl).finally(() => {
      inFlightHealth = null;
    });
    return inFlightHealth;
  }

  app.get('/api/admin/settings', async (c) => {
    requireCapability(c.get('principal'), 'settings:admin');
    const [row, health] = await Promise.all([
      ensurePlatformSettings(config, db),
      piggyHealth(),
    ]);
    return c.json(platformSettingsResponse(row, config, health));
  });

  const updateSettings = mutation(db, {
    schema: platformSettingsSchema,
    permission: { capability: 'settings:admin' },
    invalidMessage: 'Invalid platform settings.',
    async mutate({ input, principal, tx, now }) {
      const set: Partial<typeof platformSettings.$inferInsert> = {
        updatedAt: now,
        updatedByUserId: principal.userId,
      };
      if (input.piggyEnabled !== undefined) set.piggyEnabled = input.piggyEnabled;
      if (input.primeSyncEnabled !== undefined) set.primeSyncEnabled = input.primeSyncEnabled;
      if (input.primeSyncIntervalMinutes !== undefined) {
        set.primeSyncIntervalMinutes = input.primeSyncIntervalMinutes;
      }
      if (input.primeApiKey !== undefined) {
        try {
          set.primeApiKeyEncrypted = encryptSecret(
            input.primeApiKey,
            config.PIG_SETTINGS_ENCRYPTION_KEY,
          );
        } catch (error) {
          if (error instanceof SecretConfigurationError) {
            throw new MutationError('encryption_unavailable', error.message, 409);
          }
          throw error;
        }
        set.primeApiKeyUpdatedAt = now;
      } else if (input.clearPrimeApiKey) {
        set.primeApiKeyEncrypted = null;
        set.primeApiKeyUpdatedAt = null;
      }

      const [updated] = await tx
        .insert(platformSettings)
        .values({ ...initialSettings(config), ...set })
        .onConflictDoUpdate({ target: platformSettings.id, set })
        .returning();
      if (!updated) throw new Error('Platform settings update returned no row');

      return {
        data: platformSettingsResponse(updated, config),
        activity: {
          type: 'agent_action',
          subject: 'Updated platform settings',
          meta: {
            action: 'platform_settings.updated',
            fields: Object.keys(input),
            primeApiKeyChanged: input.primeApiKey !== undefined || Boolean(input.clearPrimeApiKey),
          },
        },
      };
    },
  });

  app.patch('/api/admin/settings', async (c, next) => {
    const response = await updateSettings(c, next);
    if (response?.ok && onSettingsChanged) {
      void onSettingsChanged().catch((error: unknown) =>
        console.error(
          '[pig] platform settings saved but runtime reload failed:',
          error instanceof Error ? error.message : 'unknown error',
        ),
      );
    }
    return response;
  });

  app.get('/api/admin/invites', async (c) => {
    requireCapability(c.get('principal'), 'settings:admin');
    const rows = await db.select().from(invites).orderBy(desc(invites.createdAt));
    return c.json(rows.map((row) => inviteMetadata(row)));
  });

  app.post(
    '/api/admin/invites',
    mutation(db, {
      schema: inviteCreateSchema,
      permission: { capability: 'settings:admin' },
      invalidMessage: 'Invalid invite.',
      async mutate({ input, principal, tx, now }) {
        const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
        if (expiresAt && expiresAt <= now) {
          throw new MutationError('invalid_expiry', 'Invite expiry must be in the future.', 400);
        }
        const code = generateInviteCode();
        const [created] = await tx
          .insert(invites)
          .values({
            codeHash: createHash('sha256').update(code).digest('hex'),
            email: input.email?.toLowerCase(),
            team: input.team,
            role: input.role,
            expiresAt,
            usesRemaining: input.usesRemaining,
            createdByUserId: principal.userId,
            scopeNote: 'admin-ui',
          })
          .returning();
        if (!created) throw new Error('Invite insert returned no row');
        return {
          data: { ...inviteMetadata(created, now), code },
          activity: {
            type: 'agent_action',
            subject: `Created${created.email ? ` invite for ${created.email}` : ' workspace invite'}`,
            meta: {
              action: 'invite.created',
              inviteId: created.id,
              email: created.email,
              team: created.team,
              role: created.role,
              uses: created.usesRemaining,
            },
          },
        };
      },
    }),
  );

  app.delete(
    '/api/admin/invites/:id',
    bodylessMutation(db, {
      schema: z.object({}).strict(),
      permission: { capability: 'settings:admin' },
      invalidMessage: 'Invalid invite revocation.',
      async mutate({ params, tx, now }) {
        const id = params.id;
        if (!id) throw MutationError.notFound('Invite');
        const [existing] = await tx.select().from(invites).where(eq(invites.id, id)).limit(1);
        if (!existing) throw MutationError.notFound('Invite');
        const [updated] = existing.revokedAt
          ? [existing]
          : await tx.update(invites).set({ revokedAt: now }).where(eq(invites.id, id)).returning();
        if (!updated) throw MutationError.notFound('Invite');
        return {
          data: inviteMetadata(updated, now),
          activity: {
            type: 'agent_action',
            subject: `Revoked${existing.email ? ` invite for ${existing.email}` : ' workspace invite'}`,
            meta: {
              action: 'invite.revoked',
              inviteId: existing.id,
              alreadyRevoked: existing.revokedAt !== null,
            },
          },
        };
      },
    }),
  );

  app.get('/api/admin/members', async (c) => {
    requireCapability(c.get('principal'), 'settings:admin');
    const [people, memberships] = await Promise.all([
      db.select().from(users).where(isNull(users.deactivatedAt)).orderBy(users.name),
      db.select().from(teamMemberships),
    ]);
    return c.json(
      people.map((person) => ({
        id: person.id,
        name: person.name,
        email: person.email,
        title: person.title,
        isPlatformAdmin:
          person.isPlatformAdmin || config.adminEmails.includes(person.email.toLowerCase()),
        adminSource: config.adminEmails.includes(person.email.toLowerCase())
          ? 'environment'
          : person.isPlatformAdmin
            ? 'database'
            : null,
        memberships: memberships
          .filter(({ userId }) => userId === person.id)
          .map(({ team, role }) => ({ team, role })),
      })),
    );
  });

  app.patch(
    '/api/admin/members/:id/access',
    mutation(db, {
      schema: memberAccessSchema,
      permission: { capability: 'settings:admin' },
      invalidMessage: 'Invalid member access.',
      async mutate({ input, principal, params, tx, now }) {
        const id = params.id;
        if (!id) throw MutationError.notFound('Member');
        const [person] = await tx.select().from(users).where(eq(users.id, id)).limit(1);
        if (!person || person.deactivatedAt) throw MutationError.notFound('Member');
        const environmentAdmin = config.adminEmails.includes(person.email.toLowerCase());
        if (!input.isPlatformAdmin && environmentAdmin) {
          throw new MutationError(
            'environment_admin',
            'This administrator is pinned by PIG_ADMIN_EMAILS and must be changed in deployment configuration.',
            409,
          );
        }
        if (!input.isPlatformAdmin && id === principal.userId) {
          throw new MutationError(
            'self_demotion',
            'Ask another platform administrator to remove your access.',
            409,
          );
        }

        await tx
          .update(users)
          .set({ isPlatformAdmin: input.isPlatformAdmin, updatedAt: now })
          .where(eq(users.id, id));
        await tx.delete(teamMemberships).where(eq(teamMemberships.userId, id));
        if (input.memberships.length > 0) {
          await tx.insert(teamMemberships).values(
            input.memberships.map((membership, index) => ({
              userId: id,
              ...membership,
              isPrimary: index === 0,
            })),
          );
        }
        const data = {
          id,
          name: person.name,
          email: person.email,
          title: person.title,
          isPlatformAdmin: input.isPlatformAdmin || environmentAdmin,
          adminSource: environmentAdmin ? 'environment' : input.isPlatformAdmin ? 'database' : null,
          memberships: input.memberships,
        };
        return {
          data,
          activity: {
            type: 'agent_action',
            subject: `Updated access for ${person.name}`,
            meta: {
              action: 'member_access.updated',
              targetUserId: id,
              isPlatformAdmin: data.isPlatformAdmin,
              memberships: input.memberships,
            },
          },
        };
      },
    }),
  );

  return app;
}
