/**
 * Personal Deal workflows (FDS §6.6).
 *
 * Decider/executor split:
 *   - domain/personal-deal/machine.ts        pure validation + effect emission
 *   - domain/personal-deal/apply-effects.ts  DB + DO alarm + push
 *   - this file                              loads context, runs decider, applies effects
 *
 * Flow:
 *   User on SOLD_OUT deal → requestPersonalDeal
 *   Vendor responds within 30 min → acceptPersonalDealRequest / rejectPersonalDealRequest
 *   Cron calls expireOverdueRequests for unresponded requests
 */

import { eq, and, lt, inArray } from 'drizzle-orm';
import type { DrizzleClient } from '../db/client.js';
import { deals, personalDealRequests } from '../db/schema.js';
import * as dealQueries from '../db/queries/deals.js';
import type { PushClient } from '../push/types.js';
import type { DoClient } from '@/server/services/types.js';
import type { AcceptPersonalDealNewDealInput } from '../schemas/personal-deal.js';
import { decide } from '@/server/domain/personal-deal/machine.js';
import type {
  PersonalDealRequestContext,
  SourceDealContext,
} from '@/server/domain/personal-deal/machine.js';
import { applyEffects } from '@/server/domain/personal-deal/apply-effects.js';
import type { PersonalDealRequestStatus } from '@/server/domain/personal-deal/events.js';
import { captureCaught } from '@/server/observability/capture.server.js';
import { he } from '@/lib/i18n/he.js';

// ─── Deps ─────────────────────────────────────────────────────────────────────

export interface PersonalDealDeps {
  db: DrizzleClient;
  push: PushClient;
  doClient: DoClient;
}

// ─── Errors ───────────────────────────────────────────────────────────────────

export class PersonalDealError extends Error {
  readonly code: string;
  constructor(code: string, message: string) {
    super(message);
    this.name = 'PersonalDealError';
    this.code = code;
  }
}

// ─── Response deadline ────────────────────────────────────────────────────────

const RESPONSE_DEADLINE_MS = 30 * 60 * 1000; // 30 minutes

// ─── Context loaders ──────────────────────────────────────────────────────────

async function loadSourceDealCtx(
  db: DrizzleClient,
  sourceDealId: string,
): Promise<SourceDealContext | null> {
  const deal = await dealQueries.findById(db, sourceDealId);
  if (!deal) return null;
  return { id: deal.id, vendorId: deal.vendorId, dealState: deal.dealState };
}

function reject(code: string, message: string): never {
  throw new PersonalDealError(code, message);
}

// ─── requestPersonalDeal ──────────────────────────────────────────────────────

export async function requestPersonalDeal(
  deps: PersonalDealDeps,
  { userId, sourceDealId }: { userId: string; sourceDealId: string },
) {
  const sourceDeal = await loadSourceDealCtx(deps.db, sourceDealId);
  const at = new Date();
  const responseDeadline = new Date(at.getTime() + RESPONSE_DEADLINE_MS);

  const result = decide(
    { kind: 'create', sourceDeal },
    { kind: 'request_created', userId, sourceDealId, responseDeadline, at },
  );
  if (!result.ok) reject(result.error, result.message);

  const applied = await applyEffects(deps, result.effects);
  if (!applied.request) throw new Error('create-request effect did not produce a request');
  return applied.request;
}

// ─── acceptPersonalDealRequest ────────────────────────────────────────────────

export async function acceptPersonalDealRequest(
  deps: PersonalDealDeps,
  {
    vendorId,
    requestId,
    newDealInput,
  }: { vendorId: string; requestId: string; newDealInput: AcceptPersonalDealNewDealInput },
) {
  // defineApi validates newDealInput against acceptPersonalDealBodySchema before
  // the handler runs, so no redundant parse is needed here.

  // Defence-in-depth: filter by id + vendorId + status=PENDING.
  // The machine's decideAccept also enforces ownership (VENDOR_NOT_OWNER → 403),
  // but adding vendorId at the SQL level ensures a cross-vendor attempt cannot
  // even load the row, regardless of future machine refactors.
  const [row] = await deps.db
    .select({
      id: personalDealRequests.id,
      userId: personalDealRequests.userId,
      vendorId: personalDealRequests.vendorId,
      status: personalDealRequests.status,
      responseDeadline: personalDealRequests.responseDeadline,
      sourceDealId: personalDealRequests.sourceDealId,
      sourceCategoryId: deals.categoryId,
    })
    .from(personalDealRequests)
    .innerJoin(deals, eq(deals.id, personalDealRequests.sourceDealId))
    .where(
      and(
        eq(personalDealRequests.id, requestId),
        eq(personalDealRequests.vendorId, vendorId),
        eq(personalDealRequests.status, 'PENDING'),
      ),
    )
    .limit(1);

  const request: PersonalDealRequestContext | null = row
    ? {
        id: row.id,
        userId: row.userId,
        vendorId: row.vendorId,
        sourceDealId: row.sourceDealId,
        status: row.status as PersonalDealRequestStatus,
        responseDeadline: row.responseDeadline,
      }
    : null;

  const result = decide(
    { kind: 'accept', request },
    {
      kind: 'vendor_accepted',
      requestId,
      vendorId,
      newDealInput: {
        ...newDealInput,
        categoryId: newDealInput.categoryId ?? row?.sourceCategoryId ?? undefined,
      },
      at: new Date(),
    },
  );
  if (!result.ok) reject(result.error, result.message);

  const applied = await applyEffects(deps, result.effects);
  if (!applied.deal || !applied.updatedRequest) {
    throw new Error('accept effects did not produce expected outputs');
  }
  return { request: applied.updatedRequest, deal: applied.deal };
}

// ─── rejectPersonalDealRequest ────────────────────────────────────────────────

export async function rejectPersonalDealRequest(
  deps: PersonalDealDeps,
  { vendorId, requestId }: { vendorId: string; requestId: string },
) {
  // Defence-in-depth: filter by id + vendorId + status=PENDING.
  // The machine's decideReject also enforces ownership (VENDOR_NOT_OWNER → 403),
  // but adding vendorId at the SQL level ensures a cross-vendor attempt cannot
  // even load the row, regardless of future machine refactors.
  const [row] = await deps.db
    .select()
    .from(personalDealRequests)
    .where(
      and(
        eq(personalDealRequests.id, requestId),
        eq(personalDealRequests.vendorId, vendorId),
        eq(personalDealRequests.status, 'PENDING'),
      ),
    )
    .limit(1);

  const request: PersonalDealRequestContext | null = row
    ? {
        id: row.id,
        userId: row.userId,
        vendorId: row.vendorId,
        sourceDealId: row.sourceDealId,
        status: row.status as PersonalDealRequestStatus,
        responseDeadline: row.responseDeadline,
      }
    : null;

  const result = decide(
    { kind: 'reject', request },
    { kind: 'vendor_rejected', requestId, vendorId, at: new Date() },
  );
  if (!result.ok) reject(result.error, result.message);

  const applied = await applyEffects(deps, result.effects);
  if (!applied.updatedRequest) throw new Error('mark-rejected did not produce updated request');
  return applied.updatedRequest;
}

// ─── expireOverdueRequests ────────────────────────────────────────────────────

/**
 * Called by cron (Agent 2D).
 * Walks all PENDING requests past their response_deadline → sets EXPIRED, notifies user.
 *
 * Batching strategy:
 *   1. Fetch all overdue rows.
 *   2. Single batched UPDATE ... WHERE id IN (...) to set EXPIRED in one round-trip.
 *   3. Fan out DO alarm disarms + push notifications concurrently via Promise.all.
 */
export async function expireOverdueRequests(deps: PersonalDealDeps) {
  const now = new Date();

  const overdueRequests = await deps.db
    .select()
    .from(personalDealRequests)
    .where(
      and(
        eq(personalDealRequests.status, 'PENDING'),
        lt(personalDealRequests.responseDeadline, now),
      ),
    );

  if (overdueRequests.length === 0) return { expiredCount: 0 };

  // 1. Batch DB update — one round-trip for all rows.
  const ids = overdueRequests.map((r) => r.id);
  await deps.db
    .update(personalDealRequests)
    .set({ status: 'EXPIRED' })
    .where(
      and(
        inArray(personalDealRequests.id, ids),
        eq(personalDealRequests.status, 'PENDING'),
        lt(personalDealRequests.responseDeadline, now),
      ),
    );

  // 2. Fan out DO alarm disarms + push notifications concurrently.
  await Promise.all(
    overdueRequests.map(async (row) => {
      // Disarm the DO alarm — may already have fired; capture but don't throw.
      await deps.doClient.disarmPersonalOfferAlarm(row.id).catch((err: unknown) => {
        captureCaught(err, {
          scope: 'workflows.personal-deal.expireOverdueRequests.disarm',
          extra: { requestId: row.id },
        });
      });

      // Notify the user. Strings sourced from i18n namespace (he = default locale).
      const pushT = he.vendor_personal_deals;
      await deps.push.sendToUser(row.userId, {
        title: pushT.push_expired_title,
        body: pushT.push_expired_body,
        tag: `personal-deal-expired-${row.id}`,
        data: { requestId: row.id, type: 'personal_deal_expired' },
      });
    }),
  );

  return { expiredCount: overdueRequests.length };
}
