import { describe, it, expect } from 'vitest';
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider';
import type { CapabilityRequest } from '@insignia/iios-contracts';

const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin <ceo@lynkeduppro.com>' };

const req = (payload: Record<string, unknown>): CapabilityRequest => ({
  capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1',
});

/** A recording transport; optionally throws a given error on send. */
function stub(opts: { throwErr?: unknown; messageId?: string } = {}) {
  const calls: Array<{ id: SmtpIdentity; mail: Parameters<MailTransport['sendMail']>[0] }> = [];
  const make = (id: SmtpIdentity): MailTransport => ({
    async sendMail(mail) {
      calls.push({ id, mail });
      if (opts.throwErr) throw opts.throwErr;
      return { messageId: opts.messageId ?? '<generated@smtp.test>' };
    },
  });
  return { make, calls };
}

describe('smtpIdentityFromEnv', () => {
  const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' };

  it('builds the identity from a complete env trio (defaults port 587, secure false)', () => {
    const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv);
    expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' });
  });

  it('returns null when the trio is incomplete', () => {
    expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull();
  });

  it('reads the fallback identity, reusing the primary host', () => {
    const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO <ceo@x>' } as NodeJS.ProcessEnv);
    expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO <ceo@x>' });
  });

  it('returns null fallback when not configured', () => {
    expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull();
  });
});

describe('SmtpProvider.send — envelope', () => {
  it('maps target + payload into the mail and returns the messageId as providerRef', async () => {
    const t = stub({ messageId: '<abc@smtp.test>' });
    const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '<p>x</p>', text: 'x' }));
    expect(res).toMatchObject({ outcome: 'SENT', providerRef: '<abc@smtp.test>' });
    expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '<p>x</p>', text: 'x' });
  });

  it('sets In-Reply-To + References headers for a reply', async () => {
    const t = stub();
    await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '<parent@smtp.test>' }));
    expect(t.calls[0].mail).toMatchObject({ inReplyTo: '<parent@smtp.test>', references: '<parent@smtp.test>' });
  });
});

describe('SmtpProvider.send — failure + fallback', () => {
  it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => {
    // primary throws ECONNREFUSED (server never accepted) → fallback used.
    const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({
      async sendMail(mail) {
        if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
        return { messageId: '<viaFallback@smtp.test>' };
      },
    }) };
    const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' }));
    expect(res.outcome).toBe('SENT');
    expect(res.providerRef).toBe('fallback:<viaFallback@smtp.test>');
  });

  it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => {
    // responseCode present = the server already spoke; retrying could double-send.
    const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) });
    const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' }));
    expect(res.outcome).toBe('FAILED');
    expect(t.calls).toHaveLength(1); // primary only — no fallback attempt
  });

  it('with no fallback, a failure is FAILED and never throws', async () => {
    const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) });
    const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' }));
    expect(res.outcome).toBe('FAILED');
    expect(res.errorCode).toBe('ETIMEDOUT');
  });
});

describe('SmtpProvider.send — attachments', () => {
  const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
    async (ref: string) => map[ref] ?? null;

  it('resolves attachment refs to bytes and attaches them', async () => {
    const t = stub();
    const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
      .send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
    expect(res.outcome).toBe('SENT');
    expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
  });

  it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
    const t = stub();
    const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
      .send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
    expect(res.outcome).toBe('FAILED');
    expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
    expect(t.calls).toHaveLength(0); // nothing sent
  });

  it('FAILS when attachments are requested but no resolver is wired', async () => {
    const t = stub();
    const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
      .send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
    expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
  });

  it('a plain send with no attachments is unaffected', async () => {
    const t = stub();
    const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
    expect(res.outcome).toBe('SENT');
    expect(t.calls[0].mail.attachments).toBeUndefined();
  });
});

describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
  it('maps storage bytes into an attachment; missing ref → null', async () => {
    const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
    const r = storageResolver(storage);
    expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
    expect(await r('nope')).toBeNull();
  });
});

describe('BYO SMTP (scope-aware)', () => {
  const TENANT: SmtpIdentity = { host: 'smtp.acme.com', port: 465, secure: true, user: 'apikey', pass: 't', from: 'Acme <no-reply@acme.com>' };
  const scoped = (payload: Record<string, unknown>, scopeId?: string): CapabilityRequest => ({
    capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1', ...(scopeId ? { scopeId } : {}),
  });

  it('sends from the tenant identity when a scope has its own SMTP', async () => {
    const s = stub();
    const p = new SmtpProvider(ID, FB, s.make, undefined, async (sid) => (sid === 'scope_1' ? TENANT : null));
    const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_1'));
    expect(res.outcome).toBe('SENT');
    expect(s.calls[0]!.id).toEqual(TENANT);
    expect(s.calls[0]!.mail.from).toBe('Acme <no-reply@acme.com>');
  });

  it('falls back to the platform identity when the scope has no SMTP', async () => {
    const s = stub();
    const p = new SmtpProvider(ID, FB, s.make, undefined, async () => null);
    await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_2'));
    expect(s.calls[0]!.id).toEqual(ID);
  });

  it('fails closed (NOT_CONFIGURED) when there is neither a tenant nor a platform identity', async () => {
    const s = stub();
    const p = new SmtpProvider(undefined, undefined, s.make, undefined, async () => null);
    const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_3'));
    expect(res.outcome).toBe('FAILED');
    expect(res.errorCode).toBe('NOT_CONFIGURED');
    expect(s.calls).toHaveLength(0);
  });

  it('smtpIdentityFromConfig maps a stored config (fromName + email → from)', async () => {
    const { smtpIdentityFromConfig } = await import('./smtp.provider');
    expect(smtpIdentityFromConfig({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', fromEmail: 'a@b.com', fromName: 'Acme' }))
      .toEqual({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', from: 'Acme <a@b.com>' });
    expect(smtpIdentityFromConfig({ host: 'h', user: 'u', pass: 'p' })).toBeNull();
  });
});
