import {
  ACCOUNT_SIDES,
  AFFILIATION_KINDS,
  CUSTOMER_SEGMENTS,
  DEMAND_STAGES,
  INTERCONNECT_TYPES,
  PRODUCT_LINES,
  SUPPLIER_TYPES,
  SUPPLY_STAGES,
  permissionGranted,
  type AccountSide,
  type Team,
} from '@pig/core';
import type { Database } from '@pig/db';
import { accounts, agentTasks, contacts, demandDeals, sites, supplyDeals } from '@pig/db';
import { desc, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { AuthError, effectivePermissions, type Principal } from '../lib/auth';
import { MutationError, mutation, type ApiEnv, type MutationDefinition } from '../lib/mutation';
import type { NotificationOutbox } from '../services/notification-outbox';

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

const requiredText = (maximum: number) => z.string().trim().min(1).max(maximum);
const nullableText = (maximum: number) => z.string().trim().max(maximum).nullable().optional();
const nullableUrl = z.string().trim().url().max(400).nullable().optional();
const nullableUuid = z.string().uuid().nullable().optional();
const nullableDate = z
  .union([z.string().date(), z.string().datetime()])
  .nullable()
  .optional()
  .transform((value) => {
    if (!value) return value === null ? null : undefined;
    return new Date(value.length === 10 ? `${value}T12:00:00.000Z` : value);
  });
const nullablePositiveInteger = z.number().int().positive().nullable().optional();
const nullableNonnegativeInteger = z.number().int().nonnegative().nullable().optional();

const accountFields = {
  name: requiredText(200),
  domain: nullableText(200).transform((value) => value?.toLowerCase() || value),
  website: nullableUrl,
  description: nullableText(4000),
  side: z.enum(ACCOUNT_SIDES),
  supplierType: z.enum(SUPPLIER_TYPES).nullable().optional(),
  customerSegment: z.enum(CUSTOMER_SEGMENTS).nullable().optional(),
  country: nullableText(100),
  region: nullableText(100),
  jurisdiction: nullableText(100),
  ultimateParentName: nullableText(200),
  ultimateParentCountry: nullableText(100),
};

const accountCreateSchema = z.object(accountFields).strict();
const accountUpdateSchema = z.object(accountFields).partial().strict();

const contactFields = {
  accountId: z.string().uuid(),
  fullName: requiredText(200),
  firstName: nullableText(100),
  lastName: nullableText(100),
  title: nullableText(200),
  email: z.string().trim().email().max(320).nullable().optional(),
  phone: nullableText(80),
  linkedinUrl: nullableUrl,
  twitterHandle: nullableText(100),
  githubHandle: nullableText(100),
  websiteUrl: nullableUrl,
  affiliation: z.enum(AFFILIATION_KINDS),
  isDecisionMaker: z.boolean(),
  confidenceNote: nullableText(2000),
};

const contactCreateSchema = z.object(contactFields).strict();
const contactUpdateSchema = z.object(contactFields).partial().strict();

const demandDealFields = {
  accountId: z.string().uuid(),
  name: requiredText(240),
  description: nullableText(4000),
  productLine: z.enum(PRODUCT_LINES),
  stage: z.enum(DEMAND_STAGES),
  primaryContactId: nullableUuid,
  acvCents: nullableNonnegativeInteger,
  tcvCents: nullableNonnegativeInteger,
  currency: z.string().trim().toUpperCase().regex(/^[A-Z]{3}$/),
  termMonths: nullablePositiveInteger,
  probability: z.number().min(0).max(1).nullable().optional(),
  expectedCloseDate: nullableDate,
  closedReason: nullableText(2000),
  msaExecuted: z.boolean(),
  dpaExecuted: z.boolean(),
  parentDealId: nullableUuid,
};

const demandDealCreateSchema = z.object(demandDealFields).strict();
const demandDealUpdateSchema = z.object(demandDealFields).partial().strict();

const supplyDealFields = {
  accountId: z.string().uuid(),
  siteId: nullableUuid,
  name: requiredText(240),
  stage: z.enum(SUPPLY_STAGES),
  primaryContactId: nullableUuid,
  gpuType: nullableText(100),
  gpuCount: nullablePositiveInteger,
  interconnectType: z.enum(INTERCONNECT_TYPES).nullable().optional(),
  targetCostPerGpuHourCents: nullableNonnegativeInteger,
  termMonths: nullablePositiveInteger,
  availableFrom: nullableDate,
  technicalVerdict: nullableText(500),
  technicalNotes: nullableText(4000),
  financialVerdict: nullableText(500),
  financialNotes: nullableText(4000),
  rejectionReason: nullableText(2000),
};

const supplyDealCreateSchema = z.object(supplyDealFields).strict();
const supplyDealUpdateSchema = z.object(supplyDealFields).partial().strict();

export function accountSupportsTeam(side: AccountSide, team: Team): boolean {
  return team !== 'research' && (side === 'both' || side === team);
}

function hasDealPermission(principal: Principal, team: 'supply' | 'demand'): boolean {
  return permissionGranted(effectivePermissions(principal), 'deal:write', team);
}

function requireAnyDealPermission(principal: Principal): void {
  if (hasDealPermission(principal, 'supply') || hasDealPermission(principal, 'demand')) return;
  throw new AuthError(
    "This principal lacks the 'deal:write' capability for supply or demand.",
    403,
    'insufficient_permission',
  );
}

function requireAccountPermission(principal: Principal, side: AccountSide): void {
  if (
    (accountSupportsTeam(side, 'supply') && hasDealPermission(principal, 'supply')) ||
    (accountSupportsTeam(side, 'demand') && hasDealPermission(principal, 'demand'))
  ) {
    return;
  }
  throw new AuthError(
    `This principal cannot write ${side}-side records.`,
    403,
    'insufficient_permission',
  );
}

async function findAccount(tx: Transaction, id: string) {
  const [account] = await tx.select().from(accounts).where(eq(accounts.id, id)).limit(1);
  if (!account) throw MutationError.notFound('Account');
  return account;
}

async function requireAccountForTeam(tx: Transaction, id: string, team: 'supply' | 'demand') {
  const account = await findAccount(tx, id);
  if (!accountSupportsTeam(account.side, team)) {
    throw new MutationError(
      'relationship_mismatch',
      `The selected account is not on the ${team} side.`,
      409,
    );
  }
  return account;
}

async function requireContactForAccount(
  tx: Transaction,
  contactId: string | null | undefined,
  accountId: string,
): Promise<void> {
  if (!contactId) return;
  const [contact] = await tx.select().from(contacts).where(eq(contacts.id, contactId)).limit(1);
  if (!contact) throw MutationError.notFound('Contact');
  if (contact.accountId !== accountId) {
    throw new MutationError(
      'relationship_mismatch',
      'The primary contact must belong to the selected account.',
      409,
    );
  }
}

async function touchAccount(tx: Transaction, accountId: string, now: Date): Promise<void> {
  await tx
    .update(accounts)
    .set({ lastActivityAt: now, updatedAt: now })
    .where(eq(accounts.id, accountId));
}

const anyDealPermission = { authorize: requireAnyDealPermission } as const;

export function createAccountMutationDefinition(): MutationDefinition<
  typeof accountCreateSchema,
  typeof accounts.$inferSelect
> {
  return {
    schema: accountCreateSchema,
    permission: anyDealPermission,
    invalidMessage: 'Invalid account.',
    async mutate({ input, principal, tx, now }) {
      requireAccountPermission(principal, input.side);
      const [created] = await tx
        .insert(accounts)
        .values({
          ...input,
          ownerUserId: principal.userId,
          source: 'manual',
          lastActivityAt: now,
          updatedAt: now,
        })
        .returning();
      if (!created) throw new MutationError('write_failed', 'Account was not created.', 409);

      await tx.insert(agentTasks).values({
        kind: 'enrich_account',
        subject: created.id,
        reason: `New account created by ${principal.name}`,
        requestedByUserId: principal.userId,
      });
      return {
        data: created,
        activity: {
          type: 'note',
          subject: `Created account — ${created.name}`,
          accountId: created.id,
          meta: { action: 'created', recordType: 'account', side: created.side },
        },
      };
    },
  };
}

export function updateAccountMutationDefinition(): MutationDefinition<
  typeof accountUpdateSchema,
  typeof accounts.$inferSelect
> {
  return {
    schema: accountUpdateSchema,
    permission: anyDealPermission,
    invalidMessage: 'Invalid account update.',
    async mutate({ input, principal, params, tx, now }) {
      if (!params.id) throw MutationError.notFound('Account');
      const before = await findAccount(tx, params.id);
      const nextSide = input.side ?? before.side;
      // Both checks matter when a record crosses sides: access to the source
      // record must not grant permission to move it into another team's book.
      requireAccountPermission(principal, before.side);
      requireAccountPermission(principal, nextSide);

      const [updated] = await tx
        .update(accounts)
        .set({ ...input, updatedAt: now, lastActivityAt: now })
        .where(eq(accounts.id, before.id))
        .returning();
      if (!updated) throw MutationError.notFound('Account');
      return {
        data: updated,
        activity: {
          type: 'note',
          subject: `Updated account — ${updated.name}`,
          accountId: updated.id,
          meta: { action: 'updated', recordType: 'account', fromSide: before.side, side: updated.side },
        },
      };
    },
  };
}

export function createContactMutationDefinition(): MutationDefinition<
  typeof contactCreateSchema,
  typeof contacts.$inferSelect
> {
  return {
    schema: contactCreateSchema,
    permission: anyDealPermission,
    invalidMessage: 'Invalid contact.',
    async mutate({ input, principal, tx, now }) {
      const account = await findAccount(tx, input.accountId);
      requireAccountPermission(principal, account.side);
      const [created] = await tx
        .insert(contacts)
        .values({
          ...input,
          ownerUserId: principal.userId,
          source: 'manual',
          lastActivityAt: now,
          updatedAt: now,
        })
        .returning();
      if (!created) throw new MutationError('write_failed', 'Contact was not created.', 409);
      await touchAccount(tx, account.id, now);
      await tx.insert(agentTasks).values({
        kind: 'enrich_contact',
        subject: created.id,
        reason: `New contact created by ${principal.name}`,
        requestedByUserId: principal.userId,
      });
      return {
        data: created,
        activity: {
          type: 'note',
          subject: `Created contact — ${created.fullName}`,
          accountId: account.id,
          contactId: created.id,
          meta: { action: 'created', recordType: 'contact', side: account.side },
        },
      };
    },
  };
}

function updateContactMutationDefinition(): MutationDefinition<
  typeof contactUpdateSchema,
  typeof contacts.$inferSelect
> {
  return {
    schema: contactUpdateSchema,
    permission: anyDealPermission,
    invalidMessage: 'Invalid contact update.',
    async mutate({ input, principal, params, tx, now }) {
      if (!params.id) throw MutationError.notFound('Contact');
      const [before] = await tx.select().from(contacts).where(eq(contacts.id, params.id)).limit(1);
      if (!before) throw MutationError.notFound('Contact');
      if (!before.accountId && !input.accountId) {
        throw new MutationError(
          'relationship_required',
          'Select an account before editing this contact.',
          409,
        );
      }

      if (before.accountId) {
        const beforeAccount = await findAccount(tx, before.accountId);
        requireAccountPermission(principal, beforeAccount.side);
      }
      const account = await findAccount(tx, input.accountId ?? before.accountId!);
      requireAccountPermission(principal, account.side);

      const [updated] = await tx
        .update(contacts)
        .set({ ...input, updatedAt: now, lastActivityAt: now })
        .where(eq(contacts.id, before.id))
        .returning();
      if (!updated) throw MutationError.notFound('Contact');
      await touchAccount(tx, account.id, now);
      return {
        data: updated,
        activity: {
          type: 'note',
          subject: `Updated contact — ${updated.fullName}`,
          accountId: account.id,
          contactId: updated.id,
          meta: { action: 'updated', recordType: 'contact', side: account.side },
        },
      };
    },
  };
}

export function createDemandDealMutationDefinition(): MutationDefinition<
  typeof demandDealCreateSchema,
  typeof demandDeals.$inferSelect
> {
  return {
    schema: demandDealCreateSchema,
    permission: { capability: 'deal:write', team: 'demand' },
    invalidMessage: 'Invalid demand deal.',
    async mutate({ input, principal, tx, now }) {
      const account = await requireAccountForTeam(tx, input.accountId, 'demand');
      await requireContactForAccount(tx, input.primaryContactId, account.id);
      if (input.parentDealId) {
        const [parent] = await tx
          .select()
          .from(demandDeals)
          .where(eq(demandDeals.id, input.parentDealId))
          .limit(1);
        if (!parent) throw MutationError.notFound('Parent demand deal');
        if (parent.accountId !== account.id) {
          throw new MutationError(
            'relationship_mismatch',
            'A parent deal must belong to the selected account.',
            409,
          );
        }
      }
      const closedAt = ['closed_won', 'closed_lost'].includes(input.stage) ? now : null;
      const probability =
        input.probability === undefined
          ? undefined
          : input.probability === null
            ? null
            : String(input.probability);
      const [created] = await tx
        .insert(demandDeals)
        .values({
          ...input,
          probability,
          ownerUserId: principal.userId,
          stageChangedAt: now,
          closedAt,
          lastActivityAt: now,
          updatedAt: now,
        })
        .returning();
      if (!created) throw new MutationError('write_failed', 'Demand deal was not created.', 409);
      await touchAccount(tx, account.id, now);
      return {
        data: created,
        activity: {
          type: 'note',
          subject: `Created demand deal — ${created.name}`,
          accountId: account.id,
          demandDealId: created.id,
          meta: { action: 'created', recordType: 'demand_deal', stage: created.stage },
        },
      };
    },
  };
}

export function updateDemandDealMutationDefinition(notifications?: NotificationOutbox): MutationDefinition<
  typeof demandDealUpdateSchema,
  typeof demandDeals.$inferSelect
> {
  return {
    schema: demandDealUpdateSchema,
    permission: { capability: 'deal:write', team: 'demand' },
    invalidMessage: 'Invalid demand deal update.',
    async mutate({ input, params, tx, now }) {
      if (!params.id) throw MutationError.notFound('Demand deal');
      const [before] = await tx
        .select()
        .from(demandDeals)
        .where(eq(demandDeals.id, params.id))
        .limit(1);
      if (!before) throw MutationError.notFound('Demand deal');
      const accountId = input.accountId ?? before.accountId;
      const account = await requireAccountForTeam(tx, accountId, 'demand');
      const contactId = input.primaryContactId === undefined
        ? before.primaryContactId
        : input.primaryContactId;
      await requireContactForAccount(tx, contactId, account.id);

      const parentId = input.parentDealId === undefined ? before.parentDealId : input.parentDealId;
      if (parentId) {
        if (parentId === before.id) {
          throw new MutationError('relationship_mismatch', 'A deal cannot be its own parent.', 409);
        }
        const [parent] = await tx.select().from(demandDeals).where(eq(demandDeals.id, parentId)).limit(1);
        if (!parent) throw MutationError.notFound('Parent demand deal');
        if (parent.accountId !== account.id) {
          throw new MutationError(
            'relationship_mismatch',
            'A parent deal must belong to the selected account.',
            409,
          );
        }
      }

      const stage = input.stage ?? before.stage;
      const stageChanged = stage !== before.stage;
      const probability =
        input.probability === undefined
          ? undefined
          : input.probability === null
            ? null
            : String(input.probability);
      const closedAt = stageChanged
        ? ['closed_won', 'closed_lost'].includes(stage)
          ? now
          : null
        : before.closedAt;
      const [updated] = await tx
        .update(demandDeals)
        .set({
          ...input,
          probability,
          stageChangedAt: stageChanged ? now : before.stageChangedAt,
          closedAt,
          updatedAt: now,
          lastActivityAt: now,
        })
        .where(eq(demandDeals.id, before.id))
        .returning();
      if (!updated) throw MutationError.notFound('Demand deal');
      await touchAccount(tx, account.id, now);
      if (stageChanged) {
        await notifications?.enqueueStageChange(tx, {
          accountId: account.id,
          dealId: updated.id,
          dealSide: 'demand',
          dealName: updated.name,
          fromStage: before.stage,
          toStage: updated.stage,
          changedAt: now.toISOString(),
        });
      }
      return {
        data: updated,
        activity: {
          type: stageChanged ? 'stage_change' : 'note',
          subject: stageChanged
            ? `${before.stage} → ${updated.stage}`
            : `Updated demand deal — ${updated.name}`,
          accountId: account.id,
          demandDealId: updated.id,
          meta: {
            action: 'updated',
            recordType: 'demand_deal',
            fromStage: before.stage,
            stage: updated.stage,
          },
        },
      };
    },
  };
}

function createSupplyDealMutationDefinition(): MutationDefinition<
  typeof supplyDealCreateSchema,
  typeof supplyDeals.$inferSelect
> {
  return {
    schema: supplyDealCreateSchema,
    permission: { capability: 'deal:write', team: 'supply' },
    invalidMessage: 'Invalid supply deal.',
    async mutate({ input, principal, tx, now }) {
      const account = await requireAccountForTeam(tx, input.accountId, 'supply');
      await requireContactForAccount(tx, input.primaryContactId, account.id);
      if (input.siteId) {
        const [site] = await tx.select().from(sites).where(eq(sites.id, input.siteId)).limit(1);
        if (!site) throw MutationError.notFound('Site');
        if (site.accountId !== account.id) {
          throw new MutationError(
            'relationship_mismatch',
            'The selected site must belong to the selected account.',
            409,
          );
        }
      }
      const closedAt = ['live', 'churned', 'rejected'].includes(input.stage) ? now : null;
      const [created] = await tx
        .insert(supplyDeals)
        .values({
          ...input,
          ownerUserId: principal.userId,
          stageChangedAt: now,
          closedAt,
          lastActivityAt: now,
          updatedAt: now,
        })
        .returning();
      if (!created) throw new MutationError('write_failed', 'Supply deal was not created.', 409);
      await touchAccount(tx, account.id, now);
      return {
        data: created,
        activity: {
          type: 'note',
          subject: `Created supply deal — ${created.name}`,
          accountId: account.id,
          supplyDealId: created.id,
          meta: { action: 'created', recordType: 'supply_deal', stage: created.stage },
        },
      };
    },
  };
}

export function updateSupplyDealMutationDefinition(notifications?: NotificationOutbox): MutationDefinition<
  typeof supplyDealUpdateSchema,
  typeof supplyDeals.$inferSelect
> {
  return {
    schema: supplyDealUpdateSchema,
    permission: { capability: 'deal:write', team: 'supply' },
    invalidMessage: 'Invalid supply deal update.',
    async mutate({ input, principal, params, tx, now }) {
      if (!params.id) throw MutationError.notFound('Supply deal');
      const [before] = await tx.select().from(supplyDeals).where(eq(supplyDeals.id, params.id)).limit(1);
      if (!before) throw MutationError.notFound('Supply deal');
      const accountId = input.accountId ?? before.accountId;
      const account = await requireAccountForTeam(tx, accountId, 'supply');
      const contactId = input.primaryContactId === undefined
        ? before.primaryContactId
        : input.primaryContactId;
      await requireContactForAccount(tx, contactId, account.id);
      const siteId = input.siteId === undefined ? before.siteId : input.siteId;
      if (siteId) {
        const [site] = await tx.select().from(sites).where(eq(sites.id, siteId)).limit(1);
        if (!site) throw MutationError.notFound('Site');
        if (site.accountId !== account.id) {
          throw new MutationError(
            'relationship_mismatch',
            'The selected site must belong to the selected account.',
            409,
          );
        }
      }

      const stage = input.stage ?? before.stage;
      const stageChanged = stage !== before.stage;
      const closedAt = stageChanged
        ? ['live', 'churned', 'rejected'].includes(stage)
          ? now
          : null
        : before.closedAt;
      const technicalVerdictChanged =
        input.technicalVerdict !== undefined &&
        input.technicalVerdict !== before.technicalVerdict;
      const financialVerdictChanged =
        input.financialVerdict !== undefined &&
        input.financialVerdict !== before.financialVerdict;
      const verdictUpdate = {
        technicalVerdictBy: technicalVerdictChanged
          ? input.technicalVerdict === null
            ? null
            : principal.userId
          : undefined,
        technicalVerdictAt: technicalVerdictChanged
          ? input.technicalVerdict === null
            ? null
            : now
          : undefined,
        financialVerdictBy: financialVerdictChanged
          ? input.financialVerdict === null
            ? null
            : principal.userId
          : undefined,
        financialVerdictAt: financialVerdictChanged
          ? input.financialVerdict === null
            ? null
            : now
          : undefined,
      };
      const [updated] = await tx
        .update(supplyDeals)
        .set({
          ...input,
          ...verdictUpdate,
          stageChangedAt: stageChanged ? now : before.stageChangedAt,
          closedAt,
          updatedAt: now,
          lastActivityAt: now,
        })
        .where(eq(supplyDeals.id, before.id))
        .returning();
      if (!updated) throw MutationError.notFound('Supply deal');
      await touchAccount(tx, account.id, now);
      if (stageChanged) {
        await notifications?.enqueueStageChange(tx, {
          accountId: account.id,
          dealId: updated.id,
          dealSide: 'supply',
          dealName: updated.name,
          fromStage: before.stage,
          toStage: updated.stage,
          changedAt: now.toISOString(),
        });
      }
      return {
        data: updated,
        activity: {
          type: stageChanged ? 'stage_change' : 'note',
          subject: stageChanged
            ? `${before.stage} → ${updated.stage}`
            : `Updated supply deal — ${updated.name}`,
          accountId: account.id,
          supplyDealId: updated.id,
          meta: {
            action: 'updated',
            recordType: 'supply_deal',
            fromStage: before.stage,
            stage: updated.stage,
          },
        },
      };
    },
  };
}

export function createRecordRoutes(db: Database, notifications?: NotificationOutbox): Hono<ApiEnv> {
  const app = new Hono<ApiEnv>();

  app.get('/api/contacts', async (c) => {
    const accountId = c.req.query('accountId');
    const rows = await db
      .select({ contact: contacts, accountName: accounts.name, accountSide: accounts.side })
      .from(contacts)
      .leftJoin(accounts, eq(accounts.id, contacts.accountId))
      .where(accountId ? eq(contacts.accountId, accountId) : undefined)
      .orderBy(desc(contacts.lastActivityAt), contacts.fullName)
      .limit(500);
    return c.json(rows);
  });

  app.post('/api/accounts', mutation(db, createAccountMutationDefinition()));
  app.patch('/api/accounts/:id', mutation(db, updateAccountMutationDefinition()));
  app.post('/api/contacts', mutation(db, createContactMutationDefinition()));
  app.patch('/api/contacts/:id', mutation(db, updateContactMutationDefinition()));
  app.post('/api/deals/demand', mutation(db, createDemandDealMutationDefinition()));
  app.patch('/api/deals/demand/:id', mutation(db, updateDemandDealMutationDefinition(notifications)));
  app.post('/api/deals/supply', mutation(db, createSupplyDealMutationDefinition()));
  app.patch('/api/deals/supply/:id', mutation(db, updateSupplyDealMutationDefinition(notifications)));

  return app;
}
