'use strict';

/**
 * node/hooks/express/openapi.js
 *
 * This module generates OpenAPI definitions for each API version defined by
 * APIHandler.js and hooks into express to route the API using openapi-backend.
 *
 * The openapi definition files are publicly available under:
 *
 * - /api/openapi.json
 * - /rest/openapi.json
 * - /api/{version}/openapi.json
 * - /rest/{version}/openapi.json
 */

const OpenAPIBackend = require('openapi-backend').default;
const formidable = require('formidable');
const {promisify} = require('util');
const cloneDeep = require('lodash.clonedeep');
const createHTTPError = require('http-errors');

const apiHandler = require('../../handler/APIHandler');
const settings = require('../../utils/Settings');

const log4js = require('log4js');
const logger = log4js.getLogger('API');

// https://github.com/OAI/OpenAPI-Specification/tree/master/schemas/v3.0
const OPENAPI_VERSION = '3.0.2'; // Swagger/OAS version

const info = {
  title: 'Etherpad API',
  description:
      'Etherpad is a real-time collaborative editor scalable to thousands of simultaneous ' +
      'real time users. It provides full data export capabilities, and runs on your server, ' +
      'under your control.',
  termsOfService: 'https://etherpad.org/',
  contact: {
    name: 'The Etherpad Foundation',
    url: 'https://etherpad.org/',
    email: 'support@example.com',
  },
  license: {
    name: 'Apache 2.0',
    url: 'https://www.apache.org/licenses/LICENSE-2.0.html',
  },
  version: apiHandler.latestApiVersion,
};

const APIPathStyle = {
  FLAT: 'api', // flat paths e.g. /api/createGroup
  REST: 'rest', // restful paths e.g. /rest/group/create
};

// API resources - describe your API endpoints here
const resources = {
  // Group
  group: {
    create: {
      operationId: 'createGroup',
      summary: 'creates a new group',
      responseSchema: {groupID: {type: 'string'}},
    },
    createIfNotExistsFor: {
      operationId: 'createGroupIfNotExistsFor',
      summary: 'this functions helps you to map your application group ids to Etherpad group ids',
      responseSchema: {groupID: {type: 'string'}},
    },
    delete: {
      operationId: 'deleteGroup',
      summary: 'deletes a group',
    },
    listPads: {
      operationId: 'listPads',
      summary: 'returns all pads of this group',
      responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
    },
    createPad: {
      operationId: 'createGroupPad',
      summary: 'creates a new pad in this group',
    },
    listSessions: {
      operationId: 'listSessionsOfGroup',
      summary: '',
      responseSchema: {
        sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
      },
    },
    list: {
      operationId: 'listAllGroups',
      summary: '',
      responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}},
    },
  },

  // Author
  author: {
    create: {
      operationId: 'createAuthor',
      summary: 'creates a new author',
      responseSchema: {authorID: {type: 'string'}},
    },
    createIfNotExistsFor: {
      operationId: 'createAuthorIfNotExistsFor',
      summary: 'this functions helps you to map your application author ids to Etherpad author ids',
      responseSchema: {authorID: {type: 'string'}},
    },
    listPads: {
      operationId: 'listPadsOfAuthor',
      summary: 'returns an array of all pads this author contributed to',
      responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
    },
    listSessions: {
      operationId: 'listSessionsOfAuthor',
      summary: 'returns all sessions of an author',
      responseSchema: {
        sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},
      },
    },
    // We need an operation that return a UserInfo so it can be picked up by the codegen :(
    getName: {
      operationId: 'getAuthorName',
      summary: 'Returns the Author Name of the author',
      responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}},
    },
  },

  // Session
  session: {
    create: {
      operationId: 'createSession',
      summary: 'creates a new session. validUntil is an unix timestamp in seconds',
      responseSchema: {sessionID: {type: 'string'}},
    },
    delete: {
      operationId: 'deleteSession',
      summary: 'deletes a session',
    },
    // We need an operation that returns a SessionInfo so it can be picked up by the codegen :(
    info: {
      operationId: 'getSessionInfo',
      summary: 'returns information about a session',
      responseSchema: {info: {$ref: '#/components/schemas/SessionInfo'}},
    },
  },

  // Pad
  pad: {
    listAll: {
      operationId: 'listAllPads',
      summary: 'list all the pads',
      responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},
    },
    createDiffHTML: {
      operationId: 'createDiffHTML',
      summary: '',
      responseSchema: {},
    },
    create: {
      operationId: 'createPad',
      description:
          'creates a new (non-group) pad. Note that if you need to create a group Pad, ' +
          'you should call createGroupPad',
    },
    getText: {
      operationId: 'getText',
      summary: 'returns the text of a pad',
      responseSchema: {text: {type: 'string'}},
    },
    setText: {
      operationId: 'setText',
      summary: 'sets the text of a pad',
    },
    getHTML: {
      operationId: 'getHTML',
      summary: 'returns the text of a pad formatted as HTML',
      responseSchema: {html: {type: 'string'}},
    },
    setHTML: {
      operationId: 'setHTML',
      summary: 'sets the text of a pad with HTML',
    },
    getRevisionsCount: {
      operationId: 'getRevisionsCount',
      summary: 'returns the number of revisions of this pad',
      responseSchema: {revisions: {type: 'integer'}},
    },
    getLastEdited: {
      operationId: 'getLastEdited',
      summary: 'returns the timestamp of the last revision of the pad',
      responseSchema: {lastEdited: {type: 'integer'}},
    },
    delete: {
      operationId: 'deletePad',
      summary: 'deletes a pad',
    },
    getReadOnlyID: {
      operationId: 'getReadOnlyID',
      summary: 'returns the read only link of a pad',
      responseSchema: {readOnlyID: {type: 'string'}},
    },
    setPublicStatus: {
      operationId: 'setPublicStatus',
      summary: 'sets a boolean for the public status of a pad',
    },
    getPublicStatus: {
      operationId: 'getPublicStatus',
      summary: 'return true of false',
      responseSchema: {publicStatus: {type: 'boolean'}},
    },
    authors: {
      operationId: 'listAuthorsOfPad',
      summary: 'returns an array of authors who contributed to this pad',
      responseSchema: {authorIDs: {type: 'array', items: {type: 'string'}}},
    },
    usersCount: {
      operationId: 'padUsersCount',
      summary: 'returns the number of user that are currently editing this pad',
      responseSchema: {padUsersCount: {type: 'integer'}},
    },
    users: {
      operationId: 'padUsers',
      summary: 'returns the list of users that are currently editing this pad',
      responseSchema: {padUsers: {type: 'array', items: {$ref: '#/components/schemas/UserInfo'}}},
    },
    sendClientsMessage: {
      operationId: 'sendClientsMessage',
      summary: 'sends a custom message of type msg to the pad',
    },
    checkToken: {
      operationId: 'checkToken',
      summary: 'returns ok when the current api token is valid',
    },
    getChatHistory: {
      operationId: 'getChatHistory',
      summary: 'returns the chat history',
      responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}},
    },
    // We need an operation that returns a Message so it can be picked up by the codegen :(
    getChatHead: {
      operationId: 'getChatHead',
      summary: 'returns the chatHead (chat-message) of the pad',
      responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}},
    },
    appendChatMessage: {
      operationId: 'appendChatMessage',
      summary: 'appends a chat message',
    },
  },
};

const defaultResponses = {
  Success: {
    description: 'ok (code 0)',
    content: {
      'application/json': {
        schema: {
          type: 'object',
          properties: {
            code: {
              type: 'integer',
              example: 0,
            },
            message: {
              type: 'string',
              example: 'ok',
            },
            data: {
              type: 'object',
              example: null,
            },
          },
        },
      },
    },
  },
  ApiError: {
    description: 'generic api error (code 1)',
    content: {
      'application/json': {
        schema: {
          type: 'object',
          properties: {
            code: {
              type: 'integer',
              example: 1,
            },
            message: {
              type: 'string',
              example: 'error message',
            },
            data: {
              type: 'object',
              example: null,
            },
          },
        },
      },
    },
  },
  InternalError: {
    description: 'internal api error (code 2)',
    content: {
      'application/json': {
        schema: {
          type: 'object',
          properties: {
            code: {
              type: 'integer',
              example: 2,
            },
            message: {
              type: 'string',
              example: 'internal error',
            },
            data: {
              type: 'object',
              example: null,
            },
          },
        },
      },
    },
  },
  NotFound: {
    description: 'no such function (code 4)',
    content: {
      'application/json': {
        schema: {
          type: 'object',
          properties: {
            code: {
              type: 'integer',
              example: 3,
            },
            message: {
              type: 'string',
              example: 'no such function',
            },
            data: {
              type: 'object',
              example: null,
            },
          },
        },
      },
    },
  },
  Unauthorized: {
    description: 'no or wrong API key (code 4)',
    content: {
      'application/json': {
        schema: {
          type: 'object',
          properties: {
            code: {
              type: 'integer',
              example: 4,
            },
            message: {
              type: 'string',
              example: 'no or wrong API key',
            },
            data: {
              type: 'object',
              example: null,
            },
          },
        },
      },
    },
  },
};

const defaultResponseRefs = {
  200: {
    $ref: '#/components/responses/Success',
  },
  400: {
    $ref: '#/components/responses/ApiError',
  },
  401: {
    $ref: '#/components/responses/Unauthorized',
  },
  500: {
    $ref: '#/components/responses/InternalError',
  },
};

// convert to a dictionary of operation objects
const operations = {};
for (const [resource, actions] of Object.entries(resources)) {
  for (const [action, spec] of Object.entries(actions)) {
    const {operationId, responseSchema, ...operation} = spec;

    // add response objects
    const responses = {...defaultResponseRefs};
    if (responseSchema) {
      responses[200] = cloneDeep(defaultResponses.Success);
      responses[200].content['application/json'].schema.properties.data = {
        type: 'object',
        properties: responseSchema,
      };
    }

    // add final operation object to dictionary
    operations[operationId] = {
      operationId,
      ...operation,
      responses,
      tags: [resource],
      _restPath: `/${resource}/${action}`,
    };
  }
}

const generateDefinitionForVersion = (version, style = APIPathStyle.FLAT) => {
  const definition = {
    openapi: OPENAPI_VERSION,
    info,
    paths: {},
    components: {
      parameters: {},
      schemas: {
        SessionInfo: {
          type: 'object',
          properties: {
            id: {
              type: 'string',
            },
            authorID: {
              type: 'string',
            },
            groupID: {
              type: 'string',
            },
            validUntil: {
              type: 'integer',
            },
          },
        },
        UserInfo: {
          type: 'object',
          properties: {
            id: {
              type: 'string',
            },
            colorId: {
              type: 'string',
            },
            name: {
              type: 'string',
            },
            timestamp: {
              type: 'integer',
            },
          },
        },
        Message: {
          type: 'object',
          properties: {
            text: {
              type: 'string',
            },
            userId: {
              type: 'string',
            },
            userName: {
              type: 'string',
            },
            time: {
              type: 'integer',
            },
          },
        },
      },
      responses: {
        ...defaultResponses,
      },
      securitySchemes: {
        ApiKey: {
          type: 'apiKey',
          in: 'query',
          name: 'apikey',
        },
      },
    },
    security: [{ApiKey: []}],
  };

  // build operations
  for (const funcName of Object.keys(apiHandler.version[version])) {
    let operation = {};
    if (operations[funcName]) {
      operation = {...operations[funcName]};
    } else {
      // console.warn(`No operation found for function: ${funcName}`);
      operation = {
        operationId: funcName,
        responses: defaultResponseRefs,
      };
    }

    // set parameters
    operation.parameters = operation.parameters || [];
    for (const paramName of apiHandler.version[version][funcName]) {
      operation.parameters.push({$ref: `#/components/parameters/${paramName}`});
      if (!definition.components.parameters[paramName]) {
        definition.components.parameters[paramName] = {
          name: paramName,
          in: 'query',
          schema: {
            type: 'string',
          },
        };
      }
    }

    // set path
    let path = `/${operation.operationId}`; // APIPathStyle.FLAT
    if (style === APIPathStyle.REST && operation._restPath) {
      path = operation._restPath;
    }
    delete operation._restPath;

    // add to definition
    // NOTE: It may be confusing that every operation can be called with both GET and POST
    definition.paths[path] = {
      get: {
        ...operation,
        operationId: `${operation.operationId}UsingGET`,
      },
      post: {
        ...operation,
        operationId: `${operation.operationId}UsingPOST`,
      },
    };
  }
  return definition;
};

exports.expressPreSession = async (hookName, {app}) => {
  // create openapi-backend handlers for each api version under /api/{version}/*
  for (const version of Object.keys(apiHandler.version)) {
    // we support two different styles of api: flat + rest
    // TODO: do we really want to support both?

    for (const style of [APIPathStyle.FLAT, APIPathStyle.REST]) {
      const apiRoot = getApiRootForVersion(version, style);

      // generate openapi definition for this API version
      const definition = generateDefinitionForVersion(version, style);

      // serve version specific openapi definition
      app.get(`${apiRoot}/openapi.json`, (req, res) => {
        // For openapi definitions, wide CORS is probably fine
        res.header('Access-Control-Allow-Origin', '*');
        res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]});
      });

      // serve latest openapi definition file under /api/openapi.json
      const isLatestAPIVersion = version === apiHandler.latestApiVersion;
      if (isLatestAPIVersion) {
        app.get(`/${style}/openapi.json`, (req, res) => {
          res.header('Access-Control-Allow-Origin', '*');
          res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]});
        });
      }

      // build openapi-backend instance for this api version
      const api = new OpenAPIBackend({
        definition,
        validate: false,
        // for a small optimisation, we can run the quick startup for older
        // API versions since they are subsets of the latest api definition
        quick: !isLatestAPIVersion,
      });

      // register default handlers
      api.register({
        notFound: () => {
          throw new createHTTPError.NotFound('no such function');
        },
        notImplemented: () => {
          throw new createHTTPError.NotImplemented('function not implemented');
        },
      });

      // register operation handlers
      for (const funcName of Object.keys(apiHandler.version[version])) {
        const handler = async (c, req, res) => {
          // parse fields from request
          const {header, params, query} = c.request;

          // read form data if method was POST
          let formData = {};
          if (c.request.method === 'post') {
            const form = new formidable.IncomingForm();
            const parseForm = promisify(form.parse).bind(form);
            formData = await parseForm(req);
          }

          const fields = Object.assign({}, header, params, query, formData);

          if (logger.isDebugEnabled()) {
            logger.debug(`REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`);
          }

          // pass to api handler
          let data;
          try {
            data = await apiHandler.handle(version, funcName, fields, req, res);
          } catch (err) {
            // convert all errors to http errors
            if (createHTTPError.isHttpError(err)) {
              // pass http errors thrown by handler forward
              throw err;
            } else if (err.name === 'apierror') {
              // parameters were wrong and the api stopped execution, pass the error
              // convert to http error
              throw new createHTTPError.BadRequest(err.message);
            } else {
              // an unknown error happened
              // log it and throw internal error
              logger.error(err.stack || err.toString());
              throw new createHTTPError.InternalError('internal error');
            }
          }

          // return in common format
          const response = {code: 0, message: 'ok', data: data || null};

          if (logger.isDebugEnabled()) {
            logger.debug(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`);
          }

          // return the response data
          return response;
        };

        // each operation can be called with either GET or POST
        api.register(`${funcName}UsingGET`, handler);
        api.register(`${funcName}UsingPOST`, handler);
      }

      // start and bind to express
      api.init();
      app.use(apiRoot, async (req, res) => {
        let response = null;
        try {
          if (style === APIPathStyle.REST) {
            // @TODO: Don't allow CORS from everywhere
            // This is purely to maintain compatibility with old swagger-node-express
            res.header('Access-Control-Allow-Origin', '*');
          }
          // pass to openapi-backend handler
          response = await api.handleRequest(req, req, res);
        } catch (err) {
          // handle http errors
          res.statusCode = err.statusCode || 500;

          // convert to our json response format
          // https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format
          switch (res.statusCode) {
            case 403: // forbidden
              response = {code: 4, message: err.message, data: null};
              break;
            case 401: // unauthorized (no or wrong api key)
              response = {code: 4, message: err.message, data: null};
              break;
            case 404: // not found (no such function)
              response = {code: 3, message: err.message, data: null};
              break;
            case 500: // server error (internal error)
              response = {code: 2, message: err.message, data: null};
              break;
            case 400: // bad request (wrong parameters)
              // respond with 200 OK to keep old behavior and pass tests
              res.statusCode = 200; // @TODO: this is bad api design
              response = {code: 1, message: err.message, data: null};
              break;
            default:
              response = {code: 1, message: err.message, data: null};
              break;
          }
        }

        // send response
        return res.send(response);
      });
    }
  }
};

// helper to get api root
const getApiRootForVersion = (version, style = APIPathStyle.FLAT) => `/${style}/${version}`;

// helper to generate an OpenAPI server object when serving definitions
const generateServerForApiVersion = (apiRoot, req) => ({
  url: `${settings.ssl ? 'https' : 'http'}://${req.headers.host}${apiRoot}`,
});
