/*
 * Elasticsearch B.V licenses this file to you under the MIT License.
 * See `packages/kbn-handlebars/LICENSE` for more information.
 */

/**
 * ABOUT THIS FILE:
 *
 * This file is for tests not copied from the upstream handlebars project, but
 * tests that we feel are needed in order to fully cover our use-cases.
 */

import Handlebars from '.';
import type { HelperOptions, TemplateDelegate } from './src/types';
import { expectTemplate, forEachCompileFunctionName } from './src/__jest__/test_bench';

it('Handlebars.create', () => {
  expect(Handlebars.create()).toMatchSnapshot();
});

describe('Handlebars.compileAST', () => {
  describe('compiler options', () => {
    it('noEscape', () => {
      expectTemplate('{{value}}').withInput({ value: '<foo>' }).toCompileTo('&lt;foo&gt;');

      expectTemplate('{{value}}')
        .withCompileOptions({ noEscape: false })
        .withInput({ value: '<foo>' })
        .toCompileTo('&lt;foo&gt;');

      expectTemplate('{{value}}')
        .withCompileOptions({ noEscape: true })
        .withInput({ value: '<foo>' })
        .toCompileTo('<foo>');
    });
  });

  it('invalid template', () => {
    expectTemplate('{{value').withInput({ value: 42 }).toThrow(`Parse error on line 1:
{{value
--^
Expecting 'ID', 'STRING', 'NUMBER', 'BOOLEAN', 'UNDEFINED', 'NULL', 'DATA', got 'INVALID'`);
  });

  if (!process.env.EVAL) {
    it('reassign', () => {
      const fn = Handlebars.compileAST;
      expect(fn('{{value}}')({ value: 42 })).toEqual('42');
    });
  }
});

// Extra "helpers" tests
describe('helpers', () => {
  it('Only provide options.fn/inverse to block helpers', () => {
    function toHaveProperties(...args: any[]) {
      toHaveProperties.calls++;
      const options = args[args.length - 1];
      expect(options).toHaveProperty('fn');
      expect(options).toHaveProperty('inverse');
      return 42;
    }
    toHaveProperties.calls = 0;

    function toNotHaveProperties(...args: any[]) {
      toNotHaveProperties.calls++;
      const options = args[args.length - 1];
      expect(options).not.toHaveProperty('fn');
      expect(options).not.toHaveProperty('inverse');
      return 42;
    }
    toNotHaveProperties.calls = 0;

    const nonBlockTemplates = ['{{foo}}', '{{foo 1 2}}'];
    const blockTemplates = ['{{#foo}}42{{/foo}}', '{{#foo 1 2}}42{{/foo}}'];

    for (const template of nonBlockTemplates) {
      expectTemplate(template)
        .withInput({
          foo: toNotHaveProperties,
        })
        .toCompileTo('42');

      expectTemplate(template).withHelper('foo', toNotHaveProperties).toCompileTo('42');
    }

    for (const template of blockTemplates) {
      expectTemplate(template)
        .withInput({
          foo: toHaveProperties,
        })
        .toCompileTo('42');

      expectTemplate(template).withHelper('foo', toHaveProperties).toCompileTo('42');
    }

    const factor = process.env.AST || process.env.EVAL ? 1 : 2;
    expect(toNotHaveProperties.calls).toEqual(nonBlockTemplates.length * 2 * factor);
    expect(toHaveProperties.calls).toEqual(blockTemplates.length * 2 * factor);
  });

  it('should pass expected "this" to helper functions (without input)', () => {
    expectTemplate('{{hello "world" 12 true false}}')
      .withHelper('hello', function (this: any, ...args: any[]) {
        expect(this).toMatchInlineSnapshot(`Object {}`);
      })
      .toCompileTo('');
  });

  it('should pass expected "this" to helper functions (with input)', () => {
    expectTemplate('{{hello "world" 12 true false}}')
      .withHelper('hello', function (this: any, ...args: any[]) {
        expect(this).toMatchInlineSnapshot(`
          Object {
            "people": Array [
              Object {
                "id": 1,
                "name": "Alan",
              },
              Object {
                "id": 2,
                "name": "Yehuda",
              },
            ],
          }
        `);
      })
      .withInput({
        people: [
          { name: 'Alan', id: 1 },
          { name: 'Yehuda', id: 2 },
        ],
      })
      .toCompileTo('');
  });

  it('should pass expected "this" and arguments to helper functions (non-block helper)', () => {
    expectTemplate('{{hello "world" 12 true false}}')
      .withHelper('hello', function (this: any, ...args: any[]) {
        expect(args).toMatchInlineSnapshot(`
          Array [
            "world",
            12,
            true,
            false,
            Object {
              "data": Object {
                "root": Object {
                  "people": Array [
                    Object {
                      "id": 1,
                      "name": "Alan",
                    },
                    Object {
                      "id": 2,
                      "name": "Yehuda",
                    },
                  ],
                },
              },
              "hash": Object {},
              "loc": Object {
                "end": Object {
                  "column": 31,
                  "line": 1,
                },
                "start": Object {
                  "column": 0,
                  "line": 1,
                },
              },
              "lookupProperty": [Function],
              "name": "hello",
            },
          ]
        `);
      })
      .withInput({
        people: [
          { name: 'Alan', id: 1 },
          { name: 'Yehuda', id: 2 },
        ],
      })
      .toCompileTo('');
  });

  it('should pass expected "this" and arguments to helper functions (block helper)', () => {
    expectTemplate('{{#hello "world" 12 true false}}{{/hello}}')
      .withHelper('hello', function (this: any, ...args: any[]) {
        expect(args).toMatchInlineSnapshot(`
          Array [
            "world",
            12,
            true,
            false,
            Object {
              "data": Object {
                "root": Object {
                  "people": Array [
                    Object {
                      "id": 1,
                      "name": "Alan",
                    },
                    Object {
                      "id": 2,
                      "name": "Yehuda",
                    },
                  ],
                },
              },
              "fn": [Function],
              "hash": Object {},
              "inverse": [Function],
              "loc": Object {
                "end": Object {
                  "column": 42,
                  "line": 1,
                },
                "start": Object {
                  "column": 0,
                  "line": 1,
                },
              },
              "lookupProperty": [Function],
              "name": "hello",
            },
          ]
        `);
      })
      .withInput({
        people: [
          { name: 'Alan', id: 1 },
          { name: 'Yehuda', id: 2 },
        ],
      })
      .toCompileTo('');
  });
});

// Extra "blocks" tests
describe('blocks', () => {
  describe('decorators', () => {
    it('should only call decorator once', () => {
      let calls = 0;
      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
      expectTemplate('{{#helper}}{{*decorator}}{{/helper}}')
        .withHelper('helper', () => {})
        .withDecorator('decorator', () => {
          calls++;
        })
        .toCompileTo('');
      expect(calls).toEqual(callsExpected);
    });

    forEachCompileFunctionName((compileName) => {
      it(`should call decorator again if render function is called again for #${compileName}`, () => {
        global.kbnHandlebarsEnv = Handlebars.create();

        kbnHandlebarsEnv!.registerDecorator('decorator', () => {
          calls++;
        });

        const compile = kbnHandlebarsEnv![compileName].bind(kbnHandlebarsEnv);
        const render = compile('{{*decorator}}');

        let calls = 0;
        expect(render()).toEqual('');
        expect(calls).toEqual(1);

        calls = 0;
        expect(render()).toEqual('');
        expect(calls).toEqual(1);

        global.kbnHandlebarsEnv = null;
      });
    });

    it('should pass expected options to nested decorator', () => {
      expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}')
        .withHelper('helper', () => {})
        .withDecorator('decorator', function (fn, props, container, options) {
          expect(options).toMatchInlineSnapshot(`
            Object {
              "args": Array [
                "bar",
              ],
              "data": Object {
                "root": Object {
                  "foo": "bar",
                },
              },
              "hash": Object {},
              "loc": Object {
                "end": Object {
                  "column": 29,
                  "line": 1,
                },
                "start": Object {
                  "column": 11,
                  "line": 1,
                },
              },
              "name": "decorator",
            }
          `);
        })
        .withInput({ foo: 'bar' })
        .toCompileTo('');
    });

    it('should pass expected options to root decorator with no args', () => {
      expectTemplate('{{*decorator}}')
        .withDecorator('decorator', function (fn, props, container, options) {
          expect(options).toMatchInlineSnapshot(`
            Object {
              "args": Array [],
              "data": Object {
                "root": Object {
                  "foo": "bar",
                },
              },
              "hash": Object {},
              "loc": Object {
                "end": Object {
                  "column": 14,
                  "line": 1,
                },
                "start": Object {
                  "column": 0,
                  "line": 1,
                },
              },
              "name": "decorator",
            }
          `);
        })
        .withInput({ foo: 'bar' })
        .toCompileTo('');
    });

    it('should pass expected options to root decorator with one arg', () => {
      expectTemplate('{{*decorator foo}}')
        .withDecorator('decorator', function (fn, props, container, options) {
          expect(options).toMatchInlineSnapshot(`
            Object {
              "args": Array [
                undefined,
              ],
              "data": Object {
                "root": Object {
                  "foo": "bar",
                },
              },
              "hash": Object {},
              "loc": Object {
                "end": Object {
                  "column": 18,
                  "line": 1,
                },
                "start": Object {
                  "column": 0,
                  "line": 1,
                },
              },
              "name": "decorator",
            }
          `);
        })
        .withInput({ foo: 'bar' })
        .toCompileTo('');
    });

    describe('return values', () => {
      for (const [desc, template, result] of [
        ['non-block', '{{*decorator}}cont{{*decorator}}ent', 'content'],
        ['block', '{{#*decorator}}con{{/decorator}}tent', 'tent'],
      ]) {
        describe(desc, () => {
          const falsy = [undefined, null, false, 0, ''];
          const truthy = [true, 42, 'foo', {}];

          // Falsy return values from decorators are simply ignored and the
          // execution falls back to default behavior which is to render the
          // other parts of the template.
          for (const value of falsy) {
            it(`falsy value (type ${typeof value}): ${JSON.stringify(value)}`, () => {
              expectTemplate(template)
                .withDecorator('decorator', () => value)
                .toCompileTo(result);
            });
          }

          // Truthy return values from decorators are expected to be functions
          // and the program will attempt to call them. We expect an error to
          // be thrown in this case.
          for (const value of truthy) {
            it(`non-falsy value (type ${typeof value}): ${JSON.stringify(value)}`, () => {
              expectTemplate(template)
                .withDecorator('decorator', () => value)
                .toThrow('is not a function');
            });
          }

          // If the decorator return value is a custom function, its return
          // value will be the final content of the template.
          for (const value of [...falsy, ...truthy]) {
            it(`function returning ${typeof value}: ${JSON.stringify(value)}`, () => {
              expectTemplate(template)
                .withDecorator('decorator', () => () => value)
                .toCompileTo(value as string);
            });
          }
        });
      }
    });

    describe('custom return function should be called with expected arguments and its return value should be rendered in the template', () => {
      it('root decorator', () => {
        expectTemplate('{{*decorator}}world')
          .withInput({ me: 'my' })
          .withDecorator(
            'decorator',
            (fn): TemplateDelegate =>
              (context, options) => {
                expect(context).toMatchInlineSnapshot(`
              Object {
                "me": "my",
              }
            `);
                expect(options).toMatchInlineSnapshot(`
              Object {
                "decorators": Object {
                  "decorator": [Function],
                },
                "helpers": Object {},
                "partials": Object {},
              }
            `);
                return `hello ${context.me} ${fn()}!`;
              }
          )
          .toCompileTo('hello my world!');
      });

      it('decorator nested inside of array-helper', () => {
        expectTemplate('{{#arr}}{{*decorator}}world{{/arr}}')
          .withInput({ arr: ['my'] })
          .withDecorator(
            'decorator',
            (fn): TemplateDelegate =>
              (context, options) => {
                expect(context).toMatchInlineSnapshot(`"my"`);
                expect(options).toMatchInlineSnapshot(`
              Object {
                "blockParams": Array [
                  "my",
                  0,
                ],
                "data": Object {
                  "_parent": Object {
                    "root": Object {
                      "arr": Array [
                        "my",
                      ],
                    },
                  },
                  "first": true,
                  "index": 0,
                  "key": 0,
                  "last": true,
                  "root": Object {
                    "arr": Array [
                      "my",
                    ],
                  },
                },
              }
            `);
                return `hello ${context} ${fn()}!`;
              }
          )
          .toCompileTo('hello my world!');
      });

      it('decorator nested inside of custom helper', () => {
        expectTemplate('{{#helper}}{{*decorator}}world{{/helper}}')
          .withHelper('helper', function (options: HelperOptions) {
            return options.fn('my', { foo: 'bar' } as any);
          })
          .withDecorator(
            'decorator',
            (fn): TemplateDelegate =>
              (context, options) => {
                expect(context).toMatchInlineSnapshot(`"my"`);
                expect(options).toMatchInlineSnapshot(`
              Object {
                "foo": "bar",
              }
            `);
                return `hello ${context} ${fn()}!`;
              }
          )
          .toCompileTo('hello my world!');
      });
    });

    it('should call multiple decorators in the same program body in the expected order and get the expected output', () => {
      let decoratorCall = 0;
      let progCall = 0;
      expectTemplate('{{*decorator}}con{{*decorator}}tent', {
        beforeRender() {
          // ensure the counters are reset between EVAL/AST render calls
          decoratorCall = 0;
          progCall = 0;
        },
      })
        .withInput({
          decoratorCall: 0,
          progCall: 0,
        })
        .withDecorator('decorator', (fn) => {
          const decoratorCallOrder = ++decoratorCall;
          const ret: TemplateDelegate = () => {
            const progCallOrder = ++progCall;
            return `(decorator: ${decoratorCallOrder}, prog: ${progCallOrder}, fn: "${fn()}")`;
          };
          return ret;
        })
        .toCompileTo('(decorator: 2, prog: 1, fn: "(decorator: 1, prog: 2, fn: "content")")');
    });

    describe('registration', () => {
      beforeEach(() => {
        global.kbnHandlebarsEnv = Handlebars.create();
      });

      afterEach(() => {
        global.kbnHandlebarsEnv = null;
      });

      it('should be able to call decorators registered using the `registerDecorator` function', () => {
        let calls = 0;
        const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;

        kbnHandlebarsEnv!.registerDecorator('decorator', () => {
          calls++;
        });

        expectTemplate('{{*decorator}}').toCompileTo('');
        expect(calls).toEqual(callsExpected);
      });

      it('should not be able to call decorators unregistered using the `unregisterDecorator` function', () => {
        let calls = 0;

        kbnHandlebarsEnv!.registerDecorator('decorator', () => {
          calls++;
        });

        kbnHandlebarsEnv!.unregisterDecorator('decorator');

        expectTemplate('{{*decorator}}').toThrow('lookupProperty(...) is not a function');
        expect(calls).toEqual(0);
      });
    });
  });
});