/**
 * `HttpErrors.BadRequestError` (HTTP 400) is reserved for genuine input
 * validation. Throwing it where the upstream caller's input was already
 * accepted — i.e. after the provider rejected the request — mislabels the
 * failure. Per repo policy: prefer `HttpErrors.UnprocessableEntityError`
 * (422); the frontend absorbs 400s and would mask the real cause.
 *
 * Fires only on high-confidence misuse:
 *   - Inside a `catch` whose `try.block` provably contains an `await` (async
 *     I/O — provider, service helper, db query, fetch). Catches wrapping pure
 *     sync operations (JSON.parse, schema.validate, parseInt) are NOT flagged
 *     — those are legitimate input-validation throw points.
 *   - Within 3 statements after an `await provider.*` / `await Provider.*` in
 *     any enclosing block (function-boundary stop).
 *
 * Allowlisted message substrings (`/^Missing/i`, `/^Invalid/i`, `/required/i`,
 * `/must be/i`) skip the rule — those phrases describe input validation,
 * `BadRequestError` is correct there.
 */
import type { Rule } from 'eslint';
import type * as ESTree from 'estree';
import { isHttpErrorsCallee } from '../utils.js';

const SKIP_FILE_PATTERNS = [/\/provider\.ts$/, /\/credentials\.ts$/, /\/helpers\/credentials\.ts$/];

const INPUT_VALIDATION_MESSAGE_PATTERNS = [/^Missing/i, /^Invalid/i, /required/i, /must be/i];

const PROVIDER_AWAIT_LOOKBACK = 3;
const BAD_REQUEST_ERROR = 'BadRequestError';
const PROVIDER_IDENTIFIER_RE = /^provider$/i;

const FUNCTION_BOUNDARY_TYPES = new Set<string>([
  'FunctionDeclaration',
  'FunctionExpression',
  'ArrowFunctionExpression',
]);

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

function isFileSkipped(filename: string): boolean {
  return SKIP_FILE_PATTERNS.some(re => re.test(filename));
}

function getLiteralMessageText(
  arg: ESTree.Node | null | undefined,
  sourceCode: Rule.RuleContext['sourceCode'],
): string | null {
  if (!arg) {
    return null;
  }
  if (arg.type === 'Literal' && typeof arg.value === 'string') {
    return arg.value;
  }
  if (arg.type === 'TemplateLiteral') {
    const raw = sourceCode.getText(arg);
    return raw.replace(/^`|`$/g, '').replace(/\$\{[^}]*\}/g, '');
  }
  return null;
}

function isInputValidationMessage(
  arg: ESTree.Node | null | undefined,
  sourceCode: Rule.RuleContext['sourceCode'],
): boolean {
  const text = getLiteralMessageText(arg, sourceCode);
  if (text === null) {
    return false;
  }
  return INPUT_VALIDATION_MESSAGE_PATTERNS.some(re => re.test(text));
}

function isAwaitOnProvider(awaitExpression: ESTree.Node | null | undefined): boolean {
  if (!awaitExpression || awaitExpression.type !== 'AwaitExpression') {
    return false;
  }
  const inner = awaitExpression.argument;
  if (!inner || inner.type !== 'CallExpression') {
    return false;
  }
  let callee: ESTree.Node | null | undefined = inner.callee;
  while (callee && callee.type === 'MemberExpression') {
    callee = callee.object;
  }
  if (!callee || callee.type !== 'Identifier') {
    return false;
  }
  return PROVIDER_IDENTIFIER_RE.test(callee.name);
}

function findProviderAwait(statement: ESTree.Node | undefined): ESTree.Node | null {
  if (!statement) {
    return null;
  }
  if (statement.type === 'ExpressionStatement') {
    return isAwaitOnProvider(statement.expression) ? statement.expression : null;
  }
  if (statement.type === 'VariableDeclaration') {
    for (const decl of statement.declarations) {
      if (decl.init && isAwaitOnProvider(decl.init)) {
        return decl.init;
      }
    }
    return null;
  }
  if (statement.type === 'ReturnStatement' && statement.argument) {
    return isAwaitOnProvider(statement.argument) ? statement.argument : null;
  }
  return null;
}

// Walks parents up to a function boundary. At each block-level ancestor, scans
// up to PROVIDER_AWAIT_LOOKBACK preceding siblings for a provider await. Lets
// the rule fire on `await provider.x(); if (y) { throw ... }` — the throw's
// direct parent has no awaits but the enclosing block does.
function isWithinProviderAwaitWindow(throwStatement: Rule.Node): boolean {
  let current: NodeWithParent = throwStatement as unknown as NodeWithParent;
  let parent: NodeWithParent | undefined = current.parent;

  while (parent) {
    if (FUNCTION_BOUNDARY_TYPES.has(parent.type)) {
      return false;
    }
    const parentBody = (parent as { body?: unknown }).body;
    if (Array.isArray(parentBody)) {
      const idx = parentBody.indexOf(current);
      if (idx > 0) {
        const start = Math.max(0, idx - PROVIDER_AWAIT_LOOKBACK);
        for (let i = idx - 1; i >= start; i--) {
          if (findProviderAwait(parentBody[i] as ESTree.Node)) {
            return true;
          }
        }
      }
    }
    current = parent;
    parent = parent.parent;
  }
  return false;
}

// Walk subtree looking for ANY AwaitExpression. Skips nested function bodies —
// an async function defined inside the try doesn't count as awaiting at the
// try-block scope.
function subtreeHasAwait(rootNode: ESTree.Node): boolean {
  let found = false;
  const walk = (node: unknown): void => {
    if (found || !node || typeof node !== 'object') {
      return;
    }
    const n = node as ESTree.Node;
    if (n.type === 'AwaitExpression') {
      found = true;
      return;
    }
    if (FUNCTION_BOUNDARY_TYPES.has(n.type)) {
      return;
    }
    for (const key in n) {
      if (key === 'parent' || key === 'loc' || key === 'range') {
        continue;
      }
      const child = (n as unknown as Record<string, unknown>)[key];
      if (Array.isArray(child)) {
        for (const c of child) {
          walk(c);
        }
      } else if (child && typeof child === 'object' && 'type' in (child as object)) {
        walk(child);
      }
    }
  };
  walk(rootNode);
  return found;
}

// Returns true when `node` is inside a CatchClause whose corresponding
// TryStatement.block contains any `await`. Catches wrapping pure sync
// operations have no await and are skipped — those are legitimate
// input-validation throw points.
function isInsideAsyncCatch(node: Rule.Node): boolean {
  let parent: NodeWithParent | undefined = (node as unknown as NodeWithParent).parent;
  while (parent) {
    if (parent.type === 'CatchClause') {
      const tryStatement = parent.parent;
      if (tryStatement && tryStatement.type === 'TryStatement') {
        const ts = tryStatement as unknown as ESTree.TryStatement;
        if (ts.block) {
          return subtreeHasAwait(ts.block);
        }
      }
      return false;
    }
    if (FUNCTION_BOUNDARY_TYPES.has(parent.type)) {
      return false;
    }
    parent = parent.parent;
  }
  return false;
}

const rule: Rule.RuleModule = {
  meta: {
    type: 'problem',
    docs: {
      description:
        'Reserve `HttpErrors.BadRequestError` (HTTP 400) for input validation. Throwing it inside a `catch` or after a `await provider.*` call mislabels a provider rejection as a caller-input error — use `HttpErrors.UnprocessableEntityError` (422) instead.',
      recommended: true,
    },
    schema: [],
    messages: {
      badRequestNotInputValidation:
        '`HttpErrors.BadRequestError` (HTTP 400) is reserved for input validation. Throwing it {{context}} treats a provider rejection as a caller-input error — the upstream caller did not send bad input. Throw `HttpErrors.UnprocessableEntityError` (422) instead, or, if the message truly describes a missing/invalid input field, rephrase it (e.g. `Missing X`, `Invalid Y`, `X is required`, `X must be ...`).',
    },
  },

  create(context) {
    const filename = context.filename ?? '';
    if (isFileSkipped(filename)) {
      return {};
    }

    const sourceCode = context.sourceCode;

    return {
      ThrowStatement(node) {
        const argument = node.argument;
        if (!argument || argument.type !== 'NewExpression') {
          return;
        }
        if (!isHttpErrorsCallee(argument.callee, BAD_REQUEST_ERROR)) {
          return;
        }

        const firstArg = argument.arguments[0] as ESTree.Node | undefined;
        if (isInputValidationMessage(firstArg, sourceCode)) {
          return;
        }

        if (isInsideAsyncCatch(node)) {
          context.report({
            node,
            messageId: 'badRequestNotInputValidation',
            data: { context: 'inside a `catch` that wraps an `await` (async I/O)' },
          });
          return;
        }

        if (isWithinProviderAwaitWindow(node)) {
          context.report({
            node,
            messageId: 'badRequestNotInputValidation',
            data: { context: 'after a `provider.*` API call' },
          });
        }
      },
    };
  },
};

export default rule;
