/**
 * Webhook payloads are provider-controlled untrusted strings. An unhandled
 * `JSON.parse` throw crashes the handler, returns 500. The provider then
 * retries delivery per its own (bounded) policy and eventually drops the
 * event — webhook data is silently lost. Loss is undetectable: no error
 * metric fires, the provider just stops sending.
 *
 * Wrap every `JSON.parse(...)` in a `try` block (or call it from inside a
 * `catch` handler — already in error-handling territory).
 *
 * Detection covers two scopes:
 *   1. Files matching /webhook.*\.ts$/i (filename signal)
 *   2. Functions typed as Parse/AcknowledgeWebhook(s)Handler from the
 *      integration-sdk, regardless of filename (AST signal — catches
 *      handlers in `parseEvents.ts`, `eventDispatcher.ts`, etc.)
 */
import type { Rule } from 'eslint';
import type * as ESTree from 'estree';

const WEBHOOK_FILE_REGEX = /webhook.*\.ts$/i;

const WEBHOOK_HANDLER_TYPES = new Set<string>([
  'ParseWebhookHandler',
  'ParseWebhooksHandler',
  'AcknowledgeWebhookHandler',
  'AcknowledgeWebhooksHandler',
]);

type NodeWithParent = ESTree.Node & { parent?: NodeWithParent };

function isJsonParseCall(node: ESTree.Node): boolean {
  if (node.type !== 'CallExpression') {
    return false;
  }
  const callee = node.callee;
  if (!callee || callee.type !== 'MemberExpression') {
    return false;
  }
  if (!callee.object || callee.object.type !== 'Identifier' || callee.object.name !== 'JSON') {
    return false;
  }
  if (callee.property.type === 'Identifier' && callee.property.name === 'parse') {
    return true;
  }
  if (callee.property.type === 'Literal' && callee.property.value === 'parse') {
    return true;
  }
  return false;
}

// Function boundaries are NOT a hard stop: an arrow callback synchronously
// invoked inside `try { arr.map((r) => JSON.parse(r)) }` is genuinely covered.
// A function that itself contains no try will walk to Program and return false.
function isCoveredByTryOrCatch(node: Rule.Node): boolean {
  let current: NodeWithParent = node as unknown as NodeWithParent;
  let parent: NodeWithParent | undefined = current.parent;
  while (parent) {
    if (parent.type === 'CatchClause') {
      return true;
    }
    if (parent.type === 'TryStatement') {
      const tryStmt = parent as unknown as ESTree.TryStatement;
      if (tryStmt.block === (current as unknown as ESTree.Node)) {
        return true;
      }
    }
    current = parent;
    parent = parent.parent;
  }
  return false;
}

// Detect arrow/function expression assigned to a variable typed as one of
// the webhook handler types from integration-sdk:
//   const parseWebhooks: ParseWebhooksHandler = async (ctx) => { ... }
function isWebhookHandlerFunction(fnNode: NodeWithParent): boolean {
  const parent = fnNode.parent;
  if (parent?.type !== 'VariableDeclarator') {
    return false;
  }
  const declarator = parent as unknown as ESTree.VariableDeclarator & {
    id: ESTree.Identifier & {
      typeAnnotation?: {
        typeAnnotation: {
          type: string;
          typeName?: { type: string; name: string };
        };
      };
    };
  };
  const id = declarator.id;
  if (id.type !== 'Identifier' || !id.typeAnnotation) {
    return false;
  }
  const ta = id.typeAnnotation.typeAnnotation;
  if (ta.type !== 'TSTypeReference' || !ta.typeName || ta.typeName.type !== 'Identifier') {
    return false;
  }
  return WEBHOOK_HANDLER_TYPES.has(ta.typeName.name);
}

function isInsideWebhookHandler(node: Rule.Node): boolean {
  let p: NodeWithParent | undefined = (node as unknown as NodeWithParent).parent;
  while (p) {
    if (p.type === 'ArrowFunctionExpression' || p.type === 'FunctionExpression') {
      if (isWebhookHandlerFunction(p)) {
        return true;
      }
    }
    p = p.parent;
  }
  return false;
}

const rule: Rule.RuleModule = {
  meta: {
    type: 'problem',
    docs: {
      description:
        'In webhook files, JSON.parse must be wrapped in a try block — webhook payloads are untrusted and JSON.parse throws crash the handler.',
      recommended: true,
    },
    schema: [],
    messages: {
      missingTry:
        'JSON.parse on a webhook payload must be wrapped in a try block. Webhook bodies are provider-controlled untrusted strings; an unhandled throw returns 500, the provider retries per its bounded policy, and the event is eventually dropped — silent data loss. Wrap the call in `try { ... } catch (e) { logger.warn(...); ... }`.',
    },
  },

  create(context) {
    const filename = context.filename ?? '';
    const fileMatches = WEBHOOK_FILE_REGEX.test(filename);

    return {
      CallExpression(node) {
        if (!isJsonParseCall(node)) {
          return;
        }
        if (!fileMatches && !isInsideWebhookHandler(node)) {
          return;
        }
        if (isCoveredByTryOrCatch(node)) {
          return;
        }
        context.report({
          node,
          messageId: 'missingTry',
        });
      },
    };
  },
};

export default rule;
