import type { ActivityType, GlobalCapability, Team, TeamCapability } from '@pig/core';
import { isTeamCapability } from '@pig/core';
import type { Database } from '@pig/db';
import { activities } from '@pig/db';
import type { Context, Handler } from 'hono';
import type { ZodIssue, ZodTypeAny, infer as Infer } from 'zod';
import { requireCapability, type Principal } from './auth';

export type ApiEnv = { Variables: { principal: Principal } };

export interface ApiErrorEnvelope {
  error: string;
  code: string;
  issues?: ZodIssue[];
}

export function apiError(
  code: string,
  error: string,
  issues?: ZodIssue[],
): ApiErrorEnvelope {
  return issues ? { error, code, issues } : { error, code };
}

export class MutationError extends Error {
  constructor(
    readonly code: string,
    message: string,
    readonly status: 400 | 404 | 409 | 502 | 503,
    readonly issues?: ZodIssue[],
  ) {
    super(message);
    this.name = 'MutationError';
  }

  static notFound(resource: string): MutationError {
    return new MutationError('not_found', `${resource} not found.`, 404);
  }
}

type PermissionRequirement =
  | { capability: GlobalCapability; team?: never }
  | { capability: TeamCapability; team: Team }
  | { authorize(principal: Principal): void };

type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];

export interface MutationActivity {
  type: ActivityType;
  subject: string;
  body?: string;
  accountId?: string;
  contactId?: string;
  demandDealId?: string;
  supplyDealId?: string;
  meta?: Record<string, unknown>;
}

/**
 * `'self'` is for the one write whose own row IS the audit event: logging an
 * activity. Inserting an audit row about it would double every synced call in
 * the feed. It is a literal rather than an omitted field so that audit can
 * never be skipped by forgetting to write one — the type still demands an
 * answer, and `'self'` is a visible, greppable claim.
 */
export type MutationAudit = MutationActivity | 'self';

export interface MutationResult<Result> {
  data: Result;
  activity: MutationAudit;
}

interface MutationContext<Input> {
  input: Input;
  principal: Principal;
  params: Readonly<Record<string, string>>;
  tx: Transaction;
  now: Date;
}

export interface MutationDefinition<Schema extends ZodTypeAny, Result> {
  schema: Schema;
  permission: PermissionRequirement;
  invalidMessage: string;
  mutate(context: MutationContext<Infer<Schema>>): Promise<MutationResult<Result>>;
}

function enforcePermission(principal: Principal, permission: PermissionRequirement): void {
  if ('authorize' in permission) {
    permission.authorize(principal);
    return;
  }
  // Discriminated by the capability itself rather than by a hard-coded
  // 'settings:admin' check, which quietly sent any future global capability
  // down the team-scoped branch with an undefined team — the "passes on any
  // team" bug, reintroduced by omission.
  if (isTeamCapability(permission.capability)) {
    requireCapability(principal, permission.capability, permission.team as Team);
    return;
  }
  requireCapability(principal, permission.capability as GlobalCapability);
}

/**
 * Runs an authorised, validated write and its audit event atomically.
 *
 * Permission precedes body parsing so a caller cannot probe validation rules
 * for a write they are not allowed to perform. Validation precedes the
 * transaction so bad input never consumes a connection or leaves audit noise.
 */
export async function executeMutation<Schema extends ZodTypeAny, Result>(
  db: Database,
  principal: Principal,
  readInput: () => Promise<unknown>,
  definition: MutationDefinition<Schema, Result>,
  params: Readonly<Record<string, string>> = {},
): Promise<Result> {
  enforcePermission(principal, definition.permission);

  let rawInput: unknown;
  try {
    rawInput = await readInput();
  } catch {
    throw new MutationError('invalid_json', 'Request body must be valid JSON.', 400);
  }

  const parsed = definition.schema.safeParse(rawInput);
  if (!parsed.success) {
    throw new MutationError(
      'invalid_request',
      definition.invalidMessage,
      400,
      parsed.error.issues,
    );
  }

  return db.transaction(async (tx) => {
    const now = new Date();
    const context: MutationContext<Infer<Schema>> = {
      input: parsed.data,
      principal,
      params,
      tx,
      now,
    };
    const result = await definition.mutate(context);

    if (result.activity !== 'self') {
      await tx.insert(activities).values({
        ...result.activity,
        actorUserId: principal.userId,
        actorAgent: principal.via === 'api_key' ? 'agent' : null,
        source: principal.via === 'api_key' ? 'agent' : 'manual',
        occurredAt: now,
      });
    }
    return result.data;
  });
}

export function mutation<Schema extends ZodTypeAny, Result>(
  db: Database,
  definition: MutationDefinition<Schema, Result>,
): Handler<ApiEnv> {
  return async (c: Context<ApiEnv>) => {
    try {
      const result = await executeMutation(
        db,
        c.get('principal'),
        () => c.req.json(),
        definition,
        c.req.param(),
      );
      return c.json(result);
    } catch (error) {
      if (error instanceof MutationError) {
        return c.json(apiError(error.code, error.message, error.issues), error.status);
      }
      throw error;
    }
  };
}

/**
 * DELETE-style writes still use the mutation convention without making clients
 * send a meaningless JSON body. The empty input is deliberate: authorization
 * still runs first, and route parameters remain request-local.
 */
export function bodylessMutation<Schema extends ZodTypeAny, Result>(
  db: Database,
  definition: MutationDefinition<Schema, Result>,
): Handler<ApiEnv> {
  return async (c: Context<ApiEnv>) => {
    try {
      const result = await executeMutation(
        db,
        c.get('principal'),
        async () => ({}),
        definition,
        c.req.param(),
      );
      return c.json(result);
    } catch (error) {
      if (error instanceof MutationError) {
        return c.json(apiError(error.code, error.message, error.issues), error.status);
      }
      throw error;
    }
  };
}
