/*
 * Copyright (C) 2018 StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import {
  SCIndexResponse,
  SCIndexRoute,
  SCMessage,
  SCMultiSearchResponse,
  SCMultiSearchRoute,
  SCSearchRequest,
  SCSearchResponse,
  SCSearchRoute,
  SCThingOriginType,
  SCThingType,
} from '@openstapps/core';
import {expect} from 'chai';
import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import chaiSpies from 'chai-spies';
import {ApiError, OutOfRangeError, Client, HttpClient, HttpClientResponse} from '../src/index.js';

chai.should();
chai.use(chaiSpies);
chai.use(chaiAsPromised);

const sandbox = chai.spy.sandbox();

const indexRoute = new SCIndexRoute();
const multiSearchRoute = new SCMultiSearchRoute();
const searchRoute = new SCSearchRoute();

const httpClient = new HttpClient();

/**
 * Recursive Partial
 *
 * @see https://stackoverflow.com/a/51365037
 */
export type RecursivePartial<T> = {
  [P in keyof T]?: T[P] extends Array<infer U>
    ? Array<RecursivePartial<U>>
    : T[P] extends object
    ? RecursivePartial<T[P]>
    : T[P];
};

async function invokeIndexRoute(): Promise<RecursivePartial<HttpClientResponse<SCIndexResponse>>> {
  return {
    body: {
      app: {
        features: {},
      },
      backend: {
        SCVersion: 'foo.bar.dummy',
      },
    },
    statusCode: indexRoute.statusCodeSuccess,
  };
}

async function invokeIndexRouteFails(): Promise<RecursivePartial<HttpClientResponse<SCIndexResponse>>> {
  return {
    body: {
      backend: {
        SCVersion: 'foo.bar.dummy',
      },
    },
    statusCode: indexRoute.statusCodeSuccess + 1,
  };
}

describe('Client', function () {
  afterEach(function () {
    sandbox.restore();
  });

  it('should construct', function () {
    expect(() => {
      return new Client(httpClient, 'http://localhost');
    }).not.to.throw();
  });

  it('should construct with headers', async function () {
    sandbox.on(httpClient, 'request', invokeIndexRoute);

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost', 'foo.foo.foo');
    await client.handshake('foo.bar.dummy');

    expect(httpClient.request).to.have.been.first.called.with({
      body: {},
      headers: {
        'Content-Type': 'application/json',
        'X-StApps-Version': 'foo.foo.foo',
      },
      method: indexRoute.method,
      url: new URL('http://localhost' + indexRoute.getUrlPath()),
    });
  });

  it('should get thing', async function () {
    const message: SCMessage = {
      audiences: ['employees'],
      categories: ['news'],
      messageBody: 'Lorem ipsum.',
      name: 'foo',
      origin: {
        indexed: 'foo',
        name: 'foo',
        type: SCThingOriginType.Remote,
      },
      type: SCThingType.Message,
      uid: 'foo',
    };

    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCSearchResponse>> => {
      return {
        body: {
          data: [message],
          facets: [],
          pagination: {
            count: 0,
            offset: 0,
            total: 0,
          },
          stats: {
            time: 0,
          },
        },
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.getThing('foo');

    expect(httpClient.request).to.have.been.first.called.with({
      body: {
        filter: {
          arguments: {
            field: 'uid',
            value: 'foo',
          },
          type: 'value',
        },
        size: 1,
      },
      headers: {
        'Content-Type': 'application/json',
      },
      method: searchRoute.method,
      url: new URL('http://localhost' + searchRoute.getUrlPath()),
    });
  });

  it('should fail getThing by empty response', async function () {
    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCSearchResponse>> => {
      return {
        body: {
          data: [],
          facets: [],
          pagination: {
            count: 0,
            offset: 0,
            total: 0,
          },
          stats: {
            time: 0,
          },
        },
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');

    return client.getThing('bar').should.be.rejected;
  });

  it('should fail getThing by uid', async function () {
    const message: SCMessage = {
      audiences: ['employees'],
      categories: ['news'],
      messageBody: 'Lorem ipsum.',
      name: 'foo',
      origin: {
        indexed: 'foo',
        name: 'foo',
        type: SCThingOriginType.Remote,
      },
      type: SCThingType.Message,
      uid: 'foo',
    };

    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCSearchResponse>> => {
      return {
        body: {
          data: [message],
          facets: [],
          pagination: {
            count: 0,
            offset: 0,
            total: 0,
          },
          stats: {
            time: 0,
          },
        },
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');

    return client.getThing('bar').should.be.rejected;
  });

  it('should handshake', async function () {
    sandbox.on(httpClient, 'request', invokeIndexRoute);

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.handshake('foo.bar.dummy');

    expect(httpClient.request).to.have.been.first.called.with({
      body: {},
      headers: {
        'Content-Type': 'application/json',
      },
      method: indexRoute.method,
      url: new URL('http://localhost' + indexRoute.getUrlPath()),
    });
  });

  it('should fail handshake', async function () {
    sandbox.on(httpClient, 'request', invokeIndexRoute);

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');

    return client.handshake('bar.bar.dummy').should.be.rejectedWith(ApiError);
  });

  it('should invoke plugin', async function () {
    sandbox.on(
      httpClient,
      'request',
      async (): Promise<RecursivePartial<HttpClientResponse<SCIndexResponse>>> => {
        return {
          body: {
            app: {
              features: {
                plugins: {
                  supportedPlugin: {urlPath: '/'},
                },
              },
            },
          },
          statusCode: indexRoute.statusCodeSuccess,
        };
      },
    );

    expect(httpClient.request).not.to.have.been.first.called();
    const client = new Client(httpClient, 'http://localhost');

    await client.invokePlugin('unsupportedPlugin').should.be.rejectedWith(ApiError, /.*supportedPlugin.*/gim);

    // again with cached feature definitions
    await client
      .invokePlugin('supportedPlugin')
      .should.not.be.rejectedWith(ApiError, /.*supportedPlugin.*/gim);
  });

  it('should invoke unavailable plugin', async function () {
    sandbox.on(
      httpClient,
      'request',
      async (): Promise<RecursivePartial<HttpClientResponse<SCIndexResponse>>> => {
        return {
          body: {},
          statusCode: indexRoute.statusCodeSuccess,
        };
      },
    );

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');

    await client.invokePlugin('supportedPlugin').should.be.rejectedWith(ApiError, /.*supportedPlugin.*/gim);

    sandbox.restore();
    sandbox.on(
      httpClient,
      'request',
      async (): Promise<RecursivePartial<HttpClientResponse<SCIndexResponse>>> => {
        return {
          body: {
            app: {
              features: {
                plugins: {
                  unsupportedPlugin: {
                    urlPath: '/unsupported-plugin',
                  },
                },
              },
            },
          },
          statusCode: indexRoute.statusCodeSuccess,
        };
      },
    );
    // again with cached feature definitions
    return client.invokePlugin('supportedPlugin').should.be.rejectedWith(ApiError, /.*supportedPlugin.*/gim);
  });

  it('should invoke route', async function () {
    sandbox.on(httpClient, 'request', invokeIndexRoute);

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.invokeRoute(indexRoute);

    expect(httpClient.request).to.have.been.first.called.with({
      body: undefined,
      headers: {
        'Content-Type': 'application/json',
      },
      method: indexRoute.method,
      url: new URL('http://localhost' + indexRoute.getUrlPath()),
    });
  });

  it('should fail to invoke route', async function () {
    sandbox.on(httpClient, 'request', invokeIndexRouteFails);

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');

    return client.invokeRoute(indexRoute).should.be.rejectedWith(ApiError);
  });

  it('should multi search', async function () {
    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCMultiSearchResponse>> => {
      return {
        body: {
          a: {
            data: [],
            facets: [],
            pagination: {
              count: 0,
              offset: 0,
              total: 0,
            },
            stats: {
              time: 0,
            },
          },
          b: {
            data: [],
            facets: [],
            pagination: {
              count: 0,
              offset: 0,
              total: 0,
            },
            stats: {
              time: 0,
            },
          },
        },
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.multiSearch({a: {size: 1}, b: {size: 1}});

    expect(httpClient.request).to.have.been.first.called.with({
      body: {a: {size: 1}, b: {size: 1}},
      headers: {
        'Content-Type': 'application/json',
      },
      method: multiSearchRoute.method,
      url: new URL('http://localhost' + multiSearchRoute.getUrlPath()),
    });
  });

  it('should multi search with preflight', async function () {
    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCMultiSearchResponse>> => {
      return {
        body: {
          bar: {
            data: [],
            facets: [],
            pagination: {
              count: 0,
              offset: 0,
              total: 500,
            },
            stats: {
              time: 0,
            },
          },
          foo: {
            data: [],
            facets: [],
            pagination: {
              count: 0,
              offset: 0,
              total: 1000,
            },
            stats: {
              time: 0,
            },
          },
        },
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.multiSearch({foo: {}, bar: {}, foobar: {size: 30}});

    expect(httpClient.request).to.have.been.first.called.with({
      body: {foo: {size: 0}, bar: {size: 0}},
      headers: {
        'Content-Type': 'application/json',
      },
      method: multiSearchRoute.method,
      url: new URL('http://localhost' + multiSearchRoute.getUrlPath()),
    });
    expect(httpClient.request).to.have.been.second.called.with({
      body: {foo: {size: 1000}, bar: {size: 500}, foobar: {size: 30}},
      headers: {
        'Content-Type': 'application/json',
      },
      method: multiSearchRoute.method,
      url: new URL('http://localhost' + multiSearchRoute.getUrlPath()),
    });
  });

  it('should next window', async function () {
    let searchRequest: SCSearchRequest = {size: 30};
    const searchResponse: SCSearchResponse = {
      data: [],
      facets: [],
      pagination: {
        count: 30,
        offset: 0,
        total: 60,
      },
      stats: {
        time: 0,
      },
    };

    searchRequest = Client.nextWindow(searchRequest, searchResponse);

    expect(searchRequest.from).to.equal(30);

    searchResponse.pagination.offset = 30;

    expect(() => {
      Client.nextWindow(searchRequest, searchResponse);
    }).to.throw(OutOfRangeError);
  });

  it('should search', async function () {
    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCSearchResponse>> => {
      return {
        body: {
          data: [],
          facets: [],
          pagination: {
            count: 0,
            offset: 0,
            total: 0,
          },
          stats: {
            time: 0,
          },
        },
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.search({size: 1});

    expect(httpClient.request).to.have.been.first.called.with({
      body: {size: 1},
      headers: {
        'Content-Type': 'application/json',
      },
      method: searchRoute.method,
      url: new URL('http://localhost' + searchRoute.getUrlPath()),
    });
  });

  it('should search next', async function () {
    const searchResponse: SCSearchResponse = {
      data: [],
      facets: [],
      pagination: {
        count: 30,
        offset: 0,
        total: 60,
      },
      stats: {
        time: 0,
      },
    };

    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCSearchResponse>> => {
      return {
        body: searchResponse,
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.searchNext({from: 0, size: 30}, searchResponse);

    expect(httpClient.request).to.have.been.first.called.with({
      body: {from: 30, size: 30},
      headers: {
        'Content-Type': 'application/json',
      },
      method: searchRoute.method,
      url: new URL('http://localhost' + searchRoute.getUrlPath()),
    });
  });

  it('should search with preflight', async function () {
    sandbox.on(httpClient, 'request', async (): Promise<HttpClientResponse<SCSearchResponse>> => {
      return {
        body: {
          data: [],
          facets: [],
          pagination: {
            count: 0,
            offset: 0,
            total: 1000,
          },
          stats: {
            time: 0,
          },
        },
        headers: {},
        statusCode: searchRoute.statusCodeSuccess,
      };
    });

    expect(httpClient.request).not.to.have.been.first.called();

    const client = new Client(httpClient, 'http://localhost');
    await client.search({});

    expect(httpClient.request).to.have.been.first.called.with({
      body: {size: 0},
      headers: {
        'Content-Type': 'application/json',
      },
      method: searchRoute.method,
      url: new URL('http://localhost' + searchRoute.getUrlPath()),
    });
    expect(httpClient.request).to.have.been.second.called.with({
      body: {size: 1000},
      headers: {
        'Content-Type': 'application/json',
      },
      method: searchRoute.method,
      url: new URL('http://localhost' + searchRoute.getUrlPath()),
    });
  });
});
