/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import { NAMESPACE_SCHEMAS } from '@ifc-lite/sandbox/schema';
import {
  createPreflightDiagnostic,
  formatDiagnosticsForDisplay,
  type PreflightScriptDiagnostic,
} from './script-diagnostics.js';

interface MethodRule {
  required: string[];
  anyOf?: string[][];
  positiveKeys?: string[];
  pointArity?: Record<string, number>;
  axisPair?: [string, string];
  forbidKeys?: Array<{ key: string; message: string }>;
  custom?: (body: string) => string[];
}

function createMethodRulesFromSchema(): Record<string, MethodRule> {
  const createNamespace = NAMESPACE_SCHEMAS.find((schema) => schema.name === 'create');
  if (!createNamespace) return {};

  const rules: Record<string, MethodRule> = {};
  for (const method of createNamespace.methods) {
    const semantics = method.llmSemantics;
    if (!semantics?.requiredKeys?.length && !semantics?.anyOfKeys?.length && !semantics?.forbiddenKeys?.length) {
      continue;
    }

    let custom: ((body: string) => string[]) | undefined;
    switch (semantics.customValidationId) {
      case 'slab-shape':
        custom = validateSlabShape;
        break;
      case 'roof-shape':
        if (method.name === 'addIfcRoof' || method.name === 'addIfcGableRoof') {
          const roofMethod = method.name;
          custom = (body) => validateRoofShape(roofMethod, body);
        }
        break;
      case 'generic-element':
        custom = validateGenericElementShape;
        break;
      case 'axis-element':
        custom = validateAxisElementShape;
        break;
      default:
        custom = undefined;
    }

    rules[method.name] = {
      required: semantics.requiredKeys ?? [],
      anyOf: semantics.anyOfKeys,
      positiveKeys: semantics.positiveKeys,
      pointArity: semantics.pointArity,
      axisPair: semantics.axisPair,
      forbidKeys: semantics.forbiddenKeys,
      custom,
    };
  }
  return rules;
}

const METHOD_RULES: Record<string, MethodRule> = createMethodRulesFromSchema();

const SUSPICIOUS_BARE_IDENTIFIERS = new Set([
  'Position', 'Placement', 'Start', 'End', 'Width', 'Depth', 'Height', 'Thickness', 'Elevation', 'IfcType',
]);

function nearestMethodName(method: string, options: string[]): string | null {
  const lower = method.toLowerCase();
  const hit = options.find((m) => m.toLowerCase() === lower);
  if (hit) return hit;
  const close = options.find((m) => m.toLowerCase().includes(lower) || lower.includes(m.toLowerCase()));
  return close ?? null;
}

function scanToMatching(source: string, start: number, open: string, close: string): number {
  let depth = 0;
  let quote: '"' | '\'' | '`' | null = null;
  for (let i = start; i < source.length; i++) {
    const ch = source[i];
    if (quote) {
      if (ch === '\\') {
        i++;
      } else if (ch === quote) {
        quote = null;
      }
      continue;
    }
    if (ch === '"' || ch === '\'' || ch === '`') {
      quote = ch;
      continue;
    }
    if (ch === open) depth++;
    else if (ch === close) {
      depth--;
      if (depth === 0) return i;
    }
  }
  return -1;
}

function splitTopLevelItems(text: string): string[] {
  const items: string[] = [];
  let depthParen = 0;
  let depthBracket = 0;
  let depthBrace = 0;
  let quote: '"' | '\'' | '`' | null = null;
  let current = '';
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (quote) {
      current += ch;
      if (ch === '\\') {
        current += text[i + 1] ?? '';
        i++;
      } else if (ch === quote) {
        quote = null;
      }
      continue;
    }
    if (ch === '"' || ch === '\'' || ch === '`') {
      quote = ch;
      current += ch;
      continue;
    }
    if (ch === '(') depthParen++;
    else if (ch === ')') depthParen--;
    else if (ch === '[') depthBracket++;
    else if (ch === ']') depthBracket--;
    else if (ch === '{') depthBrace++;
    else if (ch === '}') depthBrace--;

    if (ch === ',' && depthParen === 0 && depthBracket === 0 && depthBrace === 0) {
      if (current.trim()) items.push(current.trim());
      current = '';
      continue;
    }
    current += ch;
  }
  if (current.trim()) items.push(current.trim());
  return items;
}

function hasKey(body: string, key: string): boolean {
  return new RegExp(String.raw`\b${key}\s*:`, 'm').test(body);
}

function findPropertyValue(body: string, key: string): string | null {
  const match = new RegExp(String.raw`\b${key}\s*:\s*`, 'm').exec(body);
  if (!match || match.index === undefined) return null;
  const start = match.index + match[0].length;
  const trimmed = body.slice(start).trimStart();
  if (!trimmed) return null;
  const offset = body.slice(start).length - trimmed.length;
  const absolute = start + offset;
  const first = body[absolute];
  if (first === '[') {
    const end = scanToMatching(body, absolute, '[', ']');
    return end >= 0 ? body.slice(absolute, end + 1) : null;
  }
  if (first === '{') {
    const end = scanToMatching(body, absolute, '{', '}');
    return end >= 0 ? body.slice(absolute, end + 1) : null;
  }
  const rest = body.slice(absolute);
  const top = splitTopLevelItems(rest);
  return top[0] ?? null;
}

function getArrayLiteralArity(body: string, key: string): number | null {
  const value = findPropertyValue(body, key);
  if (!value || !value.startsWith('[')) return null;
  return splitTopLevelItems(value.slice(1, -1)).length;
}

function getArrayLiteralItems(body: string, key: string): string[] | null {
  const value = findPropertyValue(body, key);
  if (!value || !value.startsWith('[')) return null;
  return splitTopLevelItems(value.slice(1, -1));
}

function parseNumericPointLiteral(body: string, key: string): number[] | null {
  const value = findPropertyValue(body, key);
  if (!value || !value.startsWith('[')) return null;
  const parts = splitTopLevelItems(value.slice(1, -1));
  if (parts.length === 0) return null;
  const nums = parts.map((part) => Number(part.trim()));
  if (nums.some((n) => Number.isNaN(n))) return null;
  return nums;
}

function getObjectBodiesForMethod(code: string, methodName: string): string[] {
  const marker = `bim.create.${methodName}(`;
  const bodies: string[] = [];
  let start = 0;
  while (true) {
    const idx = code.indexOf(marker, start);
    if (idx < 0) break;
    const openParen = idx + marker.length - 1;
    const closeParen = scanToMatching(code, openParen, '(', ')');
    if (closeParen < 0) break;
    const callBody = code.slice(openParen + 1, closeParen);
    let lastObject: string | null = null;
    for (let i = 0; i < callBody.length; i++) {
      if (callBody[i] !== '{') continue;
      const closeBrace = scanToMatching(callBody, i, '{', '}');
      if (closeBrace < 0) break;
      lastObject = callBody.slice(i + 1, closeBrace);
      i = closeBrace;
    }
    if (lastObject) bodies.push(lastObject);
    start = closeParen + 1;
  }
  return bodies;
}

interface MethodCallMatch {
  methodName: string;
  range: { from: number; to: number };
  snippet: string;
  line: number;
  column: number;
  unterminated: boolean;
}

function getMethodCalls(code: string, methodName: string): MethodCallMatch[] {
  const marker = `bim.create.${methodName}(`;
  const matches: MethodCallMatch[] = [];
  let start = 0;

  while (true) {
    const idx = code.indexOf(marker, start);
    if (idx < 0) break;
    const openParen = idx + marker.length - 1;
    const closeParen = scanToMatching(code, openParen, '(', ')');
    const unterminated = closeParen < 0;
    const end = unterminated ? findFallbackCallEnd(code, idx + marker.length) : closeParen + 1;
    const { line, column } = getLineAndColumn(code, idx);

    matches.push({
      methodName,
      range: { from: idx, to: end },
      snippet: code.slice(idx, end).trimEnd(),
      line,
      column,
      unterminated,
    });

    start = Math.max(end, idx + marker.length);
  }

  return matches;
}

function findFallbackCallEnd(code: string, start: number): number {
  const candidates = [
    code.indexOf('\n//', start),
    code.indexOf('\nconst ', start),
    code.indexOf('\nlet ', start),
    code.indexOf('\nvar ', start),
    code.indexOf('\nbim.create.', start),
    code.indexOf('\n\n', start),
  ].filter((value) => value >= 0);

  return candidates.length > 0 ? Math.min(...candidates) : code.length;
}

function getLineAndColumn(code: string, offset: number): { line: number; column: number } {
  let line = 1;
  let lastLineStart = 0;
  for (let i = 0; i < offset; i++) {
    if (code[i] === '\n') {
      line++;
      lastLineStart = i + 1;
    }
  }
  return { line, column: offset - lastLineStart + 1 };
}

function getLineSnippet(code: string, offset: number): string {
  const lineStart = code.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
  const nextBreak = code.indexOf('\n', offset);
  const lineEnd = nextBreak >= 0 ? nextBreak : code.length;
  return code.slice(lineStart, lineEnd).trimEnd();
}

function isIdentifierStart(char: string): boolean {
  return /[A-Za-z_$]/.test(char);
}

function isIdentifierPart(char: string): boolean {
  return /[A-Za-z0-9_$]/.test(char);
}

function isIdentifierBoundary(code: string, index: number): boolean {
  if (index < 0 || index >= code.length) return true;
  return !isIdentifierPart(code[index]);
}

function skipStringLiteral(code: string, start: number): number {
  const quote = code[start];
  let index = start + 1;
  while (index < code.length) {
    const char = code[index];
    if (char === '\\') {
      index += 2;
      continue;
    }
    if (quote === '`' && char === '$' && code[index + 1] === '{') {
      const expressionEnd = scanToMatching(code, index + 1, '{', '}');
      if (expressionEnd < 0) return code.length;
      index = expressionEnd + 1;
      continue;
    }
    if (char === quote) return index + 1;
    index++;
  }
  return code.length;
}

function skipTrivia(code: string, start: number): number {
  let index = start;
  while (index < code.length) {
    const char = code[index];
    const next = code[index + 1];
    if (/\s/.test(char)) {
      index++;
      continue;
    }
    if (char === '/' && next === '/') {
      index += 2;
      while (index < code.length && code[index] !== '\n') index++;
      continue;
    }
    if (char === '/' && next === '*') {
      index += 2;
      while (index < code.length && !(code[index] === '*' && code[index + 1] === '/')) index++;
      index = Math.min(code.length, index + 2);
      continue;
    }
    break;
  }
  return index;
}

function readIdentifier(code: string, start: number): { value: string; end: number } | null {
  const index = skipTrivia(code, start);
  const char = code[index];
  if (!char || !isIdentifierStart(char)) return null;
  let end = index + 1;
  while (end < code.length && isIdentifierPart(code[end])) end++;
  return { value: code.slice(index, end), end };
}

function scanExpressionUntil(code: string, start: number, terminators: Set<string>): number {
  let index = start;
  let parenDepth = 0;
  let bracketDepth = 0;
  let braceDepth = 0;

  while (index < code.length) {
    index = skipTrivia(code, index);
    if (index >= code.length) break;
    const char = code[index];

    if (char === '"' || char === '\'' || char === '`') {
      index = skipStringLiteral(code, index);
      continue;
    }

    if (char === '(') parenDepth++;
    else if (char === ')') {
      if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) return index;
      parenDepth = Math.max(0, parenDepth - 1);
    } else if (char === '[') bracketDepth++;
    else if (char === ']') {
      if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) return index;
      bracketDepth = Math.max(0, bracketDepth - 1);
    } else if (char === '{') braceDepth++;
    else if (char === '}') {
      if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) return index;
      braceDepth = Math.max(0, braceDepth - 1);
    } else if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) {
      return index;
    }

    index++;
  }

  return index;
}

function collectBindingsFromPattern(pattern: string, target: Set<string>): void {
  const code = pattern.trim();
  if (!code) return;

  function parsePattern(start: number, terminators: Set<string>): number {
    let index = skipTrivia(code, start);
    if (index >= code.length) return index;

    if (code.startsWith('...', index)) {
      return parsePattern(index + 3, terminators);
    }

    const char = code[index];
    if (char === '[') {
      index++;
      while (index < code.length) {
        index = skipTrivia(code, index);
        if (index >= code.length || code[index] === ']') return index + 1;
        if (code[index] === ',') {
          index++;
          continue;
        }
        index = parsePattern(index, new Set([',', ']']));
        index = skipTrivia(code, index);
        if (code[index] === '=') {
          index = scanExpressionUntil(code, index + 1, new Set([',', ']']));
        }
        index = skipTrivia(code, index);
        if (code[index] === ',') index++;
      }
      return index;
    }

    if (char === '{') {
      index++;
      while (index < code.length) {
        index = skipTrivia(code, index);
        if (index >= code.length || code[index] === '}') return index + 1;
        if (code[index] === ',') {
          index++;
          continue;
        }
        if (code.startsWith('...', index)) {
          index = parsePattern(index + 3, new Set([',', '}']));
        } else {
          if (code[index] === '[') {
            index = scanToMatching(code, index, '[', ']');
            index = index < 0 ? code.length : index + 1;
          } else if (code[index] === '"' || code[index] === '\'' || code[index] === '`') {
            index = skipStringLiteral(code, index);
          } else {
            const key = readIdentifier(code, index);
            if (key) {
              index = key.end;
              const afterKey = skipTrivia(code, index);
              if (code[afterKey] === ':') {
                index = parsePattern(afterKey + 1, new Set([',', '}', '=']));
              } else {
                target.add(key.value);
                index = afterKey;
              }
            } else {
              index++;
            }
          }
        }

        index = skipTrivia(code, index);
        if (code[index] === '=') {
          index = scanExpressionUntil(code, index + 1, new Set([',', '}']));
        }
        index = skipTrivia(code, index);
        if (code[index] === ',') index++;
      }
      return index;
    }

    const identifier = readIdentifier(code, index);
    if (identifier) {
      target.add(identifier.value);
      return identifier.end;
    }

    return scanExpressionUntil(code, index, terminators);
  }

  parsePattern(0, new Set());
}

function collectDeclaredIdentifiers(code: string): Set<string> {
  const declared = new Set<string>();

  const collectCommaSeparatedPatterns = (body: string) => {
    splitTopLevelItems(body).forEach((item) => {
      const trimmed = item.trim();
      if (!trimmed) return;
      const equalsIndex = scanExpressionUntil(trimmed, 0, new Set(['=']));
      const pattern = equalsIndex < trimmed.length ? trimmed.slice(0, equalsIndex) : trimmed;
      collectBindingsFromPattern(pattern, declared);
    });
  };

  const collectFunctionLikeBindings = (expression: string) => {
    const trimmed = expression.trim();
    if (!trimmed) return;

    if (trimmed.startsWith('function') && isIdentifierBoundary(trimmed, 'function'.length)) {
      let cursor = skipTrivia(trimmed, 'function'.length);
      const name = readIdentifier(trimmed, cursor);
      if (name) {
        cursor = name.end;
      }
      cursor = skipTrivia(trimmed, cursor);
      if (trimmed[cursor] === '(') {
        const close = scanToMatching(trimmed, cursor, '(', ')');
        if (close >= 0) {
          collectCommaSeparatedPatterns(trimmed.slice(cursor + 1, close));
        }
      }
      return;
    }

    if (trimmed[0] === '(') {
      const close = scanToMatching(trimmed, 0, '(', ')');
      const afterClose = close >= 0 ? skipTrivia(trimmed, close + 1) : 0;
      if (close >= 0 && trimmed.startsWith('=>', afterClose)) {
        collectCommaSeparatedPatterns(trimmed.slice(1, close));
      }
      return;
    }

    const param = readIdentifier(trimmed, 0);
    const afterParam = param ? skipTrivia(trimmed, param.end) : 0;
    if (param && trimmed.startsWith('=>', afterParam)) {
      declared.add(param.value);
    }
  };

  const collectVariableDeclaration = (start: number): number => {
    let index = skipTrivia(code, start);

    while (index < code.length) {
      const declaratorEnd = scanExpressionUntil(code, index, new Set([',', ';', ')']));
      const declarator = code.slice(index, declaratorEnd);
      const ofOrInMatch = declarator.match(/^(.*?)(?=\s+\b(?:of|in)\b)/s);
      const bindingSegment = ofOrInMatch ? ofOrInMatch[1] : declarator;
      const equalsIndex = scanExpressionUntil(bindingSegment, 0, new Set(['=']));
      const pattern = equalsIndex < bindingSegment.length ? bindingSegment.slice(0, equalsIndex) : bindingSegment;
      collectBindingsFromPattern(pattern, declared);
      if (equalsIndex < bindingSegment.length) {
        collectFunctionLikeBindings(bindingSegment.slice(equalsIndex + 1));
      }

      index = skipTrivia(code, declaratorEnd);
      if (code[index] !== ',') return declaratorEnd;
      index++;
      index = skipTrivia(code, index);
    }

    return index;
  };

  for (let index = 0; index < code.length;) {
    index = skipTrivia(code, index);
    if (index >= code.length) break;

    const char = code[index];
    if (char === '"' || char === '\'' || char === '`') {
      index = skipStringLiteral(code, index);
      continue;
    }

    const variableKeyword = ['const', 'let', 'var'].find((keyword) =>
      code.startsWith(keyword, index)
      && isIdentifierBoundary(code, index - 1)
      && isIdentifierBoundary(code, index + keyword.length),
    );
    if (variableKeyword) {
      index = collectVariableDeclaration(index + variableKeyword.length);
      continue;
    }

    if (code.startsWith('function', index)
      && isIdentifierBoundary(code, index - 1)
      && isIdentifierBoundary(code, index + 'function'.length)) {
      let cursor = skipTrivia(code, index + 'function'.length);
      const name = readIdentifier(code, cursor);
      if (name) {
        declared.add(name.value);
        cursor = name.end;
      }
      cursor = skipTrivia(code, cursor);
      if (code[cursor] === '(') {
        const close = scanToMatching(code, cursor, '(', ')');
        if (close >= 0) {
          collectCommaSeparatedPatterns(code.slice(cursor + 1, close));
          index = close + 1;
          continue;
        }
      }
    }

    if (code.startsWith('catch', index)
      && isIdentifierBoundary(code, index - 1)
      && isIdentifierBoundary(code, index + 'catch'.length)) {
      let cursor = skipTrivia(code, index + 'catch'.length);
      if (code[cursor] === '(') {
        const close = scanToMatching(code, cursor, '(', ')');
        if (close >= 0) {
          collectBindingsFromPattern(code.slice(cursor + 1, close), declared);
          index = close + 1;
          continue;
        }
      }
    }

    index++;
  }

  return declared;
}

function validateKnownBimMethods(code: string): string[] {
  const errors: string[] = [];
  const byNamespace = new Map<string, Set<string>>();
  for (const schema of NAMESPACE_SCHEMAS) {
    byNamespace.set(schema.name, new Set(schema.methods.map((m) => m.name)));
  }

  const regex = /\bbim\.(\w+)\.(\w+)\s*\(/g;
  let match: RegExpExecArray | null;
  while ((match = regex.exec(code)) !== null) {
    const namespace = match[1];
    const method = match[2];
    const knownMethods = byNamespace.get(namespace);
    if (!knownMethods) {
      errors.push(`Unknown namespace \`bim.${namespace}\`.`);
      continue;
    }
    if (!knownMethods.has(method)) {
      const suggestion = nearestMethodName(method, Array.from(knownMethods));
      errors.push(
        suggestion
          ? `Unknown method \`bim.${namespace}.${method}()\`. Did you mean \`bim.${namespace}.${suggestion}()\`?`
          : `Unknown method \`bim.${namespace}.${method}()\`.`,
      );
    }
  }
  return errors;
}

function validatePositiveLiterals(body: string, methodName: string, keys: string[]): string[] {
  const errors: string[] = [];
  for (const key of keys) {
    const value = findPropertyValue(body, key);
    if (!value) continue;
    const n = Number(value.trim());
    if (!Number.isNaN(n) && n <= 0) {
      errors.push(`\`bim.create.${methodName}(...)\` requires \`${key}\` to be > 0.`);
    }
  }
  return errors;
}

function validatePointArities(body: string, methodName: string, pointArity: Record<string, number>): string[] {
  const errors: string[] = [];
  for (const [key, arity] of Object.entries(pointArity)) {
    const actual = getArrayLiteralArity(body, key);
    if (actual !== null && actual !== arity) {
      errors.push(`\`bim.create.${methodName}(...)\` expects \`${key}\` to be a ${arity}D point array.`);
    }
  }
  return errors;
}

function validateAxisPair(body: string, methodName: string, startKey: string, endKey: string): string[] {
  const start = parseNumericPointLiteral(body, startKey);
  const end = parseNumericPointLiteral(body, endKey);
  if (!start || !end) return [];
  if (start.length === end.length && start.every((value, index) => value === end[index])) {
    return [`\`bim.create.${methodName}(...)\` requires \`${startKey}\` and \`${endKey}\` to define a non-zero axis.`];
  }
  return [];
}

function validateAlternativeShapes(body: string, methodName: string, anyOf: string[][]): string[] {
  if (anyOf.some((group) => group.every((key) => hasKey(body, key)))) {
    return [];
  }
  return [
    `\`bim.create.${methodName}(...)\` requires one of: ${anyOf.map((group) => group.map((key) => `\`${key}\``).join(' + ')).join(' OR ')}.`,
  ];
}

function validateForbiddenKeys(body: string, forbidKeys: Array<{ key: string; message: string }>): string[] {
  return forbidKeys.filter(({ key }) => hasKey(body, key)).map(({ message }) => message);
}

function validateSlabShape(body: string): string[] {
  const errors: string[] = [];
  const profileValue = findPropertyValue(body, 'Profile');
  if (profileValue?.startsWith('{')) {
    errors.push('`bim.create.addIfcSlab(...)` expects `Profile` to be a 2D point array, not a generic profile object.');
  }
  return errors;
}

function validateRoofShape(methodName: 'addIfcRoof' | 'addIfcGableRoof', body: string): string[] {
  const errors: string[] = [];
  const slopeValue = findPropertyValue(body, 'Slope');
  if (slopeValue) {
    const slope = Number(slopeValue.trim());
    if (!Number.isNaN(slope) && slope >= Math.PI / 2) {
      errors.push(
        `\`bim.create.${methodName}(...)\` expects \`Slope\` in radians between 0 and π/2. If you meant degrees, convert them first (for example \`15 * Math.PI / 180\`).`,
      );
    }
  }

  const overhangValue = findPropertyValue(body, 'Overhang');
  if (overhangValue) {
    const overhang = Number(overhangValue.trim());
    if (!Number.isNaN(overhang) && overhang < 0) {
      errors.push(`\`bim.create.${methodName}(...)\` requires \`Overhang\` to be >= 0.`);
    }
  }

  if (methodName === 'addIfcRoof') {
    const nameValue = findPropertyValue(body, 'Name');
    if (nameValue && /gable/i.test(nameValue)) {
      errors.push('`bim.create.addIfcRoof(...)` is a flat/mono-pitch roof helper. Use `bim.create.addIfcGableRoof(...)` for standard dual-pitch or gable roofs.');
    }
  }

  return errors;
}

function validateGenericElementShape(body: string): string[] {
  const errors: string[] = [];
  if (hasKey(body, 'Type')) {
    errors.push('`bim.create.addElement(...)` uses `IfcType`, not `Type`.');
  }
  if (hasKey(body, 'Position')) {
    errors.push('`bim.create.addElement(...)` uses `Placement: { Location: [...] }`, not `Position`.');
  }
  if (hasKey(body, 'Height')) {
    errors.push('`bim.create.addElement(...)` uses `Depth`, not `Height`.');
  }
  if (hasKey(body, 'ExtrusionHeight')) {
    errors.push('`bim.create.addElement(...)` uses `Depth`, not `ExtrusionHeight`.');
  }
  const placementValue = findPropertyValue(body, 'Placement');
  if (placementValue?.startsWith('{') && !/\bLocation\s*:/.test(placementValue)) {
    errors.push('`bim.create.addElement(...)` requires `Placement.Location`.');
  }
  const profileValue = findPropertyValue(body, 'Profile');
  if (profileValue?.startsWith('{')) {
    if (!/\bProfileType\s*:/.test(profileValue)) {
      errors.push('`bim.create.addElement(...)` requires a valid IFC-style `Profile` object with `ProfileType`.');
    }
    if (/\bkind\s*:|\bxDim\s*:|\byDim\s*:/.test(profileValue)) {
      errors.push('`bim.create.addElement(...)` profile keys must use IFC casing such as `XDim`, `YDim`, `Radius`, `OuterCurve`, and `ProfileType`.');
    }
  }
  return errors;
}

function validateAxisElementShape(body: string): string[] {
  const errors: string[] = [];
  if (hasKey(body, 'Type')) {
    errors.push('`bim.create.addAxisElement(...)` uses `IfcType`, not `Type`.');
  }
  const profileValue = findPropertyValue(body, 'Profile');
  if (profileValue?.startsWith('{') && !/\bProfileType\s*:/.test(profileValue)) {
    errors.push('`bim.create.addAxisElement(...)` requires a valid IFC-style `Profile` object with `ProfileType`.');
  }
  return errors;
}

function validateCreateContracts(code: string): string[] {
  const errors: string[] = [];
  for (const [methodName, rule] of Object.entries(METHOD_RULES)) {
    const objectBodies = getObjectBodiesForMethod(code, methodName);
    for (const body of objectBodies) {
      const missing = rule.required.filter((key) => !hasKey(body, key));
      if (missing.length > 0) {
        errors.push(`\`bim.create.${methodName}(...)\` is missing required key(s): ${missing.map((key) => `\`${key}\``).join(', ')}.`);
      }
      if (rule.anyOf) errors.push(...validateAlternativeShapes(body, methodName, rule.anyOf));
      if (rule.positiveKeys) errors.push(...validatePositiveLiterals(body, methodName, rule.positiveKeys));
      if (rule.pointArity) errors.push(...validatePointArities(body, methodName, rule.pointArity));
      if (rule.axisPair) errors.push(...validateAxisPair(body, methodName, rule.axisPair[0], rule.axisPair[1]));
      if (rule.forbidKeys) errors.push(...validateForbiddenKeys(body, rule.forbidKeys));
      if (rule.custom) errors.push(...rule.custom(body));
    }
  }
  return errors;
}

function validateBareIdentifierTraps(code: string): string[] {
  const errors: string[] = [];
  for (const methodName of Object.keys(METHOD_RULES)) {
    for (const objectBody of getObjectBodiesForMethod(code, methodName)) {
      const valueRegex = /:\s*([A-Za-z_]\w*)\s*(?=,|$)/g;
      let valueMatch: RegExpExecArray | null;
      while ((valueMatch = valueRegex.exec(objectBody)) !== null) {
        const ident = valueMatch[1];
        if (SUSPICIOUS_BARE_IDENTIFIERS.has(ident)) {
          errors.push(
            `Suspicious bare identifier value \`${ident}\` in BIM parameter object. Use a literal/array (e.g. \`${ident}: [..]\` or \`${ident}: 1\`) or declare the variable explicitly.`,
          );
        }
      }
    }
  }
  return errors;
}

function validateWallHostedOpeningDiagnostics(code: string): PreflightScriptDiagnostic[] {
  const hasWallCalls = code.includes('bim.create.addIfcWall(');
  if (!hasWallCalls) return [];

  const hasWallOpenings = /\bOpenings\s*:/.test(code);
  if (hasWallOpenings) return [];

  const diagnostics: PreflightScriptDiagnostic[] = [];
  const methodMessages = {
    addIfcWindow: 'Suspicious pattern: `bim.create.addIfcWindow(...)` is being used alongside walls, but no wall `Openings` are defined. `addIfcWindow(...)` creates a world-aligned standalone window and will not auto-align or host into a wall. For wall-hosted inserts, use `bim.create.addIfcWallWindow(...)` or `Openings` on `bim.create.addIfcWall(...)`.',
    addIfcDoor: 'Suspicious pattern: `bim.create.addIfcDoor(...)` is being used alongside walls, but no wall `Openings` are defined. `addIfcDoor(...)` creates a world-aligned standalone door and will not auto-align or host into a wall. For wall-hosted inserts, use `bim.create.addIfcWallDoor(...)` or `Openings` on `bim.create.addIfcWall(...)`.',
  } satisfies Record<'addIfcWindow' | 'addIfcDoor', string>;

  for (const methodName of Object.keys(methodMessages) as Array<'addIfcWindow' | 'addIfcDoor'>) {
    for (const match of getMethodCalls(code, methodName)) {
      diagnostics.push(createPreflightDiagnostic(
        'wall_hosted_opening_pattern',
        methodMessages[methodName],
        'error',
        {
          methodName,
          symbol: 'Openings',
          failureKind: 'standalone_opening',
          range: match.range,
          line: match.line,
          column: match.column,
          snippet: match.snippet,
          fixHint: `Replace this ${methodName === 'addIfcDoor' ? 'door' : 'window'} call with a wall-hosted insert or add it through the host wall's \`Openings\` payload.`,
          unterminated: match.unterminated,
        },
      ));
    }
  }

  return diagnostics;
}

function validateMetadataQueryPatterns(code: string): string[] {
  const errors: string[] = [];

  if (/bim\.query\.property\(\s*[^,]+,\s*["'`]Pset_MaterialCommon["'`]/.test(code) || /bim\.query\.property\(\s*[^,]+,\s*["'`]Material["'`]\s*,\s*["'`]Name["'`]/.test(code)) {
    errors.push('Suspicious material lookup: materials are usually not stored as ordinary property-set values. Prefer `bim.query.materials(entity)` over querying `Pset_MaterialCommon` or `Material.Name` as a property set.');
  }

  return errors;
}

function looksLikeMultiStoreyScript(code: string): boolean {
  const hasStoreyLoop = /for\s*\([^)]*;\s*[^;]*\b(storeyCount|levels?|floors?)\b/i.test(code) || /for\s*\(\s*let\s+\w+\s*=\s*0\s*;[^)]*<\s*\w+Count/i.test(code);
  const hasStoreyCreation = code.includes('bim.create.addIfcBuildingStorey(');
  return hasStoreyLoop && hasStoreyCreation;
}

function mentionsElevationSignal(value: string): boolean {
  return /\b(elevation|storeyElevation|levelElevation|baseZ|levelZ|storeyZ|z)\b/.test(value);
}

function validateWorldPlacementPatterns(code: string): PreflightScriptDiagnostic[] {
  if (!looksLikeMultiStoreyScript(code)) return [];

  const diagnostics: PreflightScriptDiagnostic[] = [];
  const checks: Array<{ methodName: 'addIfcCurtainWall' | 'addIfcMember' | 'addIfcPlate'; keys: string[] }> = [
    { methodName: 'addIfcCurtainWall', keys: ['Start', 'End'] },
    { methodName: 'addIfcMember', keys: ['Start', 'End'] },
    { methodName: 'addIfcPlate', keys: ['Position'] },
  ];

  for (const { methodName, keys } of checks) {
    for (const match of getMethodCalls(code, methodName)) {
      const body = getObjectBodiesForMethod(match.snippet, methodName)[0];
      if (!body) continue;
      const zValues = keys
        .map((key) => {
          const items = getArrayLiteralItems(body, key);
          return items && items.length >= 3 ? items[2].trim() : null;
        })
        .filter((value): value is string => Boolean(value));

      if (zValues.length === 0) continue;
      const allGrounded = zValues.every((value) => value === '0' || value === '0.0');
      const anyElevationAware = zValues.some((value) => mentionsElevationSignal(value));
      if (allGrounded && !anyElevationAware) {
        diagnostics.push(createPreflightDiagnostic(
          'world_placement_elevation',
          `Suspicious multi-level placement: \`bim.create.${methodName}(...)\` appears inside a repeated storey-level script but uses fixed ground-level Z coordinates. This method is world-placement based, so its Z coordinates should usually include the current level elevation.`,
          'error',
          {
            methodName,
            failureKind: 'missing_level_elevation',
            range: match.range,
            line: match.line,
            column: match.column,
            snippet: match.snippet,
            fixHint: 'Include the current level/storey elevation in the Z coordinates for this world-placement call.',
          },
        ));
      }
    }
  }

  return diagnostics;
}

function validateDetachedSnippetScope(code: string): PreflightScriptDiagnostic[] {
  const diagnostics: PreflightScriptDiagnostic[] = [];
  const declared = collectDeclaredIdentifiers(code);

  const maybePushIdentifierDiagnostic = (identifier: string, message: string) => {
    const match = new RegExp(String.raw`\b${identifier}\b`).exec(code);
    const offset = match?.index ?? 0;
    const { line, column } = getLineAndColumn(code, offset);
    diagnostics.push(createPreflightDiagnostic(
      'detached_snippet_scope',
      message,
      'error',
      {
        symbol: identifier,
        failureKind: 'missing_context_binding',
        range: { from: offset, to: offset + identifier.length },
        line,
        column,
        snippet: getLineSnippet(code, offset),
        fixHint: 'Patch the existing full script or restore the missing surrounding declarations instead of returning an isolated fragment.',
      },
    ));
  };

  if (/\bbim\.create\.[A-Za-z]+\(\s*h\s*,/.test(code) && !declared.has('h') && !/bim\.create\.project\(/.test(code)) {
    maybePushIdentifierDiagnostic('h', 'Detached snippet risk: BIM create calls reference `h`, but no project handle is declared in this script. Preserve the surrounding full script or recreate the project/context explicitly.');
  }

  if (/\bbim\.create\.[A-Za-z]+\(\s*h\s*,\s*storey\b/.test(code) && !declared.has('storey') && !/addIfcBuildingStorey\(/.test(code)) {
    maybePushIdentifierDiagnostic('storey', 'Detached snippet risk: BIM create calls reference `storey`, but no storey handle is declared in this script. Preserve the surrounding loop/context instead of returning a standalone fragment.');
  }

  for (const identifier of ['width', 'depth', 'i', 'z']) {
    if (new RegExp(String.raw`\b${identifier}\b`).test(code) && !declared.has(identifier)) {
      maybePushIdentifierDiagnostic(identifier, `Detached snippet risk: script references \`${identifier}\` without declaring it locally. If this is a fix for an existing script, patch the full script in place instead of returning an isolated fragment.`);
    }
  }

  return diagnostics;
}

export function validateScriptPreflightDetailed(code: string): PreflightScriptDiagnostic[] {
  return [
    ...validateKnownBimMethods(code).map((message) => createPreflightDiagnostic(
      message.startsWith('Unknown namespace') ? 'unknown_namespace' : 'unknown_method',
      message,
      'error',
      buildDiagnosticData(message),
    )),
    ...validateCreateContracts(code).map((message) => createPreflightDiagnostic('create_contract', message, 'error', buildDiagnosticData(message))),
    ...validateBareIdentifierTraps(code).map((message) => createPreflightDiagnostic('bare_identifier', message, 'error', buildDiagnosticData(message))),
    ...validateWallHostedOpeningDiagnostics(code),
    ...validateMetadataQueryPatterns(code).map((message) => createPreflightDiagnostic('metadata_query_pattern', message, 'error', buildDiagnosticData(message))),
    ...validateWorldPlacementPatterns(code),
    ...validateDetachedSnippetScope(code),
  ];
}

export function validateScriptPreflight(code: string): string[] {
  return formatDiagnosticsForDisplay(validateScriptPreflightDetailed(code));
}

function buildDiagnosticData(message: string): Record<string, unknown> | undefined {
  const data: Record<string, unknown> = {};
  const methodMatch = /`bim\.\w+\.([A-Za-z0-9_]+)\([^`]*`/.exec(message);
  const symbolMatch = /`([A-Za-z_]\w*)`/.exec(message);

  if (methodMatch) data.methodName = methodMatch[1];
  if (symbolMatch) data.symbol = symbolMatch[1];

  return Object.keys(data).length > 0 ? data : undefined;
}
