import { Harness, TenantClient } from './harness';

/**
 * AI governance tests run against the deterministic mock provider
 * (AI_PROVIDER=mock set by the e2e environment): no network, repeatable.
 */
describe('AI layer: proposals, document intake, analytics, copilot, budgets', () => {
  const h = new Harness();
  let t: TenantClient;
  let storerId: string;

  beforeAll(async () => {
    await h.start();
    t = await h.tenant('ai');
    storerId = (await t.standardWorld()).storer.id;
    await t.receiveStandardAsn(storerId);
  });
  afterAll(() => h.stop());

  it('status reports the provider', async () => {
    const s = (await t.get('/ai/status').expect(200)).body;
    expect(s).toEqual({ enabled: true, provider: 'mock', model: 'mock-1' });
  });

  it('document intake produces a reviewable ASN draft with SKU matching and warnings', async () => {
    const p = (await t.post('/ai/intake', { warehouseId: t.warehouseId, storerId, target: 'ASN', text: 'PURCHASE ORDER PO-7788\nSupplier: Acme Supplies\nExpected 2026-10-25\nWIDGET-1 x 48 CS\nUNKNOWN-ITEM 5 EA' }).expect(201)).body;
    expect(p.type).toBe('ASN_FROM_DOCUMENT');
    expect(p.status).toBe('PROPOSED');
    expect(p.payload.externalRef).toBe('PO-7788');
    expect(p.payload.lines).toHaveLength(2);
    expect(p.payload.lines[0].skuId).toBeTruthy();
    expect(p.payload.lines[0].packLevel).toBe('CASE');
    expect(p.payload.lines[1].skuId).toBeUndefined();
    expect(p.evidence.warnings.some((w: string) => /UNKNOWN-ITEM/.test(w))).toBe(true);
    expect(p.model).toBe('mock-1');
    expect(p.promptTokens).toBeGreaterThan(0);

    // Accepting as-is fails on the unmatched line; nothing is created.
    await t.post(`/ai/proposals/${p.id}/decide`, { decision: 'ACCEPT' }).expect(404);
    expect((await t.get(`/ai/proposals/${p.id}`).expect(200)).body.status).toBe('PROPOSED');

    // Accept with the edited payload -> real ASN through the normal service.
    const edited = { ...p.payload, lines: p.payload.lines.filter((l: { skuId?: string }) => l.skuId) };
    const decided = (await t.post(`/ai/proposals/${p.id}/decide`, { decision: 'ACCEPT', notes: 'removed unknown line', editedPayload: edited }).expect(201)).body;
    expect(decided.status).toBe('APPLIED');
    expect(decided.applied.refType).toBe('ASN');
    const asn = (await t.get(`/inbound/asns/${decided.applied.refId}`).expect(200)).body;
    expect(asn.source).toBe('AI');
    expect(asn.externalRef).toBe('PO-7788');
    expect(Number(asn.totalExpectedQty)).toBe(576); // 48 cases x 12

    // A decided proposal cannot be decided again.
    const again = await t.post(`/ai/proposals/${p.id}/decide`, { decision: 'REJECT' }).expect(422);
    expect(again.body.code).toBe('INVALID_STATE');
  });

  it('deterministic analytics create proposals only when there is something to say', async () => {
    const rec = (await t.post('/ai/analyses/run', { warehouseId: t.warehouseId, kind: 'RECEIVING_ANOMALY' }).expect(201)).body;
    expect(rec.kind).toBe('RECEIVING_ANOMALY');
    expect(Array.isArray(rec.findings)).toBe(true);
    const fc = (await t.post('/ai/analyses/run', { warehouseId: t.warehouseId, kind: 'WORKLOAD_FORECAST', horizonDays: 7 }).expect(201)).body;
    expect(fc.data.days).toHaveLength(8);
    expect(fc.proposal.type).toBe('WORKLOAD_FORECAST');
    const slot = (await t.post('/ai/analyses/run', { warehouseId: t.warehouseId, kind: 'SLOTTING_RECOMMENDATION' }).expect(201)).body;
    expect(slot.data.recommendations).toBeDefined();
  });

  it('slotting proposal applies pick-face assignments on accept', async () => {
    // Generate pick velocity: an order picked through RF
    const o = (await t.post('/outbound/orders', { warehouseId: t.warehouseId, storerId, externalRef: 'SO-AI', shipTo: { name: 'S' }, lines: [{ skuCode: 'WIDGET-1', orderedQty: '1', packLevel: 'CASE' }] }).expect(201)).body;
    await t.post('/outbound/waves', { warehouseId: t.warehouseId, strategy: 'BY_ORDER', orderIds: [o.id], allocateNow: true, releaseNow: true }).expect(201);
    const task = (await t.post('/tasks/next', { warehouseId: t.warehouseId, types: ['PICK'] }).expect(201)).body.task;
    await t.post(`/tasks/${task.id}/confirm`, { fromLocationCode: task.fromLocationCode, barcode: 'CS-WIDGET-1', toLpnNumber: 'TOTE-AI' }).expect(201);
    // The pick face is occupied by the received pallet, so free faces are the other ground-level slots.
    const slot = (await t.post('/ai/analyses/run', { warehouseId: t.warehouseId, kind: 'SLOTTING_RECOMMENDATION' }).expect(201)).body;
    const rec = slot.data.recommendations.find((r: { skuCode: string }) => r.skuCode === 'WIDGET-1');
    expect(rec).toBeTruthy();
    expect(rec.abcClass).toBe('A');
    if (slot.proposal) {
      const decided = (await t.post(`/ai/proposals/${slot.proposal.id}/decide`, { decision: 'ACCEPT' }).expect(201)).body;
      expect(['APPLIED', 'ACCEPTED']).toContain(decided.status);
      if (rec.apply) {
        const detail = (await t.get(`/skus/${rec.skuId}`).expect(200)).body;
        expect(detail.slottings.some((s: { pickLocationId: string }) => s.pickLocationId === rec.pickLocationId)).toBe(true);
      }
    }
  });

  it('copilot answers through read-only tools and persists the conversation', async () => {
    const r = (await t.post('/ai/copilot/ask', { warehouseId: t.warehouseId, question: 'How many open orders do we have?' }).expect(201)).body;
    expect(r.conversationId).toBeTruthy();
    expect(r.toolsUsed.map((x: { name: string }) => x.name)).toContain('find_orders');
    expect(r.answer).toMatch(/Mock answer/);
    const r2 = (await t.post('/ai/copilot/ask', { conversationId: r.conversationId, question: 'and expiring stock?' }).expect(201)).body;
    expect(r2.toolsUsed[0].name).toBe('expiry_report');
    const conv = (await t.get(`/ai/copilot/conversations/${r.conversationId}`).expect(200)).body;
    expect(conv.messages.map((m: { role: string }) => m.role)).toEqual(['user', 'assistant', 'user', 'assistant']);
    expect(conv.messages[1].toolCalls[0].name).toBe('find_orders');
  });

  it('meters tokens and enforces the monthly plan budget', async () => {
    const pt = await h.platformToken();
    const month = new Date().toISOString().slice(0, 7);
    const usage = (await t.get('/platform/usage', { from: `${month}-01`, to: `${month}-31`, metric: 'AI_TOKENS' }, pt).expect(200)).body;
    const mine = usage.filter((u: { tenantId: string }) => u.tenantId === t.profile.tenantId);
    expect(mine.reduce((a: number, u: { quantity: string }) => a + Number(u.quantity), 0)).toBeGreaterThan(0);

    const tiny = (await t.post('/platform/plans', { code: 'AI_TINY', name: 'Tiny AI', limits: { maxWarehouses: 5, maxUsers: 5, maxStorers: 5, aiTokensPerMonth: 10 }, features: ['ai.nl_query', 'ai.document_intake'] }, pt).expect(201)).body;
    await t.patch(`/platform/tenants/${t.profile.tenantId}/subscription`, { planCode: tiny.code }, pt).expect(200);
    const blocked = await t.post('/ai/copilot/ask', { question: 'anything' }).expect(429);
    expect(blocked.body.code).toBe('PLAN_LIMIT');
  });

  it('proposals are tenant-scoped (another tenant sees nothing)', async () => {
    const other = await h.tenant('ai-other');
    expect((await other.get('/ai/proposals').expect(200)).body.total).toBe(0);
    const mine = (await t.get('/ai/proposals').expect(200)).body;
    await other.get(`/ai/proposals/${mine.items[0].id}`).expect(404);
  });
});
