// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * OpenAPI 3.1 export for the Web Runtime capability registry: turns
 * `CapabilityRegistry.list()` into a documented, introspectable Web API.
 *
 * CLI-only — not re-exported from `lib/runtime/index.js`, so it isn't
 * part of the published `rclnodejs/web/server` API. Go through the
 * `rclnodejs-web openapi` subcommand instead of importing this directly.
 *
 * Reuses core's message introspection (`message_validation.js`'s
 * `getMessageSchema()`) rather than re-parsing rosidl ASTs, and resolves
 * types via `interface_loader` — so this runs standalone against just a
 * `web.json` config, without any transport or ROS graph. Still needs
 * ROS 2 sourced, though: resolving a message type loads rclnodejs's
 * native addon as a side effect, and without it the loader may fall back
 * to a slow source rebuild.
 */

import interfaceLoader from './interface_loader.js';
import { getMessageSchema } from './message_validation.js';

/**
 * Map one ROS field-type descriptor (from `getMessageSchema()`) to a
 * JSON Schema fragment.
 *
 * One deliberate deviation: 64-bit integers map to `type: string`, not
 * `type: integer` — OpenAPI's `format: int64` is documentation-only, and
 * JSON numbers can't safely hold 64-bit precision. rclnodejs's own wire
 * convention already sends `int64`/`uint64` as `"<digits>n"` strings, so
 * the schema follows that instead of the nominal `integer`/`int64` pairing.
 *
 * @param {object} fieldType - a field's `type` descriptor
 * @param {Map<string,object>} components - accumulator for nested-message
 *   component schemas, keyed by component name
 * @returns {object} a JSON Schema fragment
 */
function rosFieldTypeToJsonSchema(fieldType, components) {
  if (fieldType.isArray) {
    // Recurse for the element schema; ROS has no array-of-array, so
    // clearing `isArray` always lands in a branch below.
    const itemSchema = rosFieldTypeToJsonSchema(
      { ...fieldType, isArray: false },
      components
    );
    const arraySchema = { type: 'array', items: itemSchema };
    if (fieldType.isFixedSizeArray && fieldType.arraySize != null) {
      arraySchema.minItems = fieldType.arraySize;
      arraySchema.maxItems = fieldType.arraySize;
    } else if (fieldType.isUpperBound && fieldType.arraySize != null) {
      arraySchema.maxItems = fieldType.arraySize;
    }
    return arraySchema;
  }

  if (fieldType.isPrimitiveType) {
    return primitiveToJsonSchema(fieldType);
  }

  // Nested message type — register as a component and return a $ref so
  // repeated uses of the same type (e.g. geometry_msgs/msg/Pose across many
  // capabilities) share one schema instead of being inlined N times.
  const componentName = `${fieldType.pkgName}__msg__${fieldType.type}`;
  const typeName = `${fieldType.pkgName}/msg/${fieldType.type}`;
  registerComponent(typeName, componentName, components);
  return { $ref: `#/components/schemas/${componentName}` };
}

const INT64_TYPES = new Set(['int64', 'uint64']);

function primitiveToJsonSchema(fieldType) {
  const { type, stringUpperBound } = fieldType;

  if (type === 'bool') return { type: 'boolean' };
  if (INT64_TYPES.has(type)) {
    // Deliberately string, not integer (see docstring above). uint64 gets
    // an unsigned-only pattern so the schema can't claim negatives are valid.
    const unsigned = type === 'uint64';
    return {
      type: 'string',
      format: unsigned ? 'uint64' : 'int64',
      pattern: unsigned ? '^[0-9]+n$' : '^-?[0-9]+n$',
      example: '42n',
      description: `ROS 2 ${type}, transmitted as a BigInt-string (e.g. "42n") for precision-safety.`,
    };
  }
  if (
    [
      'int8',
      'uint8',
      'int16',
      'uint16',
      'int32',
      'uint32',
      'byte',
      'char',
    ].includes(type)
  ) {
    return { type: 'integer' };
  }
  if (['float32', 'float64'].includes(type)) {
    return { type: 'number' };
  }
  if (type === 'string' || type === 'wstring') {
    const schema = { type: 'string' };
    if (stringUpperBound != null && stringUpperBound > 0) {
      schema.maxLength = stringUpperBound;
    }
    return schema;
  }
  // Unknown/unmapped primitive — fall back to permissive rather than
  // silently wrong.
  return {};
}

/**
 * Resolve a ROS message type name into a JSON Schema object (properties per
 * field), registering it into `components` under `componentName` so nested
 * `$ref`s can point at it. No-op if already registered.
 *
 * No cycle guard: a message value type can't structurally reference itself
 * (directly or indirectly) — rosidl generates fixed-size value types, and a
 * self-referential one would need infinite size, so it can't compile.
 */
function registerComponent(typeName, componentName, components) {
  if (components.has(componentName)) return;

  let typeClass;
  try {
    typeClass = interfaceLoader.loadInterface(typeName);
  } catch {
    components.set(componentName, {
      type: 'object',
      description: `Could not resolve ${typeName}`,
    });
    return;
  }

  const schema = getMessageSchema(typeClass);
  components.set(componentName, messageSchemaToJsonSchema(schema, components));
}

/**
 * Convert a `getMessageSchema()`-shaped object into a JSON Schema Object
 * (`{type: 'object', properties: {...}}`), recursively registering any
 * nested message types into `components`.
 */
function messageSchemaToJsonSchema(schema, components) {
  const properties = {};
  const required = [];
  for (const field of schema.fields || []) {
    if (field.name.startsWith('_')) continue;
    properties[field.name] = rosFieldTypeToJsonSchema(field.type, components);
    required.push(field.name);
  }
  const jsonSchema = { type: 'object', properties };
  if (required.length) jsonSchema.required = required;
  if (schema.messageType) jsonSchema['x-ros-type'] = schema.messageType;
  return jsonSchema;
}

/**
 * Resolve a top-level capability type (message for publish/subscribe,
 * service Request/Response for call) to a JSON Schema, without registering
 * it as a component itself (the top-level request/response body is inlined
 * in the operation, only *nested* types become `$ref`d components — this
 * matches typical OpenAPI style for RPC-shaped APIs).
 */
function topLevelSchema(typeName, subType, components) {
  let typeClass;
  try {
    typeClass = interfaceLoader.loadInterface(typeName);
  } catch {
    return { type: 'object', description: `Could not resolve ${typeName}` };
  }
  const resolved = subType ? typeClass[subType] : typeClass;
  const schema = getMessageSchema(resolved);
  if (!schema) {
    return { type: 'object', description: `Could not resolve ${typeName}` };
  }
  return messageSchemaToJsonSchema(schema, components);
}

/**
 * Build a full OpenAPI 3.1 document from a capability registry snapshot
 * (`CapabilityRegistry.list()`'s shape: `{call, publish, subscribe}`, each a
 * `{name: typeName}` map).
 *
 * No `servers` option: it's pure top-level metadata this function never
 * reads while building `paths`, so callers (e.g. the CLI's
 * `openApiServers()`) attach it to the returned document directly instead.
 *
 * No `version` option either: `info.version` describes the caller's API,
 * not the rclnodejs release that generated the document, and there's no
 * source for the former today — so it's a fixed `'0.0.0'` placeholder.
 *
 * @param {{call: object, publish: object, subscribe: object}} capabilities
 * @param {object} [options]
 * @param {string} [options.title]
 * @param {string} [options.basePath] - default '/capability'
 * @returns {object} an OpenAPI 3.1 document (plain object; caller decides
 *   JSON vs. YAML serialization)
 */
function buildOpenApiDocument(capabilities, options = {}) {
  const { title = 'rclnodejs/web capability API' } = options;
  // Match HttpTransport's normalisation so a trailing-slash basePath
  // can't produce a route the runtime doesn't actually serve.
  const basePath = _normaliseBasePath(options.basePath);

  const components = new Map();
  const paths = {};

  for (const [name, typeName] of Object.entries(capabilities.call || {})) {
    const route = `${basePath}/call${name}`;
    paths[route] = {
      post: {
        summary: `Call ROS 2 service ${name}`,
        operationId: `call_${sanitizeName(name)}`,
        'x-ros-capability': { kind: 'call', name, type: typeName },
        requestBody: {
          required: true,
          content: {
            'application/json': {
              schema: topLevelSchema(typeName, 'Request', components),
            },
          },
        },
        responses: {
          200: {
            description: 'ROS 2 service response',
            content: {
              'application/json': {
                schema: topLevelSchema(typeName, 'Response', components),
              },
            },
          },
          404: notExposedResponse(),
        },
      },
    };
  }

  for (const [name, typeName] of Object.entries(capabilities.publish || {})) {
    const route = `${basePath}/publish${name}`;
    paths[route] = {
      post: {
        summary: `Publish to ROS 2 topic ${name}`,
        operationId: `publish_${sanitizeName(name)}`,
        'x-ros-capability': { kind: 'publish', name, type: typeName },
        requestBody: {
          required: true,
          content: {
            'application/json': {
              schema: topLevelSchema(typeName, null, components),
            },
          },
        },
        responses: {
          204: { description: 'Published, no content' },
          404: notExposedResponse(),
        },
      },
    };
  }

  for (const [name, typeName] of Object.entries(capabilities.subscribe || {})) {
    const route = `${basePath}/subscribe${name}`;
    paths[route] = {
      get: {
        summary: `Subscribe to ROS 2 topic ${name} via Server-Sent Events`,
        operationId: `subscribe_${sanitizeName(name)}`,
        'x-ros-capability': { kind: 'subscribe', name, type: typeName },
        description:
          'Requires the HTTP transport to be started with `sse: true` ' +
          '(`--http-sse` on the CLI). Shipped in rclnodejs 2.1.1. ' +
          '**Not testable via "Try it out"**: this response is an ' +
          'unbounded stream that never completes, and API explorers ' +
          '(e.g. Swagger UI) wait for the response body to finish before ' +
          'displaying it, so the request will appear to hang forever. Use ' +
          '`curl -N` or a browser `EventSource` instead.',
        responses: {
          200: {
            description: `Server-Sent Events stream of ${typeName} messages`,
            content: {
              'text/event-stream': {
                schema: topLevelSchema(typeName, null, components),
              },
            },
          },
          404: subscribeNotExposedResponse(),
        },
      },
    };
  }

  return {
    openapi: '3.1.0',
    info: { title, version: '0.0.0' },
    paths,
    components: { schemas: Object.fromEntries(components) },
  };
}

function notExposedResponse() {
  return {
    description: 'Capability not exposed',
    content: {
      'application/json': {
        schema: {
          type: 'object',
          properties: {
            ok: { type: 'boolean', const: false },
            error: { type: 'string' },
            code: { type: 'string', const: 'not_exposed' },
          },
        },
      },
    },
  };
}

/**
 * 404 for `subscribe`: unlike `call`/`publish`, it has two causes —
 * `unsupported_kind` when `sse` is off (the default), `not_exposed` when
 * `sse` is on but the capability isn't registered — so `code` lists both
 * instead of a single `const`.
 */
function subscribeNotExposedResponse() {
  return {
    description:
      'Capability not exposed, or subscribe over HTTP is disabled ' +
      '(`sse: false`, the default)',
    content: {
      'application/json': {
        schema: {
          type: 'object',
          properties: {
            ok: { type: 'boolean', const: false },
            error: { type: 'string' },
            code: {
              type: 'string',
              enum: ['not_exposed', 'unsupported_kind'],
            },
          },
        },
      },
    },
  };
}

/**
 * Normalise `basePath` like `HttpTransport` does (single leading slash, no
 * trailing slash). Duplicated, not imported, to keep this file free of any
 * `lib/runtime/` dependency.
 */
function _normaliseBasePath(value) {
  if (value === undefined || value === null || value === '') {
    return '/capability';
  }
  let p = String(value).replace(/\/+$/, '');
  if (!p.startsWith('/')) p = '/' + p;
  return p || '/capability';
}

function sanitizeName(name) {
  return name.replace(/^\//, '').replace(/[^a-zA-Z0-9_]/g, '_');
}

export {
  buildOpenApiDocument,
  rosFieldTypeToJsonSchema,
  messageSchemaToJsonSchema,
  primitiveToJsonSchema,
};