import { Request, Response, NextFunction } from 'express';
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
import {
  Handler,
  HandlersInput,
  GetItemHandler,
  GetCollectionHandler,
  UpdateItemHandler,
  DeleteItemHandler,
  CreateItemHandler,
} from '../src/handler.js';
import { BadRequestError, UnprocessableEntityError } from '../src/httpErrors.js';

describe('Handler', () => {
  beforeEach(() => {
    // Disable debug logging to keep the test output clean.
    mock.method(global.console, 'debug', () => {});
  });

  afterEach(() => {
    mock.reset();
  });

  describe('constructor', () => {
    it('returns a Handler', () => {
      const itemHandler = new Handler('/', {});

      assert.ok(itemHandler instanceof Handler);
    });

    it('validates path', () => {
      const paths: [string, boolean][] = [
        ['', false],
        ['invalid', false],
        ['/', true],
        ['/bar', true],
        ['/bar/', false],
        ['/foo/:bar', true],
        ['/foo/:bar/', false],
        ['/foo/:bar/spam/:baz', true],
        ['/foo/:bar/:spam', true],
      ];

      for (const [path, valid] of paths) {
        if (valid) {
          assert.doesNotThrow(() => new Handler(path, {}));
        } else {
          assert.throws(() => new Handler(path, {}));
        }
      }
    });

    it('validates configuration', () => {
      const getItem = (() => {}) as unknown as GetItemHandler;
      const updateItem = (() => {}) as unknown as UpdateItemHandler;
      const deleteItem = (() => {}) as unknown as DeleteItemHandler;
      const getCollection = (() => {}) as unknown as GetCollectionHandler;
      const createItem = (() => {}) as unknown as CreateItemHandler;

      const paths: [string, HandlersInput, boolean][] = [
        ['/foo', { getItem }, true],
        ['/foo', { getCollection }, true],
        ['/foo', { getItem, getCollection }, false],
        ['/foo', { updateItem, createItem }, false],
        ['/foo', { deleteItem, createItem }, false],
        ['/foo/:bar', { getItem, getCollection }, true],
        ['/foo/:bar', { getItem, updateItem, deleteItem, createItem, getCollection }, true],
      ];

      for (const [path, handlers, valid] of paths) {
        if (valid) {
          assert.doesNotThrow(() => new Handler(path, handlers));
        } else {
          assert.throws(() => new Handler(path, handlers));
        }
      }
    });
  });

  describe('generate', () => {
    async function executeHandler(
      handler: (req: Request, res: Response, callback: NextFunction) => Promise<void>,
      request: Record<string, unknown> = {},
    ) {
      let body: Record<string, unknown> = {};
      let statusCode = 0;

      const response = {
        status: (receivedStatusCode: number) => ({
          send: (receivedBody: Record<string, unknown>) => {
            body = receivedBody;
            statusCode = receivedStatusCode;
          },
        }),
        statusCode: 0,
        locals: {
          credentials: { foo: 'bar' },
          logger: { decorate: () => {} },
        },
      } as unknown as Response;

      await handler(request as unknown as Request, response, () => {});

      return { body, statusCode };
    }

    it('returns a router', async () => {
      const handler = new Handler('/foo/:bar', {
        getCollection: () => Promise.resolve({ info: {}, data: [] }),
        getItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }),
        createItem: () => Promise.resolve({ fields: {}, path: '/', canonicalPath: '/foo/1' }),
        updateItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }),
        deleteItem: () => Promise.resolve(),
      });

      const router = handler.generate();
      const routes = router.stack;

      assert.equal(routes.length, 5);
      assert.equal(routes[0]!.route!.path, '/foo');
      assert.equal((routes[0]!.route! as any).methods.get, true);
      assert.equal(routes[1]!.route!.path, '/foo');
      assert.equal((routes[1]!.route! as any).methods.post, true);
      assert.equal(routes[2]!.route!.path, '/foo/:bar');
      assert.equal((routes[2]!.route! as any).methods.get, true);
      assert.equal(routes[3]!.route!.path, '/foo/:bar');
      assert.equal((routes[3]!.route! as any).methods.patch, true);
      assert.equal(routes[4]!.route!.path, '/foo/:bar');
      assert.equal((routes[4]!.route! as any).methods.delete, true);

      // GetCollection.
      assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle), {
        body: { info: {}, data: [] },
        statusCode: 200,
      });

      // CreateItem.
      assert.deepEqual(await executeHandler(routes[1]!.route!.stack[0]!.handle, { body: { foo: 'bar' } }), {
        body: { fields: {}, path: '/', canonicalPath: '/foo/1' },
        statusCode: 201,
      });

      // GetItem.
      assert.deepEqual(await executeHandler(routes[2]!.route!.stack[0]!.handle), {
        body: { fields: {}, relations: [], canonicalPath: '/foo/1' },
        statusCode: 200,
      });

      // UpdateItem.
      assert.deepEqual(await executeHandler(routes[3]!.route!.stack[0]!.handle, { body: { foo: 'bar' } }), {
        body: { fields: {}, relations: [], canonicalPath: '/foo/1' },
        statusCode: 200,
      });

      // DeleteItem.
      assert.deepEqual(await executeHandler(routes[4]!.route!.stack[0]!.handle), {
        body: null,
        statusCode: 204,
      });
    });

    it('uses pathWithIdentifier as is when appropriate', async () => {
      const handler = new Handler('/foo/:bar/baz', {
        getCollection: () => Promise.resolve({ info: {}, data: [] }),
      });

      const router = handler.generate();
      const routes = router.stack;

      assert.equal(routes.length, 1);
      assert.equal(routes[0]!.route!.path, '/foo/:bar/baz');
      assert.equal((routes[0]!.route as any).methods.get, true);

      // GetCollection.
      assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle), {
        body: { info: {}, data: [] },
        statusCode: 200,
      });
    });

    it('undefined handlers', async () => {
      const handler = new Handler('/foo/:bar', {});

      const router = handler.generate();
      const routes = router.stack;

      assert.equal(routes.length, 0);
    });

    it('returns a 400 for malformed request payloads and a 422 for empty ones', async () => {
      const handler = new Handler('/foo/:bar', {
        createItem: () => Promise.resolve({ fields: {}, path: '/', canonicalPath: '/foo/1' }),
        updateItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }),
      });

      const router = handler.generate();
      const routes = router.stack;

      // CreateItemRequestPayload.
      const createHandler = routes[0]!.route!.stack[0]!.handle;
      await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } }));
      await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError);
      await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError);
      await assert.rejects(async () => await executeHandler(createHandler, { body: {} }), UnprocessableEntityError);

      // UpdateItemRequestPayload.
      const updateHandler = routes[1]!.route!.stack[0]!.handle;
      await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } }));
      await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError);
      await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError);
      await assert.rejects(async () => await executeHandler(updateHandler, { body: {} }), UnprocessableEntityError);
    });

    it('mounts and executes parseRichText', async () => {
      const root = { type: 'root' as const, children: [{ type: 'text', value: 'hello' }] };
      const handler = new Handler('/richtext/parse', {
        parseRichText: () => Promise.resolve({ root }),
      });

      const router = handler.generate();
      const routes = router.stack;

      assert.equal(routes.length, 1);
      assert.equal(routes[0]!.route!.path, '/richtext/parse');
      assert.equal((routes[0]!.route as any).methods.post, true);

      const validBody = {
        itemPath: '/projects/P1',
        relationName: 'tasks',
        fieldName: 'description',
        value: '<p>hello</p>',
      };

      assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle, { body: validBody }), {
        body: { root },
        statusCode: 200,
      });
    });

    it('rejects invalid parseRichText request payloads', async () => {
      const root = { type: 'root' as const, children: [] };
      const handler = new Handler('/richtext/parse', {
        parseRichText: () => Promise.resolve({ root }),
      });

      const router = handler.generate();
      const parseHandler = router.stack[0]!.route!.stack[0]!.handle;

      const valid = {
        itemPath: '/projects/P1',
        relationName: 'tasks',
        fieldName: 'description',
        value: '<p>hi</p>',
      };

      await assert.doesNotReject(async () => await executeHandler(parseHandler, { body: valid }));
      await assert.rejects(async () => await executeHandler(parseHandler, { body: null }), BadRequestError);
      await assert.rejects(async () => await executeHandler(parseHandler, { body: 'not json' }), BadRequestError);
      await assert.rejects(async () => await executeHandler(parseHandler, { body: {} }), BadRequestError);
      await assert.rejects(
        async () => await executeHandler(parseHandler, { body: { ...valid, itemPath: undefined } }),
        BadRequestError,
      );
      await assert.rejects(
        async () => await executeHandler(parseHandler, { body: { ...valid, relationName: undefined } }),
        BadRequestError,
      );
      await assert.rejects(
        async () => await executeHandler(parseHandler, { body: { ...valid, fieldName: undefined } }),
        BadRequestError,
      );
      await assert.rejects(
        async () => await executeHandler(parseHandler, { body: { ...valid, value: undefined } }),
        BadRequestError,
      );
      await assert.rejects(
        async () => await executeHandler(parseHandler, { body: { ...valid, value: 42 } }),
        BadRequestError,
      );
    });

    it('mounts and executes dumpRichText', async () => {
      const handler = new Handler('/richtext/dump', {
        dumpRichText: () => Promise.resolve({ value: '<p>hello</p>' }),
      });

      const router = handler.generate();
      const routes = router.stack;

      assert.equal(routes.length, 1);
      assert.equal(routes[0]!.route!.path, '/richtext/dump');
      assert.equal((routes[0]!.route as any).methods.post, true);

      const validBody = {
        itemPath: '/projects/P1',
        relationName: 'tasks',
        fieldName: 'description',
        root: { type: 'root', children: [{ type: 'text', value: 'hello' }] },
      };

      assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle, { body: validBody }), {
        body: { value: '<p>hello</p>' },
        statusCode: 200,
      });
    });

    it('rejects invalid dumpRichText request payloads', async () => {
      const handler = new Handler('/richtext/dump', {
        dumpRichText: () => Promise.resolve({ value: '' }),
      });

      const router = handler.generate();
      const dumpHandler = router.stack[0]!.route!.stack[0]!.handle;

      const valid = {
        itemPath: '/projects/P1',
        relationName: 'tasks',
        fieldName: 'description',
        root: { type: 'root', children: [] },
      };

      await assert.doesNotReject(async () => await executeHandler(dumpHandler, { body: valid }));
      await assert.rejects(async () => await executeHandler(dumpHandler, { body: null }), BadRequestError);
      await assert.rejects(async () => await executeHandler(dumpHandler, { body: 'not json' }), BadRequestError);
      await assert.rejects(async () => await executeHandler(dumpHandler, { body: {} }), BadRequestError);
      await assert.rejects(
        async () => await executeHandler(dumpHandler, { body: { ...valid, itemPath: undefined } }),
        BadRequestError,
      );
      await assert.rejects(
        async () => await executeHandler(dumpHandler, { body: { ...valid, root: undefined } }),
        BadRequestError,
      );
      await assert.rejects(
        async () => await executeHandler(dumpHandler, { body: { ...valid, root: 'not an object' } }),
        BadRequestError,
      );
    });

    it('decorates the request logger with fieldNames on createItem', async () => {
      const handler = new Handler('/foo/:bar', {
        createItem: () => Promise.resolve({ fields: {}, path: '/', canonicalPath: '/foo/1' }),
      });

      const router = handler.generate();
      // /foo/:bar with only createItem → createItem mounts at POST /foo (one route).
      const createRoute = router.stack[0]!.route!.stack[0]!.handle;

      const decorateCalls: unknown[] = [];
      const logger = { decorate: (meta: unknown) => decorateCalls.push(meta) };

      const response = {
        status: () => ({ send: () => {} }),
        locals: { credentials: { foo: 'bar' }, logger },
      } as unknown as Response;

      await createRoute(
        { body: { title: 'x', description: 'y', assignee: 'u1' } } as unknown as Request,
        response,
        (() => {}) as NextFunction,
      );

      assert.equal(decorateCalls.length, 1);
      assert.deepEqual(decorateCalls[0], { fieldNames: ['title', 'description', 'assignee'] });
    });

    it('decorates the request logger with fieldNames on updateItem, stripping __meta', async () => {
      const handler = new Handler('/foo/:bar', {
        updateItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }),
      });

      const router = handler.generate();
      // /foo/:bar with only updateItem → updateItem mounts at PATCH /foo/:bar (one route).
      const updateRoute = router.stack[0]!.route!.stack[0]!.handle;

      const decorateCalls: unknown[] = [];
      const logger = { decorate: (meta: unknown) => decorateCalls.push(meta) };

      const response = {
        status: () => ({ send: () => {} }),
        locals: { credentials: { foo: 'bar' }, logger },
      } as unknown as Response;

      await updateRoute(
        {
          body: {
            title: 'x',
            description: 'y',
            __meta: { additionalField: { relationName: 'tasks', field: { name: 'note', type: 'string' } } },
          },
        } as unknown as Request,
        response,
        (() => {}) as NextFunction,
      );

      assert.equal(decorateCalls.length, 1);
      assert.deepEqual(decorateCalls[0], { fieldNames: ['title', 'description'] });
    });
  });
});
