#!/usr/bin/env node

import { createHash, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { canonicalizeEntityId, loadCatalog } from './catalog.mjs'
import { resolveContext } from './resolver.mjs'

const MCP_PROTOCOL_VERSION = '2025-06-18'
const MAX_BODY_BYTES = 1024 * 1024
const MAX_SEARCH_RESULTS = 50

const TOOLS = [
  {
    name: 'ontology_status',
    title: 'Ontology catalog status',
    description: 'Returns a safe, read-only summary of the live NODE.DC ontology catalog and its loaded domain packages.',
    inputSchema: { type: 'object', additionalProperties: false, properties: {} },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
  {
    name: 'ontology_search',
    title: 'Search ontology catalog',
    description: 'Searches canonical entities, relations and aliases in the live ontology catalog. Use this before introducing names or data contracts into a workflow.',
    inputSchema: {
      type: 'object',
      additionalProperties: false,
      required: ['query'],
      properties: {
        query: { type: 'string', minLength: 1, maxLength: 200 },
        limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_RESULTS },
      },
    },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
  {
    name: 'ontology_get_entity',
    title: 'Get ontology entity',
    description: 'Resolves an entity id or alias and returns its safe definition, aliases, relations and applicable guardrails. It never returns runtime data, credentials or source filesystem paths.',
    inputSchema: {
      type: 'object',
      additionalProperties: false,
      properties: {
        entityId: { type: 'string', minLength: 1, maxLength: 240 },
        term: { type: 'string', minLength: 1, maxLength: 240 },
      },
      anyOf: [{ required: ['entityId'] }, { required: ['term'] }],
    },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
  {
    name: 'ontology_get_guardrails',
    title: 'Get ontology guardrails',
    description: 'Returns semantic and safety guardrails for the full catalog or for one entity/alias. This is read-only advice; it does not grant capability access.',
    inputSchema: {
      type: 'object',
      additionalProperties: false,
      properties: {
        entityId: { type: 'string', minLength: 1, maxLength: 240 },
        term: { type: 'string', minLength: 1, maxLength: 240 },
      },
    },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
  {
    name: 'ontology_resolve_context',
    title: 'Resolve ontology context',
    description: 'Resolves a semantic request to canonical entities and context routes. It deliberately returns no source-system ids, secrets, runtime data or execution capability.',
    inputSchema: {
      type: 'object',
      additionalProperties: false,
      properties: {
        surface: { type: 'string', maxLength: 120 },
        entityId: { type: 'string', maxLength: 240 },
        alias: { type: 'string', maxLength: 240 },
        term: { type: 'string', maxLength: 240 },
        intent: { type: 'string', maxLength: 1000 },
        contextId: { type: 'string', maxLength: 240 },
      },
    },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
]

export function createOntologyMcpServer(overrides = {}) {
  const config = { ...readConfig(), ...overrides }
  return createServer((req, res) => handleRequest(req, res, config).catch((error) => {
    if (!res.headersSent) {
      sendJson(res, 500, { ok: false, error: 'ontology_mcp_internal_error' })
    } else {
      res.destroy(error)
    }
  }))
}

async function handleRequest(req, res, config) {
  const url = new URL(req.url || '/', 'http://localhost')
  if (url.pathname === '/healthz') {
    const catalog = await loadCatalog()
    sendJson(res, 200, {
      ok: true,
      service: 'nodedc-ontology-core-mcp',
      mcp: {
        protocolVersion: MCP_PROTOCOL_VERSION,
        tools: TOOLS.length,
        readOnly: true,
      },
      catalog: catalogStatus(catalog),
      internalApiConfigured: config.internalTokens.length > 0,
    })
    return
  }

  if (url.pathname !== '/mcp') {
    sendJson(res, 404, { ok: false, error: 'not_found' })
    return
  }

  if (!isAllowedOrigin(req.headers.origin, config.allowedOrigins)) {
    sendJson(res, 403, { ok: false, error: 'mcp_origin_forbidden' })
    return
  }

  if (!isAuthorized(req.headers.authorization, config.internalTokens)) {
    sendJson(res, config.internalTokens.length ? 401 : 503, {
      ok: false,
      error: config.internalTokens.length ? 'ontology_mcp_unauthorized' : 'ontology_mcp_token_not_configured',
    })
    return
  }

  const method = String(req.method || 'GET').toUpperCase()
  if (method === 'GET') {
    res.setHeader('allow', 'POST')
    sendJson(res, 405, { ok: false, error: 'mcp_sse_not_supported' })
    return
  }
  if (method !== 'POST') {
    res.setHeader('allow', 'POST')
    sendJson(res, 405, { ok: false, error: 'method_not_allowed' })
    return
  }

  let payload
  try {
    payload = JSON.parse((await readRequestBody(req, config.maxBodyBytes)).toString('utf8'))
  } catch (error) {
    sendMcpError(res, null, -32700, error?.code === 'body_too_large' ? 'request_too_large' : 'parse_error')
    return
  }

  if (!isPlainObject(payload) || payload.jsonrpc !== '2.0' || !cleanString(payload.method, 160)) {
    sendMcpError(res, isPlainObject(payload) ? payload.id ?? null : null, -32600, 'invalid_request')
    return
  }

  const isNotification = !Object.hasOwn(payload, 'id')
  const response = await dispatchMcpRequest(payload)
  if (isNotification) {
    res.statusCode = 202
    res.end()
    return
  }
  sendJson(res, 200, response, { 'mcp-protocol-version': MCP_PROTOCOL_VERSION })
}

async function dispatchMcpRequest(payload) {
  const id = payload.id ?? null
  const method = cleanString(payload.method, 160)
  const params = isPlainObject(payload.params) ? payload.params : {}

  if (method === 'initialize') {
    return mcpResult(id, {
      protocolVersion: MCP_PROTOCOL_VERSION,
      capabilities: { tools: { listChanged: false } },
      serverInfo: {
        name: 'nodedc-ontology-core',
        version: '0.1.0',
      },
      instructions: 'Read-only semantic catalog. It exposes no runtime telemetry, database access, credentials, source paths, workflow mutations, command dispatch or Studio rendering controls.',
    })
  }

  if (method === 'notifications/initialized') return mcpResult(id, {})
  if (method === 'ping') return mcpResult(id, {})
  if (method === 'tools/list') return mcpResult(id, { tools: TOOLS })
  if (method !== 'tools/call') return mcpError(id, -32601, 'method_not_found')

  const toolName = cleanString(params.name, 120)
  const argumentsValue = isPlainObject(params.arguments) ? params.arguments : {}
  return mcpResult(id, await callTool(toolName, argumentsValue))
}

async function callTool(name, input) {
  if (name === 'ontology_status') {
    const catalog = await loadCatalog()
    return toolResult({
      schemaVersion: 'nodedc.ontology-mcp.status.v1',
      readOnly: true,
      catalog: catalogStatus(catalog),
    })
  }

  if (name === 'ontology_search') {
    const query = cleanString(input.query, 200)
    if (!query) return toolError('query_required')
    const catalog = await loadCatalog()
    return toolResult(searchCatalog(catalog, query, boundedLimit(input.limit)))
  }

  if (name === 'ontology_get_entity') {
    const catalog = await loadCatalog()
    const requested = cleanString(input.entityId || input.term, 240)
    const entityId = canonicalizeEntityId(requested, catalog)
    const entity = catalog.entityById.get(entityId)
    if (!entity) return toolError('entity_not_found', { requested })
    return toolResult(entityDetails(catalog, entity))
  }

  if (name === 'ontology_get_guardrails') {
    const catalog = await loadCatalog()
    const requested = cleanString(input.entityId || input.term, 240)
    const entityId = requested ? canonicalizeEntityId(requested, catalog) : ''
    if (requested && !catalog.entityById.has(entityId)) return toolError('entity_not_found', { requested })
    return toolResult(guardrailDetails(catalog, entityId))
  }

  if (name === 'ontology_resolve_context') {
    const resolved = await resolveContext({
      surface: cleanString(input.surface, 120),
      entityId: cleanString(input.entityId, 240),
      alias: cleanString(input.alias, 240),
      term: cleanString(input.term, 240),
      intent: cleanString(input.intent, 1000),
      contextId: cleanString(input.contextId, 240),
    })
    return toolResult(sanitizeResolvedContext(resolved))
  }

  return toolError('tool_not_found', { name })
}

function catalogStatus(catalog) {
  const packages = (catalog.domainPackages || [])
    .map((item) => ({
      id: cleanString(item.id, 120),
      version: cleanString(item.version, 80),
      updatedAt: cleanString(item.updatedAt, 80),
      status: cleanString(item.status, 120),
      summary: cleanString(item.summary, 1000),
    }))
    .sort((left, right) => left.id.localeCompare(right.id))
  const snapshot = {
    entityIds: (catalog.entities?.entities || []).map((item) => item.id).sort(),
    relationIds: (catalog.relations?.relations || []).map((item) => item.id).sort(),
    aliases: (catalog.aliases?.aliases || []).map((item) => [item.alias, item.canonicalId]).sort((left, right) => String(left[0]).localeCompare(String(right[0]))),
    packages,
  }
  return {
    schemaVersion: 'nodedc.ontology-catalog.status.v1',
    catalogHash: createHash('sha256').update(JSON.stringify(snapshot)).digest('hex').slice(0, 16),
    entities: snapshot.entityIds.length,
    relations: snapshot.relationIds.length,
    aliases: snapshot.aliases.length,
    guardrails: (catalog.guardrails?.rules || []).length,
    blockedConflations: (catalog.guardrails?.blockedConflations || []).length,
    domainPackages: packages,
  }
}

function searchCatalog(catalog, query, limit) {
  const needle = normalizeSearch(query)
  const items = []
  for (const entity of catalog.entities?.entities || []) {
    const aliasScore = (catalog.aliases?.aliases || [])
      .filter((alias) => alias.canonicalId === entity.id)
      .reduce((best, alias) => Math.max(best, searchScore(needle, alias.alias)), 0)
    const score = Math.max(
      aliasScore,
      searchScore(needle, entity.id, entity.name, entity.summary, entity.surface, entity.authority),
    )
    if (score > 0) items.push({ kind: 'entity', score, entity: safeEntity(entity) })
  }
  for (const relation of catalog.relations?.relations || []) {
    const score = searchScore(needle, relation.id, relation.summary, ...(relation.from || []), ...(relation.to || []))
    if (score > 0) items.push({ kind: 'relation', score, relation: safeRelation(relation) })
  }
  for (const alias of catalog.aliases?.aliases || []) {
    const score = searchScore(needle, alias.alias, alias.canonicalId)
    if (score > 0) items.push({ kind: 'alias', score, alias: safeAlias(alias) })
  }
  items.sort((left, right) => right.score - left.score || stableItemId(left).localeCompare(stableItemId(right)))
  return {
    schemaVersion: 'nodedc.ontology-mcp.search.v1',
    query,
    totalMatches: items.length,
    results: items.slice(0, limit).map(({ score, ...item }) => item),
  }
}

function entityDetails(catalog, entity) {
  const entityId = entity.id
  return {
    schemaVersion: 'nodedc.ontology-mcp.entity.v1',
    entity: safeEntity(entity),
    aliases: (catalog.aliases?.aliases || [])
      .filter((item) => item.canonicalId === entityId)
      .map(safeAlias)
      .sort((left, right) => left.alias.localeCompare(right.alias)),
    relations: (catalog.relations?.relations || [])
      .filter((item) => (item.from || []).includes(entityId) || (item.to || []).includes(entityId))
      .map(safeRelation)
      .sort((left, right) => left.id.localeCompare(right.id)),
    guardrails: (catalog.guardrails?.rules || [])
      .filter((item) => (item.entityIds || []).includes(entityId))
      .map(safeGuardrail)
      .sort((left, right) => left.id.localeCompare(right.id)),
    blockedConflations: (catalog.guardrails?.blockedConflations || [])
      .filter((pair) => Array.isArray(pair) && pair.includes(entityId))
      .map((pair) => pair.map((item) => cleanString(item, 240))),
  }
}

function guardrailDetails(catalog, entityId) {
  const rules = (catalog.guardrails?.rules || [])
    .filter((item) => !entityId || (item.entityIds || []).includes(entityId))
    .map(safeGuardrail)
    .sort((left, right) => left.id.localeCompare(right.id))
  const blockedConflations = (catalog.guardrails?.blockedConflations || [])
    .filter((pair) => !entityId || (Array.isArray(pair) && pair.includes(entityId)))
    .map((pair) => Array.isArray(pair) ? pair.map((item) => cleanString(item, 240)) : [])
  return {
    schemaVersion: 'nodedc.ontology-mcp.guardrails.v1',
    entityId: entityId || null,
    rules,
    blockedConflations,
  }
}

function sanitizeResolvedContext(resolved) {
  return {
    schemaVersion: 'nodedc.ontology-mcp.context-resolution.v1',
    input: {
      surface: cleanString(resolved?.input?.surface, 120),
      entityId: cleanString(resolved?.input?.entityId, 240),
      intent: cleanString(resolved?.input?.intent, 1000),
    },
    canonicalEntity: resolved?.canonicalEntity ? safeEntity(resolved.canonicalEntity) : null,
    matchedContexts: (resolved?.matchedContexts || []).map((item) => ({
      surface: cleanString(item.surface, 120),
      entityId: cleanString(item.entityId, 240),
      label: cleanString(item.label, 1000),
      sourceSystem: cleanString(item.sourceSystem, 160),
      status: cleanString(item.status, 120),
    })),
    bindings: (resolved?.bindings || []).map((item) => ({
      typeId: cleanString(item.typeId, 240),
      status: cleanString(item.status, 120),
      relationId: cleanString(item.relationId, 240),
      summary: cleanString(item.summary, 1000),
    })),
    missingBindingTypes: (resolved?.missingBindingTypes || []).map((item) => ({
      id: cleanString(item.id, 240),
      relationId: cleanString(item.relationId, 240),
      summary: cleanString(item.summary, 1000),
    })),
    selectedRule: resolved?.selectedRule ? {
      id: cleanString(resolved.selectedRule.id, 240),
      fromSurface: cleanString(resolved.selectedRule.fromSurface, 120),
      outputSurface: cleanString(resolved.selectedRule.outputSurface, 120),
      inputEntityIds: stringList(resolved.selectedRule.inputEntityIds),
      outputEntityIds: stringList(resolved.selectedRule.outputEntityIds),
      requiredBindings: stringList(resolved.selectedRule.requiredBindings),
      summary: cleanString(resolved.selectedRule.summary, 1000),
    } : null,
    candidates: (resolved?.candidates || []).map((item) => ({
      id: cleanString(item.id, 240),
      score: Number.isFinite(Number(item.score)) ? Number(item.score) : 0,
      outputSurface: cleanString(item.outputSurface, 120),
      outputEntityIds: stringList(item.outputEntityIds),
      requiredBindings: stringList(item.requiredBindings),
      summary: cleanString(item.summary, 1000),
    })),
  }
}

function safeEntity(value) {
  return {
    id: cleanString(value?.id, 240),
    name: cleanString(value?.name, 240),
    surface: cleanString(value?.surface, 120),
    status: stringList(value?.status),
    authority: cleanString(value?.authority, 240),
    summary: cleanString(value?.summary, 2000),
    ...(value?.valueContract ? { valueContract: safeValueContract(value.valueContract) } : {}),
  }
}

function safeValueContract(value) {
  return {
    field: cleanString(value?.field, 120),
    values: (Array.isArray(value?.values) ? value.values : []).map((item) => ({
      value: cleanString(item?.value, 120),
      label: cleanString(item?.label, 240),
      sourceLabel: cleanString(item?.sourceLabel, 240),
    })),
    sourcePaths: stringList(value?.sourcePaths),
    rules: stringList(value?.rules),
  }
}

function safeRelation(value) {
  return {
    id: cleanString(value?.id, 240),
    from: stringList(value?.from),
    to: stringList(value?.to),
    status: cleanString(value?.status, 120),
    summary: cleanString(value?.summary, 2000),
  }
}

function safeAlias(value) {
  return {
    alias: cleanString(value?.alias, 240),
    canonicalId: cleanString(value?.canonicalId, 240),
  }
}

function safeGuardrail(value) {
  return {
    id: cleanString(value?.id, 240),
    severity: cleanString(value?.severity, 40),
    summary: cleanString(value?.summary, 2000),
    entityIds: stringList(value?.entityIds),
  }
}

function toolResult(value) {
  return {
    content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
    structuredContent: value,
  }
}

function toolError(error, details = {}) {
  const value = { ok: false, error, ...details }
  return {
    content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
    structuredContent: value,
    isError: true,
  }
}

function mcpResult(id, result) {
  return { jsonrpc: '2.0', id, result }
}

function mcpError(id, code, message) {
  return { jsonrpc: '2.0', id, error: { code, message } }
}

function sendMcpError(res, id, code, message) {
  sendJson(res, 200, mcpError(id, code, message), { 'mcp-protocol-version': MCP_PROTOCOL_VERSION })
}

function sendJson(res, status, body, headers = {}) {
  res.statusCode = status
  res.setHeader('content-type', 'application/json; charset=utf-8')
  res.setHeader('cache-control', 'no-store')
  for (const [key, value] of Object.entries(headers)) res.setHeader(key, value)
  res.end(JSON.stringify(body))
}

async function readRequestBody(req, maxBytes) {
  const chunks = []
  let size = 0
  for await (const chunk of req) {
    size += chunk.length
    if (size > maxBytes) {
      const error = new Error('body_too_large')
      error.code = 'body_too_large'
      throw error
    }
    chunks.push(chunk)
  }
  return Buffer.concat(chunks)
}

function isAuthorized(header, candidates) {
  const match = String(header || '').match(/^Bearer\s+(.+)$/i)
  const token = match?.[1]?.trim() || ''
  if (!token) return false
  return (candidates || []).some((candidate) => safeEqual(token, candidate))
}

function safeEqual(left, right) {
  const leftBuffer = Buffer.from(String(left || ''))
  const rightBuffer = Buffer.from(String(right || ''))
  return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer)
}

function isAllowedOrigin(origin, allowedOrigins) {
  if (!origin) return true
  const normalized = normalizeOrigin(origin)
  return Boolean(normalized && allowedOrigins.includes(normalized))
}

function normalizeOrigin(value) {
  try {
    const url = new URL(String(value || ''))
    if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
    return url.origin
  } catch {
    return ''
  }
}

function readConfig() {
  const internalTokens = uniqueStrings([
    process.env.ONTOLOGY_CORE_TOKEN,
    process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
    process.env.NODEDC_PLATFORM_SERVICE_TOKEN,
  ])
  return {
    port: boundedNumber(process.env.PORT || process.env.ONTOLOGY_CORE_PORT, 18104, 1, 65535),
    internalTokens,
    allowedOrigins: uniqueStrings(String(process.env.ONTOLOGY_MCP_ALLOWED_ORIGINS || '')
      .split(/[\n,;]+/)
      .map(normalizeOrigin)
      .filter(Boolean)),
    maxBodyBytes: boundedNumber(process.env.ONTOLOGY_MCP_MAX_BODY_BYTES, MAX_BODY_BYTES, 1024, 10 * 1024 * 1024),
  }
}

function boundedLimit(value) {
  return boundedNumber(value, 20, 1, MAX_SEARCH_RESULTS)
}

function boundedNumber(value, fallback, min, max) {
  const numeric = Number(value)
  if (!Number.isFinite(numeric)) return fallback
  return Math.min(Math.max(Math.trunc(numeric), min), max)
}

function normalizeSearch(value) {
  return String(value || '').trim().toLocaleLowerCase('ru-RU')
}

function searchScore(needle, ...values) {
  let score = 0
  for (const value of values) {
    const haystack = normalizeSearch(value)
    if (!haystack) continue
    if (haystack === needle) score = Math.max(score, 100)
    else if (haystack.startsWith(needle)) score = Math.max(score, 60)
    else if (haystack.includes(needle)) score = Math.max(score, 30)
  }
  return score
}

function stableItemId(item) {
  return item.entity?.id || item.relation?.id || item.alias?.alias || ''
}

function stringList(value) {
  if (!Array.isArray(value)) return []
  return value.map((item) => cleanString(item, 240)).filter(Boolean)
}

function cleanString(value, maxLength) {
  return String(value || '').trim().slice(0, maxLength)
}

function uniqueStrings(values) {
  return [...new Set((values || []).map((item) => cleanString(item, 4000)).filter(Boolean))]
}

function isPlainObject(value) {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}

const currentFile = fileURLToPath(import.meta.url)
const isEntrypoint = process.argv[1] && path.resolve(process.argv[1]) === currentFile

if (isEntrypoint) {
  const config = readConfig()
  const server = createOntologyMcpServer(config)
  server.listen(config.port, '0.0.0.0', () => {
    console.log(`NODE.DC Ontology Core MCP listening on http://0.0.0.0:${config.port}`)
  })
  const shutdown = () => server.close(() => process.exit(0))
  process.on('SIGTERM', shutdown)
  process.on('SIGINT', shutdown)
}
