import { NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from 'nestjs-prisma';
import { TemporalService } from './temporal.service';
import { signWebhookPayload } from './webhook-signature.util';
import { WorkflowService } from './workflow.service';

describe('WorkflowService — cron lifecycle hooks', () => {
  let service: WorkflowService;
  let prisma: any;
  let temporal: any;

  beforeEach(async () => {
    prisma = {
      workflow: {
        findUnique: jest.fn(),
        update: jest.fn(),
        delete: jest.fn(),
        findMany: jest.fn(),
      },
      workflowVersion: {
        findFirst: jest.fn(),
        create: jest.fn(),
      },
      $transaction: jest.fn(async (cb: any) =>
        cb({
          workflowVersion: {
            findFirst: prisma.workflowVersion.findFirst,
            create: prisma.workflowVersion.create,
          },
          workflow: { update: prisma.workflow.update },
        }),
      ),
    };
    temporal = {
      upsertCronSchedule: jest.fn(),
      pauseCronSchedule: jest.fn(),
      unpauseCronSchedule: jest.fn(),
      deleteCronSchedule: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        WorkflowService,
        { provide: PrismaService, useValue: prisma },
        { provide: TemporalService, useValue: temporal },
      ],
    }).compile();

    service = module.get<WorkflowService>(WorkflowService);
  });

  describe('publish — cron schedule sync', () => {
    const ID = BigInt(42);
    const mkWorkflow = (dsl: any) => ({ id: ID, dsl, status: 'DRAFT' });

    it('upserts cron schedule when DSL contains cron_trigger', async () => {
      const dsl = {
        nodes: [
          {
            id: 't1',
            type: 'cron_trigger',
            config: { cron: '0 2 * * *', timezone: 'Asia/Shanghai' },
          },
        ],
        edges: [],
      };
      prisma.workflow.findUnique.mockResolvedValueOnce(mkWorkflow(dsl));
      prisma.workflowVersion.findFirst.mockResolvedValueOnce(null);
      prisma.workflowVersion.create.mockResolvedValueOnce({
        id: BigInt(1),
        version: 1,
      });
      prisma.workflow.update.mockResolvedValueOnce({});

      await service.publish(ID, { changeSummary: 'init' }, 'tester');

      expect(temporal.upsertCronSchedule).toHaveBeenCalledWith(
        expect.objectContaining({
          workflowId: 42,
          cron: '0 2 * * *',
          timezone: 'Asia/Shanghai',
        }),
      );
      expect(temporal.deleteCronSchedule).not.toHaveBeenCalled();
    });

    it('deletes cron schedule when published DSL has no cron_trigger', async () => {
      const dsl = {
        nodes: [
          { id: 't1', type: 'manual_trigger', config: {} },
          { id: 'n1', type: 'http_request', config: {} },
        ],
        edges: [{ source: 't1', target: 'n1' }],
      };
      prisma.workflow.findUnique.mockResolvedValueOnce(mkWorkflow(dsl));
      prisma.workflowVersion.findFirst.mockResolvedValueOnce(null);
      prisma.workflowVersion.create.mockResolvedValueOnce({
        id: BigInt(1),
        version: 1,
      });
      prisma.workflow.update.mockResolvedValueOnce({});

      await service.publish(ID, { changeSummary: 'init' }, 'tester');

      expect(temporal.upsertCronSchedule).not.toHaveBeenCalled();
      expect(temporal.deleteCronSchedule).toHaveBeenCalledWith(42);
    });

    it('rejects webhook_trigger without secret (strict HMAC mode)', async () => {
      const dsl = {
        nodes: [
          {
            id: 't1',
            type: 'webhook_trigger',
            config: { path: 'order-paid', method: 'POST' },
          },
        ],
        edges: [],
      };
      prisma.workflow.findUnique.mockResolvedValueOnce(mkWorkflow(dsl));
      prisma.workflow.findMany.mockResolvedValueOnce([]);

      await expect(
        service.publish(ID, { changeSummary: 'init' }, 'tester'),
      ).rejects.toThrow(/requires a secret/);
    });

    it('rejects webhook path collision with another published workflow', async () => {
      const dsl = {
        nodes: [
          {
            id: 't1',
            type: 'webhook_trigger',
            config: { path: 'order-paid', method: 'POST', secret: 's' },
          },
        ],
        edges: [],
      };
      prisma.workflow.findUnique.mockResolvedValueOnce(mkWorkflow(dsl));
      prisma.workflow.findMany.mockResolvedValueOnce([
        {
          id: BigInt(999),
          name: 'other',
          dsl: {
            nodes: [
              {
                id: 't1',
                type: 'webhook_trigger',
                config: { path: 'order-paid', method: 'POST', secret: 'x' },
              },
            ],
          },
        },
      ]);

      await expect(
        service.publish(ID, { changeSummary: 'init' }, 'tester'),
      ).rejects.toThrow(/already used by workflow #999/);
    });

    it('proceeds when webhook path differs from existing one', async () => {
      const dsl = {
        nodes: [
          {
            id: 't1',
            type: 'webhook_trigger',
            config: { path: 'invoice-paid', method: 'POST', secret: 's' },
          },
        ],
        edges: [],
      };
      prisma.workflow.findUnique.mockResolvedValueOnce(mkWorkflow(dsl));
      prisma.workflow.findMany.mockResolvedValueOnce([
        {
          id: BigInt(999),
          name: 'other',
          dsl: {
            nodes: [
              {
                id: 't1',
                type: 'webhook_trigger',
                config: { path: 'order-paid', method: 'POST', secret: 'x' },
              },
            ],
          },
        },
      ]);
      prisma.workflowVersion.findFirst.mockResolvedValueOnce(null);
      prisma.workflowVersion.create.mockResolvedValueOnce({
        id: BigInt(1),
        version: 1,
      });
      prisma.workflow.update.mockResolvedValueOnce({});

      await expect(
        service.publish(ID, { changeSummary: 'init' }, 'tester'),
      ).resolves.toBeDefined();
    });
  });

  describe('toggleEnabled', () => {
    const ID = BigInt(42);

    it('pauses cron schedule when disabling', async () => {
      prisma.workflow.findUnique.mockResolvedValueOnce({ id: ID });
      prisma.workflow.update.mockResolvedValueOnce({});

      await service.toggleEnabled(ID, false);

      expect(temporal.pauseCronSchedule).toHaveBeenCalledWith(42);
      expect(temporal.unpauseCronSchedule).not.toHaveBeenCalled();
    });

    it('unpauses cron schedule when enabling', async () => {
      prisma.workflow.findUnique.mockResolvedValueOnce({ id: ID });
      prisma.workflow.update.mockResolvedValueOnce({});

      await service.toggleEnabled(ID, true);

      expect(temporal.unpauseCronSchedule).toHaveBeenCalledWith(42);
      expect(temporal.pauseCronSchedule).not.toHaveBeenCalled();
    });

    it('does not throw if schedule operation fails', async () => {
      prisma.workflow.findUnique.mockResolvedValueOnce({ id: ID });
      prisma.workflow.update.mockResolvedValueOnce({});
      temporal.pauseCronSchedule.mockRejectedValueOnce(new Error('boom'));

      await expect(service.toggleEnabled(ID, false)).resolves.toBeDefined();
    });
  });

  describe('remove', () => {
    const ID = BigInt(42);

    it('deletes cron schedule before deleting workflow', async () => {
      prisma.workflow.findUnique.mockResolvedValueOnce({ id: ID });
      prisma.workflow.delete.mockResolvedValueOnce({});

      await service.remove(ID);

      expect(temporal.deleteCronSchedule).toHaveBeenCalledWith(42);
      expect(prisma.workflow.delete).toHaveBeenCalled();
    });

    it('still deletes the workflow even if schedule deletion fails', async () => {
      prisma.workflow.findUnique.mockResolvedValueOnce({ id: ID });
      temporal.deleteCronSchedule.mockRejectedValueOnce(new Error('boom'));
      prisma.workflow.delete.mockResolvedValueOnce({});

      await service.remove(ID);

      expect(prisma.workflow.delete).toHaveBeenCalled();
    });
  });
});

describe('WorkflowService — receiveWebhook', () => {
  let service: WorkflowService;
  let prisma: any;
  let temporal: any;

  beforeEach(async () => {
    prisma = {
      workflow: {
        findUnique: jest.fn(),
        findMany: jest.fn(),
      },
      workflowVersion: { findFirst: jest.fn() },
      workflowInstance: {
        create: jest.fn(),
        update: jest.fn(),
      },
    };
    temporal = {
      startWorkflow: jest.fn(async () => ({ temporalRunId: 'run-1' })),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        WorkflowService,
        { provide: PrismaService, useValue: prisma },
        { provide: TemporalService, useValue: temporal },
      ],
    }).compile();

    service = module.get<WorkflowService>(WorkflowService);
  });

  const SECRET = 'topsecret';
  const PATH = 'order-paid';
  const METHOD = 'POST';
  const BODY = '{"orderId":42}';
  const RAW = Buffer.from(BODY);

  const nowSec = () => Math.floor(Date.now() / 1000).toString();

  const mkPublishedWorkflow = (id: number, secret = SECRET, path = PATH) => ({
    id: BigInt(id),
    status: 'PUBLISHED',
    isEnabled: true,
    dsl: null,
    versions: [
      {
        id: BigInt(id * 10),
        version: 1,
        dsl: {
          nodes: [
            {
              id: 't1',
              type: 'webhook_trigger',
              config: { path, method: METHOD, secret },
            },
          ],
          edges: [],
        },
      },
    ],
  });

  const mkExecuteSetup = () => {
    // executeWorkflow re-fetches the workflow with its own includes
    prisma.workflow.findUnique.mockImplementation(({ where }: any) => ({
      id: where.id,
      status: 'PUBLISHED',
      isEnabled: true,
      versions: [
        {
          id: where.id * BigInt(10),
          version: 1,
          dsl: { nodes: [], edges: [] },
        },
      ],
    }));
    prisma.workflowInstance.create.mockResolvedValueOnce({ id: BigInt(7777) });
    prisma.workflowInstance.update.mockResolvedValueOnce({});
  };

  it('returns 401 (UnauthorizedException) when timestamp is missing', async () => {
    const sig = signWebhookPayload(SECRET, nowSec(), RAW);
    await expect(
      service.receiveWebhook({
        path: PATH,
        method: METHOD,
        signatureHeader: sig,
        timestampHeader: undefined,
        rawBody: RAW,
        body: { orderId: 42 },
        headers: {},
        query: {},
      }),
    ).rejects.toBeInstanceOf(UnauthorizedException);
  });

  it('returns 401 when timestamp is older than 5 minutes', async () => {
    const oldTs = String(Math.floor(Date.now() / 1000) - 6 * 60);
    const sig = signWebhookPayload(SECRET, oldTs, RAW);
    await expect(
      service.receiveWebhook({
        path: PATH,
        method: METHOD,
        signatureHeader: sig,
        timestampHeader: oldTs,
        rawBody: RAW,
        body: {},
        headers: {},
        query: {},
      }),
    ).rejects.toBeInstanceOf(UnauthorizedException);
  });

  it('returns 404 (NotFoundException) when no workflow listens on this path', async () => {
    prisma.workflow.findMany.mockResolvedValueOnce([]);
    const ts = nowSec();
    const sig = signWebhookPayload(SECRET, ts, RAW);
    await expect(
      service.receiveWebhook({
        path: 'unknown',
        method: METHOD,
        signatureHeader: sig,
        timestampHeader: ts,
        rawBody: RAW,
        body: {},
        headers: {},
        query: {},
      }),
    ).rejects.toBeInstanceOf(NotFoundException);
  });

  it('returns 401 on bad signature', async () => {
    prisma.workflow.findMany.mockResolvedValueOnce([mkPublishedWorkflow(1)]);
    const ts = nowSec();
    await expect(
      service.receiveWebhook({
        path: PATH,
        method: METHOD,
        signatureHeader: 'sha256=deadbeef',
        timestampHeader: ts,
        rawBody: RAW,
        body: {},
        headers: {},
        query: {},
      }),
    ).rejects.toBeInstanceOf(UnauthorizedException);
  });

  it('starts a workflow on valid signature and returns the instanceId', async () => {
    prisma.workflow.findMany.mockResolvedValueOnce([mkPublishedWorkflow(1)]);
    mkExecuteSetup();

    const ts = nowSec();
    const sig = signWebhookPayload(SECRET, ts, RAW);

    const result = await service.receiveWebhook({
      path: PATH,
      method: METHOD,
      signatureHeader: sig,
      timestampHeader: ts,
      rawBody: RAW,
      body: { orderId: 42 },
      headers: { 'x-workflow-signature': sig },
      query: { foo: 'bar' },
    });

    expect(result.instanceIds).toEqual(['7777']);
    expect(temporal.startWorkflow).toHaveBeenCalledTimes(1);
    const startArg = temporal.startWorkflow.mock.calls[0][0];
    expect(startArg.context).toMatchObject({
      body: { orderId: 42 },
      query: { foo: 'bar' },
    });
  });

  it('rejects with 401 when one of multiple matches has a different secret', async () => {
    // Strict mode: every match must verify against the same signature
    prisma.workflow.findMany.mockResolvedValueOnce([
      mkPublishedWorkflow(1, SECRET),
      mkPublishedWorkflow(2, 'different-secret'),
    ]);
    const ts = nowSec();
    const sig = signWebhookPayload(SECRET, ts, RAW);

    await expect(
      service.receiveWebhook({
        path: PATH,
        method: METHOD,
        signatureHeader: sig,
        timestampHeader: ts,
        rawBody: RAW,
        body: {},
        headers: {},
        query: {},
      }),
    ).rejects.toBeInstanceOf(UnauthorizedException);
  });
});
