#!/usr/bin/env node

import { execFileSync, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, lstatSync, readFileSync } from 'node:fs'
import { basename } from 'node:path'

const migrationDirectory = 'apps/api/migrations/'
const reviewedMigrationsFile = 'scripts/migration-safety-reviewed.json'
const base = process.argv[2] || process.env.RELEASE_BASE_SHA || ''

function fail(message) {
  console.error(`migration_safety=FAIL ${message}`)
  process.exit(1)
}

function git(args, options = {}) {
  try {
    return execFileSync('git', args, {
      encoding: options.encoding ?? 'utf8',
      stdio: ['ignore', 'pipe', 'pipe'],
    })
  } catch (error) {
    const stderr = error.stderr?.toString().trim()
    fail(stderr ? `git_error=${JSON.stringify(stderr)}` : 'git command failed')
  }
}

if (!/^[0-9a-f]{40}$/.test(base)) {
  fail('RELEASE_BASE_SHA must be a full lowercase commit SHA')
}

const root = git(['rev-parse', '--show-toplevel']).trim()
process.chdir(root)

const resolvedBase = git([
  'rev-parse',
  '--verify',
  '--end-of-options',
  `${base}^{commit}`,
]).trim()
if (resolvedBase !== base) {
  fail('release base did not resolve exactly')
}

const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], {
  stdio: 'ignore',
})
if (ancestry.status !== 0) {
  fail('release base is not an ancestor of HEAD')
}

function changedMigrations(from) {
  const rawChanges = git(
    [
      'diff',
      '--name-status',
      '-z',
      '--no-renames',
      `${from}..HEAD`,
      '--',
      migrationDirectory,
    ],
    { encoding: null },
  )
  const fields = rawChanges.toString('utf8').split('\0')
  if (fields.at(-1) === '') fields.pop()
  if (fields.length % 2 !== 0) {
    fail('could not parse changed migration paths')
  }
  return fields
}

const fields = changedMigrations(base)

function executableSql(sql, source = 'SQL input') {
  let output = ''

  for (let index = 0; index < sql.length; index += 1) {
    const current = sql[index]
    const next = sql[index + 1]

    if (current === '-' && next === '-') {
      index += 2
      while (index < sql.length && sql[index] !== '\n') index += 1
      output += '\n'
      continue
    }

    if (current === '/' && next === '*') {
      let depth = 1
      index += 2
      while (index < sql.length && depth > 0) {
        if (sql[index] === '/' && sql[index + 1] === '*') {
          depth += 1
          index += 2
          continue
        }
        if (sql[index] === '*' && sql[index + 1] === '/') {
          depth -= 1
          index += 2
          continue
        }
        index += 1
      }
      if (depth !== 0) fail('unterminated SQL block comment')
      index -= 1
      output += ' '
      continue
    }

    if (current === "'" || current === '"') {
      const prefix = sql.slice(Math.max(0, index - 2), index).toUpperCase()
      const previous = sql[index - 1]?.toUpperCase()
      const beforePrevious = sql[index - 2] || ''
      const beforeUnicodePrefix = sql[index - 3] || ''
      const escapeString =
        current === "'" &&
        previous === 'E' &&
        !/[A-Za-z0-9_$]/.test(beforePrevious)
      const unicodeString =
        prefix === 'U&' && !/[A-Za-z0-9_$]/.test(beforeUnicodePrefix)
      if (escapeString || unicodeString) {
        fail(`${source}: PostgreSQL escape/unicode strings are not allowed in migrations`)
      }
      const quote = current
      index += 1
      let closed = false
      while (index < sql.length) {
        if (sql[index] === quote && sql[index + 1] === quote) {
          index += 2
          continue
        }
        if (sql[index] === quote) {
          closed = true
          break
        }
        index += 1
      }
      if (!closed) fail('unterminated SQL quoted value')
      output += ' '
      continue
    }

    if (current === '$') {
      const delimiter = sql.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0]
      if (delimiter) {
        const bodyStart = index + delimiter.length
        const bodyEnd = sql.indexOf(delimiter, bodyStart)
        if (bodyEnd === -1) fail('unterminated SQL dollar-quoted body')
        // PostgreSQL function bodies may contain executable DDL. Scan their
        // contents recursively instead of treating them as inert strings.
        const bodySql = executableSql(sql.slice(bodyStart, bodyEnd), source)
        // A bare END terminates a PL/pgSQL block inside a dollar-quoted body,
        // but at the top level PostgreSQL treats END as COMMIT. Mask only the
        // block terminator here so the top-level transaction rule stays exact.
        const bodyWithoutBlockEnd = bodySql
          .split(';')
          .map((statement) => (/^\s*END\s*$/i.test(statement) ? ' ' : statement))
          .join(';')
        output += ` ${bodyWithoutBlockEnd} `
        index = bodyEnd + delimiter.length - 1
        continue
      }
    }

    output += current
  }

  return output
}

const destructiveRules = [
  {
    name: 'procedural SQL body',
    pattern:
      /^\s*(?:DO\b|CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b)/i,
  },
  {
    name: 'dynamic SQL execution',
    pattern: /\bEXECUTE\b/i,
  },
  {
    name: 'destructive DROP operation',
    pattern: /\bDROP\b/i,
  },
  { name: 'MERGE operation', pattern: /\bMERGE\b/i },
  {
    name: 'CREATE OR REPLACE operation',
    pattern: /\bCREATE\s+OR\s+REPLACE\b/i,
  },
  { name: 'TRUNCATE operation', pattern: /\bTRUNCATE(?:\s+TABLE)?\b/i },
  { name: 'DELETE FROM operation', pattern: /\bDELETE\s+FROM\b/i },
  { name: 'permission revocation', pattern: /\bREVOKE\b/i },
  {
    name: 'transaction control operation',
    pattern:
      /\b(?:COMMIT|ROLLBACK|ABORT|SAVEPOINT)\b|\bRELEASE\s+SAVEPOINT\b|\bPREPARE\s+TRANSACTION\b|^\s*END(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*$/i,
  },
  {
    name: 'SQL string semantics override',
    pattern:
      /\bSET\s+(?:LOCAL\s+|SESSION\s+)?STANDARD_CONFORMING_STRINGS\b/i,
  },
  {
    name: 'object or column rename',
    pattern: /\bRENAME\b/i,
  },
  { name: 'SET SCHEMA operation', pattern: /\bSET\s+SCHEMA\b/i },
  {
    name: 'column type change',
    pattern:
      /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\b(?:SET\s+DATA\s+)?TYPE\b/i,
  },
  {
    name: 'SET NOT NULL operation',
    pattern:
      /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bSET\s+NOT\s+NULL\b/i,
  },
  {
    name: 'non-null column addition',
    pattern:
      /\bALTER\s+TABLE\b[\s\S]*\bADD\s+(?:COLUMN\s+)?[\s\S]*\bNOT\s+NULL\b/i,
  },
  {
    name: 'DROP DEFAULT operation',
    pattern:
      /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bDROP\s+DEFAULT\b/i,
  },
]

const violations = []
const reviewedViolations = []
let addedFiles = 0
const orderingCandidates = new Set()
const destructiveRuleNames = new Set(destructiveRules.map((rule) => rule.name))

function readReviewedMigrations() {
  if (!existsSync(reviewedMigrationsFile)) return new Map()

  let payload
  try {
    payload = JSON.parse(readFileSync(reviewedMigrationsFile, 'utf8'))
  } catch (error) {
    fail(
      `${reviewedMigrationsFile} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
    )
  }
  if (
    payload?.version !== 1 ||
    payload.migrations === null ||
    typeof payload.migrations !== 'object' ||
    Array.isArray(payload.migrations)
  ) {
    fail(`${reviewedMigrationsFile} must contain version 1 migrations`)
  }

  const reviewed = new Map()
  for (const [file, entry] of Object.entries(payload.migrations)) {
    if (
      !file.startsWith(migrationDirectory) ||
      !/^[0-9a-f]{64}$/.test(entry?.sha256 || '') ||
      !Array.isArray(entry?.allowedViolations) ||
      entry.allowedViolations.length === 0 ||
      typeof entry?.reason !== 'string' ||
      entry.reason.trim() === ''
    ) {
      fail(`${reviewedMigrationsFile} contains an invalid entry for ${file}`)
    }
    if (
      entry.allowedViolations.some(
        (violation) =>
          typeof violation !== 'string' ||
          !destructiveRuleNames.has(violation),
      )
    ) {
      fail(`${reviewedMigrationsFile} contains an unknown rule for ${file}`)
    }
    if (!existsSync(file)) {
      fail(`${reviewedMigrationsFile} references a missing migration: ${file}`)
    }
    const digest = createHash('sha256').update(readFileSync(file)).digest('hex')
    if (digest !== entry.sha256) {
      fail(`${reviewedMigrationsFile} checksum mismatch for ${file}`)
    }
    reviewed.set(file, new Set(entry.allowedViolations))
  }
  return reviewed
}

const reviewedMigrations = readReviewedMigrations()

function migrationNamesAt(ref) {
  const output = git(
    ['ls-tree', '-r', '-z', '--name-only', ref, '--', migrationDirectory],
    { encoding: null },
  )
  return output
    .toString('utf8')
    .split('\0')
    .filter(Boolean)
    .filter((file) => {
      const relative = file.slice(migrationDirectory.length)
      return file.startsWith(migrationDirectory) && !relative.includes('/')
    })
    .map((file) => basename(file))
    .filter((name) => /^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(name))
    .sort()
}

const existingNames = migrationNamesAt(base)
const previousMaximumName = existingNames.at(-1) || ''

for (let index = 0; index < fields.length; index += 2) {
  if (fields[index] === 'A') orderingCandidates.add(fields[index + 1])
}

for (let index = 0; index < fields.length; index += 2) {
  const status = fields[index]
  const file = fields[index + 1]

  if (status !== 'A') {
    violations.push(`${file}: applied migrations are immutable (status ${status})`)
    continue
  }

  addedFiles += 1
  const filename = basename(file)
  if (
    file !== `${migrationDirectory}${filename}` ||
    !/^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(filename)
  ) {
    violations.push(`${file}: new migration must use NNNN_lowercase_name.sql`)
    continue
  }

  if (
    orderingCandidates.has(file) &&
    previousMaximumName &&
    filename <= previousMaximumName
  ) {
    violations.push(
      `${file}: new migration name must sort after ${previousMaximumName}`,
    )
    continue
  }

  const treeEntry = git(['ls-tree', 'HEAD', '--', file]).trim()
  let fileState
  try {
    fileState = lstatSync(file)
  } catch {
    violations.push(`${file}: new migration is unavailable in the worktree`)
    continue
  }
  if (
    !/^100644 blob [0-9a-f]+\t/.test(treeEntry) ||
    !fileState.isFile() ||
    fileState.isSymbolicLink()
  ) {
    violations.push(`${file}: new migration must be a mode 100644 regular file`)
    continue
  }

  const statements = executableSql(readFileSync(file, 'utf8'), file).split(';')
  for (const statement of statements) {
    for (const rule of destructiveRules) {
      if (rule.pattern.test(statement)) {
        if (reviewedMigrations.get(file)?.has(rule.name)) {
          reviewedViolations.push(`${file}: ${rule.name}`)
        } else {
          violations.push(`${file}: ${rule.name}`)
        }
      }
    }
  }
}

if (violations.length > 0) {
  for (const violation of [...new Set(violations)]) {
    console.error(`migration_safety_violation=${JSON.stringify(violation)}`)
  }
  fail(`base=${base} violations=${new Set(violations).size}`)
}

for (const violation of [...new Set(reviewedViolations)]) {
  console.log(`migration_safety_reviewed=${JSON.stringify(violation)}`)
}
console.log(
  `migration_safety=PASS base=${base} added_files=${addedFiles} reviewed_violations=${new Set(reviewedViolations).size}`,
)
