import { Injectable, Logger } from '@nestjs/common';
import { and, desc, eq, lt, sql } from 'drizzle-orm';
import { z } from 'zod';
import { AiProposalType, AiProposalQuerySchema, DecideProposalDto, PaginationQueryDto, CreateAsnSchema, CreateOrderSchema, UpsertSlottingSchema } from '@wms/shared';
import { DbService, Tx } from '@/common/db/db.service';
import { aiProposals } from '@/db/schema';
import { AuditService } from '@/common/audit/audit.service';
import { EventBus } from '@/common/events/event-bus.service';
import { MailService } from '@/common/mail/mail.service';
import { requireRequestContext } from '@/common/context/request-context';
import { DomainError, NotFoundError, ValidationError, ErrorCodes } from '@/common/errors/domain-error';
import { D, paginate, offsetOf, resolveStorerScope, serialize } from '@/common/util';
import { AsnService } from '@/modules/inbound/asn.service';
import { OrdersService } from '@/modules/outbound/orders.service';
import { SkusService } from '@/modules/master-data/skus.service';

export interface NewProposal {
  type: AiProposalType;
  warehouseId?: string | null;
  storerId?: string | null;
  title: string;
  summary: string;
  payload: Record<string, unknown>;
  evidence?: Record<string, unknown>;
  confidence?: number | null; // 0..1
  severity?: 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
  model?: string | null;
  promptTokens?: number;
  completionTokens?: number;
  /** Days until the proposal auto-expires (default 14). */
  ttlDays?: number;
}

const DISCREPANCY_EMAIL_PAYLOAD = z.object({
  to: z.array(z.string().email()).min(1),
  cc: z.array(z.string().email()).default([]),
  subject: z.string().min(1).max(200),
  bodyHtml: z.string().min(1),
  asnId: z.string().uuid(),
});

const SLOTTING_PAYLOAD = z.object({
  recommendations: z.array(
    z.object({
      skuId: z.string().uuid(),
      skuCode: z.string(),
      warehouseId: z.string().uuid(),
      pickLocationId: z.string().uuid().nullable(),
      pickLocationCode: z.string().nullable(),
      minQty: z.string(),
      maxQty: z.string(),
      replenishPackLevel: z.string().default('CASE'),
      apply: z.boolean().default(true),
    }),
  ),
});

/**
 * Proposal lifecycle: PROPOSED -> ACCEPTED (-> APPLIED) | REJECTED | EXPIRED.
 *
 * Accepting a proposal is the only path from AI output into operational data,
 * and it always runs through the same domain services (and the same
 * validation) a human user would use. The proposal row is the audit record:
 * who decided, when, what was applied and with which model.
 */
@Injectable()
export class ProposalsService {
  private readonly logger = new Logger(ProposalsService.name);

  constructor(
    private readonly db: DbService,
    private readonly audit: AuditService,
    private readonly events: EventBus,
    private readonly mail: MailService,
    private readonly asnService: AsnService,
    private readonly ordersService: OrdersService,
    private readonly skusService: SkusService,
  ) {}

  async create(tx: Tx, p: NewProposal) {
    const ctx = requireRequestContext();
    const ttl = p.ttlDays ?? 14;
    const [row] = await tx
      .insert(aiProposals)
      .values({
        tenantId: ctx.tenantId!,
        warehouseId: p.warehouseId ?? null,
        storerId: p.storerId ?? null,
        type: p.type,
        title: p.title.slice(0, 200),
        summary: p.summary,
        payload: p.payload,
        evidence: p.evidence ?? {},
        confidence: p.confidence == null ? null : D(Math.round(p.confidence * 10000) / 100),
        severity: p.severity ?? 'INFO',
        model: p.model ?? null,
        promptTokens: p.promptTokens ?? 0,
        completionTokens: p.completionTokens ?? 0,
        requestedById: ctx.userId,
        expiresAt: new Date(Date.now() + ttl * 86400000),
      })
      .returning();
    await this.audit.log({ action: 'ai.proposal.create', entityType: 'ai_proposal', entityId: row.id, after: { type: p.type, title: p.title, severity: p.severity } }, tx);
    await this.events.publish('ai.proposal.created', { proposalId: row.id, type: p.type, title: p.title, severity: p.severity ?? 'INFO' }, tx, { warehouseId: p.warehouseId ?? undefined, storerId: p.storerId ?? undefined });
    return serialize(row);
  }

  list(q: z.infer<typeof AiProposalQuerySchema> & PaginationQueryDto) {
    const storerId = resolveStorerScope(q.storerId);
    return this.db.run(async (tx) => {
      const where = and(
        q.warehouseId ? eq(aiProposals.warehouseId, q.warehouseId) : undefined,
        storerId ? eq(aiProposals.storerId, storerId) : undefined,
        q.type ? eq(aiProposals.type, q.type) : undefined,
        q.status ? eq(aiProposals.status, q.status) : undefined,
        q.severity ? eq(aiProposals.severity, q.severity) : undefined,
      );
      const [{ total }] = await tx.select({ total: sql<number>`count(*)`.mapWith(Number) }).from(aiProposals).where(where);
      const rows = await tx
        .select({
          id: aiProposals.id, type: aiProposals.type, status: aiProposals.status, title: aiProposals.title, summary: aiProposals.summary,
          severity: aiProposals.severity, confidence: aiProposals.confidence, warehouseId: aiProposals.warehouseId, storerId: aiProposals.storerId,
          model: aiProposals.model, createdAt: aiProposals.createdAt, decidedAt: aiProposals.decidedAt, expiresAt: aiProposals.expiresAt,
          appliedRefType: aiProposals.appliedRefType, appliedRefId: aiProposals.appliedRefId,
        })
        .from(aiProposals)
        .where(where)
        .orderBy(desc(aiProposals.createdAt))
        .limit(q.pageSize)
        .offset(offsetOf(q));
      return paginate(serialize(rows), total, q);
    });
  }

  get(id: string) {
    return this.db.run((tx) => this.getInTx(tx, id));
  }

  async getInTx(tx: Tx, id: string) {
    const [row] = await tx.select().from(aiProposals).where(eq(aiProposals.id, id)).limit(1);
    if (!row) throw new NotFoundError('AI proposal', id);
    if (row.storerId) resolveStorerScope(row.storerId); // storer-scoped callers cannot see other storers' proposals
    return serialize(row);
  }

  /** Summary counts for the AI inbox badge. */
  inbox(warehouseId?: string) {
    return this.db.run(async (tx) => {
      const rows = await tx
        .select({ type: aiProposals.type, severity: aiProposals.severity, count: sql<number>`count(*)`.mapWith(Number) })
        .from(aiProposals)
        .where(and(eq(aiProposals.status, 'PROPOSED'), warehouseId ? eq(aiProposals.warehouseId, warehouseId) : undefined))
        .groupBy(aiProposals.type, aiProposals.severity);
      return { open: rows.reduce((a, r) => a + r.count, 0), byType: rows };
    });
  }

  /**
   * Human decision. ACCEPT applies the proposal through the normal domain
   * services inside the same transaction, so either everything is applied and
   * the proposal is marked APPLIED, or nothing is.
   */
  decide(id: string, dto: DecideProposalDto) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const p = await this.getInTx(tx, id);
      if (p.status !== 'PROPOSED') throw new DomainError(ErrorCodes.INVALID_STATE, `Proposal is already ${p.status}`);
      if (p.expiresAt && new Date(p.expiresAt) < new Date()) {
        await tx.update(aiProposals).set({ status: 'EXPIRED' }).where(eq(aiProposals.id, id));
        throw new DomainError(ErrorCodes.INVALID_STATE, 'Proposal has expired; run the analysis or intake again.');
      }

      if (dto.decision === 'REJECT') {
        const [row] = await tx
          .update(aiProposals)
          .set({ status: 'REJECTED', decidedById: ctx.userId, decidedAt: new Date(), decisionNotes: dto.notes ?? null })
          .where(eq(aiProposals.id, id))
          .returning();
        await this.audit.log({ action: 'ai.proposal.reject', entityType: 'ai_proposal', entityId: id, after: { notes: dto.notes } }, tx);
        await this.events.publish('ai.proposal.decided', { proposalId: id, type: p.type, decision: 'REJECT' }, tx, { warehouseId: p.warehouseId ?? undefined, storerId: p.storerId ?? undefined });
        return serialize(row);
      }

      const payload = (dto.editedPayload ?? p.payload) as Record<string, unknown>;
      const applied = await this.apply(tx, p.type, payload, p);

      const [row] = await tx
        .update(aiProposals)
        .set({
          status: applied ? 'APPLIED' : 'ACCEPTED',
          payload,
          decidedById: ctx.userId,
          decidedAt: new Date(),
          decisionNotes: dto.notes ?? null,
          appliedRefType: applied?.refType ?? null,
          appliedRefId: applied?.refId ?? null,
        })
        .where(eq(aiProposals.id, id))
        .returning();
      await this.audit.log({ action: 'ai.proposal.accept', entityType: 'ai_proposal', entityId: id, before: p.payload, after: { payload, applied, edited: !!dto.editedPayload, notes: dto.notes } }, tx);
      await this.events.publish('ai.proposal.decided', { proposalId: id, type: p.type, decision: 'ACCEPT', applied }, tx, { warehouseId: p.warehouseId ?? undefined, storerId: p.storerId ?? undefined });
      return serialize({ ...row, applied });
    });
  }

  /** Applies an accepted proposal. Returns a reference to what was created, or null for acknowledge-only types. */
  private async apply(tx: Tx, type: AiProposalType, payload: Record<string, unknown>, p: { warehouseId: string | null; storerId: string | null; id: string }): Promise<{ refType: string; refId: string; refNumber?: string; detail?: unknown } | null> {
    switch (type) {
      case 'ASN_FROM_DOCUMENT': {
        const parsed = CreateAsnSchema.safeParse({ ...payload, warehouseId: payload.warehouseId ?? p.warehouseId, storerId: payload.storerId ?? p.storerId });
        if (!parsed.success) throw new ValidationError('ASN draft is not valid; edit the draft before accepting.', parsed.error.flatten());
        const asn = await this.asnService.create(parsed.data, 'AI', tx);
        return { refType: 'ASN', refId: asn.id, refNumber: asn.asnNumber };
      }
      case 'ORDER_FROM_DOCUMENT': {
        const parsed = CreateOrderSchema.safeParse({ ...payload, warehouseId: payload.warehouseId ?? p.warehouseId, storerId: payload.storerId ?? p.storerId });
        if (!parsed.success) throw new ValidationError('Order draft is not valid; edit the draft before accepting.', parsed.error.flatten());
        const order = await this.ordersService.create(parsed.data, 'AI', tx);
        return { refType: 'ORDER', refId: order.id, refNumber: order.orderNumber };
      }
      case 'DISCREPANCY_EMAIL': {
        const parsed = DISCREPANCY_EMAIL_PAYLOAD.safeParse(payload);
        if (!parsed.success) throw new ValidationError('Email draft is not valid (recipients, subject and body are required).', parsed.error.flatten());
        const mailId = await this.mail.queue({ to: parsed.data.to, cc: parsed.data.cc, subject: parsed.data.subject, bodyHtml: parsed.data.bodyHtml, refType: 'ASN', refId: parsed.data.asnId }, tx);
        return { refType: 'EMAIL', refId: mailId };
      }
      case 'SLOTTING_RECOMMENDATION': {
        const parsed = SLOTTING_PAYLOAD.safeParse(payload);
        if (!parsed.success) throw new ValidationError('Slotting payload is not valid.', parsed.error.flatten());
        const applied: string[] = [];
        const skipped: string[] = [];
        for (const r of parsed.data.recommendations) {
          if (!r.apply || !r.pickLocationId) { skipped.push(r.skuCode); continue; }
          const dto = UpsertSlottingSchema.parse({ skuId: r.skuId, warehouseId: r.warehouseId, pickLocationId: r.pickLocationId, minQty: r.minQty, maxQty: r.maxQty, replenishPackLevel: r.replenishPackLevel, active: true });
          await this.skusService.upsertSlotting(dto, tx);
          applied.push(r.skuCode);
        }
        return { refType: 'SKU_SLOTTING', refId: p.id, detail: { applied, skipped } };
      }
      // Insight-only proposals: accepting means "acknowledged".
      case 'RECEIVING_ANOMALY':
      case 'INVENTORY_ANOMALY':
      case 'BILLING_ANOMALY':
      case 'WORKLOAD_FORECAST':
      case 'NL_QUERY':
        return null;
      default:
        return null;
    }
  }

  /** Called by the scheduler: flips stale PROPOSED rows to EXPIRED. Runs cross-tenant. */
  async expireStale(): Promise<number> {
    return this.db.runBypass(async (tx) => {
      const rows = await tx
        .update(aiProposals)
        .set({ status: 'EXPIRED' })
        .where(and(eq(aiProposals.status, 'PROPOSED'), lt(aiProposals.expiresAt, new Date())))
        .returning({ id: aiProposals.id });
      if (rows.length) this.logger.log(`Expired ${rows.length} stale AI proposals`);
      return rows.length;
    });
  }
}
