#!/usr/bin/env node
// ponytail — UserPromptSubmit hook to track which ponytail mode is active
// Inspects user input for /ponytail commands and writes mode to flag file

const { getDefaultMode, isDeactivationCommand, writeDefaultMode } = require('./ponytail-config');
const { clearMode, isQoder, readMode, setMode, writeHookOutput } = require('./ponytail-runtime');
const { getPonytailInstructions } = require('./ponytail-instructions');

let input = '';
let done = false;

function finish() {
  if (done) return;
  done = true;
  try {
    // Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
    const data = JSON.parse(input.replace(/^\uFEFF/, ''));
    const prompt = (data.prompt || '').trim().toLowerCase();

    // Match /ponytail commands
    let modeSwitched = false;
    let deactivated = false;
    if (/^[/@$]ponytail/.test(prompt)) {
      const parts = prompt.split(/\s+/);
      const cmd = parts[0].replace(/^[@$]/, '/');
      const arg = parts[1] || '';

      let mode = null;
      let isReportOnly = false;

      if (cmd === '/ponytail-review' || cmd === '/ponytail:ponytail-review') {
        mode = 'review';
      } else if (cmd === '/ponytail' || cmd === '/ponytail:ponytail') {
        // `/ponytail default <mode>` persists the default to config (survives
        // restarts). Plain switches stay session-scoped ("sticks until session
        // end"), so this is the only path that writes config. review is not a
        // valid default (#377), so only off/lite/full/ultra are accepted.
        if (arg === 'default') {
          const dmode = parts[2];
          if (dmode === 'off' || dmode === 'lite' || dmode === 'full' || dmode === 'ultra') {
            writeDefaultMode(dmode);
            writeHookOutput('UserPromptSubmit', dmode, 'PONYTAIL DEFAULT SET — new sessions start in ' + dmode + '.');
          }
          return; // don't fall through to the session-mode switch
        }
        if (arg === 'lite') mode = 'lite';
        else if (arg === 'full') mode = 'full';
        else if (arg === 'ultra') mode = 'ultra';
        else if (arg === 'off') mode = 'off';
        else if (arg === '') {
          isReportOnly = true;
          mode = readMode() || getDefaultMode();
        } else {
          mode = getDefaultMode();
        }
      }

      if (isReportOnly) {
        writeHookOutput(
          'UserPromptSubmit',
          mode,
          'PONYTAIL MODE ACTIVE — level: ' + mode,
        );
      } else if (mode && mode !== 'off') {
        setMode(mode);
        modeSwitched = true;
        // ponytail: Qoder needs the full ruleset every turn, so when a mode
        // switch happens we fold the confirmation into the ruleset output
        // below (one JSON on stdout) instead of emitting two separate writes.
        if (!isQoder) {
          writeHookOutput(
            'UserPromptSubmit',
            mode,
            'PONYTAIL MODE CHANGED — level: ' + mode,
          );
        }
      } else if (mode === 'off') {
        clearMode();
        deactivated = true;
        writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
      }
    }

    // Detect deactivation
    if (!modeSwitched && !deactivated && isDeactivationCommand(prompt)) {
      clearMode();
      deactivated = true;
      writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
    }

    // Qoder has no SessionStart event, so UserPromptSubmit does double duty:
    // activate the default mode on first prompt (if no flag exists yet), then
    // inject the ruleset on every prompt. Claude Code/Codex do this in
    // SessionStart via ponytail-activate.js; Qoder can't, so we do it here.
    // Skip when deactivated — user just turned ponytail off.
    if (isQoder && !deactivated) {
      let currentMode = readMode();
      if (!currentMode) {
        // First prompt in session — initialize from config/env default
        currentMode = getDefaultMode();
        if (currentMode !== 'off') {
          try { setMode(currentMode); } catch (e) {}
        }
      }
      if (currentMode && currentMode !== 'off') {
        // ponytail: one JSON per invocation — mode-switch confirmation is
        // folded into the ruleset header so Qoder gets both in one write.
        const header = modeSwitched
          ? 'PONYTAIL MODE CHANGED — level: ' + currentMode + '\n\n'
          : '';
        writeHookOutput('UserPromptSubmit', currentMode, header + getPonytailInstructions(currentMode));
      }
    }
  } catch (e) {
    // Silent fail
  }
}

process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', finish);

// Never hang the session. On Windows, Claude Code runs this hook through a
// PowerShell `if {}` wrapper that can swallow the piped prompt JSON, so stdin
// 'end' never fires and the hook blocks forever — freezing the session (#443).
// On error, or after a short fallback, process whatever arrived (recovering the
// mode if data came without EOF) and exit. unref() keeps the timer from adding
// latency to the normal path, where 'end' fires first. Mirrors the best-effort,
// never-block contract the other lifecycle hooks already follow.
process.stdin.on('error', () => { finish(); process.exit(0); });
setTimeout(() => { finish(); process.exit(0); }, 1000).unref();


/**
 * Fuzz target for DiskANN RobustPrune algorithm (diskann_prune_select).
 *
 * diskann_prune_select is exposed for testing and takes:
 *   - inter_distances: flattened NxN matrix of inter-candidate distances
 *   - p_distances: N distances from node p to each candidate
 *   - num_candidates, alpha, max_neighbors
 *
 * This is a pure function that doesn't need a database, so we can
 * call it directly with fuzz-controlled inputs. This gives the fuzzer
 * maximum speed (no SQLite overhead) to explore:
 *
 *   - alpha boundary: alpha=0 (prunes nothing), alpha=very large (prunes all)
 *   - max_neighbors = 0, 1, num_candidates, > num_candidates
 *   - num_candidates = 0, 1, large
 *   - Distance matrices with: all zeros, all same, negative values, NaN, Inf
 *   - Non-symmetric distance matrices (should still work)
 *   - Memory: large num_candidates to stress malloc
 *
 * Key code paths:
 *   - diskann_prune_select alpha-pruning loop
 *   - Boundary: selectedCount reaches max_neighbors exactly
 *   - All candidates pruned before max_neighbors reached
 */
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include "sqlite-vec.h"
#include "sqlite3.h"
#include <assert.h>

/* Declare the test-exposed function.
 * diskann_prune_select is not static -- it's a public symbol. */
extern int diskann_prune_select(
    const float *inter_distances, const float *p_distances,
    int num_candidates, float alpha, int max_neighbors,
    int *outSelected, int *outCount);

static uint8_t fuzz_byte(const uint8_t **data, size_t *size, uint8_t def) {
  if (*size == 0) return def;
  uint8_t b = **data;
  (*data)++;
  (*size)--;
  return b;
}

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
  if (size < 8) return 0;

  /* Consume parameters from fuzz data */
  int num_candidates = fuzz_byte(&data, &size, 0) % 33; /* 0..32 */
  int max_neighbors = fuzz_byte(&data, &size, 0) % 17;  /* 0..16 */

  /* Alpha: pick from interesting values */
  uint8_t alpha_idx = fuzz_byte(&data, &size, 0) % 8;
  float alpha_values[] = {0.0f, 0.5f, 1.0f, 1.2f, 1.5f, 2.0f, 10.0f, 100.0f};
  float alpha = alpha_values[alpha_idx];

  if (num_candidates == 0) {
    /* Test empty case */
    int outCount = -1;
    int rc = diskann_prune_select(NULL, NULL, 0, alpha, max_neighbors,
                                   NULL, &outCount);
    assert(rc == 0 /* SQLITE_OK */);
    assert(outCount == 0);
    return 0;
  }

  /* Allocate arrays */
  int n = num_candidates;
  float *inter_distances = malloc(n * n * sizeof(float));
  float *p_distances = malloc(n * sizeof(float));
  int *outSelected = malloc(n * sizeof(int));
  if (!inter_distances || !p_distances || !outSelected) {
    free(inter_distances);
    free(p_distances);
    free(outSelected);
    return 0;
  }

  /* Fill p_distances from fuzz data (sorted ascending for correct input) */
  for (int i = 0; i < n; i++) {
    uint8_t raw = fuzz_byte(&data, &size, (uint8_t)(i * 10));
    p_distances[i] = (float)raw / 10.0f;
  }
  /* Sort p_distances ascending (prune_select expects sorted input) */
  for (int i = 1; i < n; i++) {
    float tmp = p_distances[i];
    int j = i - 1;
    while (j >= 0 && p_distances[j] > tmp) {
      p_distances[j + 1] = p_distances[j];
      j--;
    }
    p_distances[j + 1] = tmp;
  }

  /* Fill inter-distance matrix from fuzz data */
  for (int i = 0; i < n * n; i++) {
    uint8_t raw = fuzz_byte(&data, &size, (uint8_t)(i % 256));
    inter_distances[i] = (float)raw / 10.0f;
  }
  /* Make diagonal zero */
  for (int i = 0; i < n; i++) {
    inter_distances[i * n + i] = 0.0f;
  }

  int outCount = -1;
  int rc = diskann_prune_select(inter_distances, p_distances,
                                 n, alpha, max_neighbors,
                                 outSelected, &outCount);
  /* Basic sanity: should not crash, count should be valid */
  assert(rc == 0);
  assert(outCount >= 0);
  assert(outCount <= max_neighbors || max_neighbors == 0);
  assert(outCount <= n);

  /* Verify outSelected flags are consistent with outCount */
  int flagCount = 0;
  for (int i = 0; i < n; i++) {
    if (outSelected[i]) flagCount++;
  }
  assert(flagCount == outCount);

  free(inter_distances);
  free(p_distances);
  free(outSelected);
  return 0;
}


/**
 * Update Command
 *
 * Refreshes OpenSpec skills and commands for configured tools.
 * Supports profile-aware updates, delivery changes, migration, and smart update detection.
 */

import path from 'path';
import chalk from 'chalk';
import ora from 'ora';
import * as fs from 'fs';
import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
import {
  generateCommands,
  CommandAdapterRegistry,
} from './command-generation/index.js';
import {
  getToolVersionStatus,
  getSkillTemplates,
  getCommandContents,
  generateSkillContent,
  getToolsWithSkillsDir,
  type ToolVersionStatus,
} from './shared/index.js';
import {
  detectLegacyArtifacts,
  cleanupLegacyArtifacts,
  formatDeferredGlobalPromptSummary,
  formatCleanupSummary,
  formatDetectionSummary,
  getLegacyGlobalPromptMatches,
  getLegacyWorkflowIdsForTool,
  getToolsFromLegacyArtifacts,
  omitGlobalLegacyPromptFiles,
  pickGlobalLegacyPromptFiles,
  type LegacyDetectionResult,
} from './legacy-cleanup.js';
import { isInteractive } from '../utils/interactive.js';
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, ALL_WORKFLOWS, CORE_WORKFLOWS } from './profiles.js';
import { getOnboardingCommands } from './onboarding-commands.js';
import { getAvailableTools } from './available-tools.js';
import {
  WORKFLOW_TO_SKILL_DIR,
  getCommandConfiguredTools,
  getConfiguredToolsForProfileSync,
  getToolsNeedingProfileSync,
} from './profile-sync-drift.js';
import {
  scanInstalledWorkflows as scanInstalledWorkflowsShared,
  migrateIfNeeded as migrateIfNeededShared,
  migrateLegacySkillDirs,
} from './migration.js';
import {
  resolveCommandSurfaceCapability,
  shouldGenerateCommandsForTool,
  shouldGenerateSkillsForTool,
  shouldReconcileCommandFilesForTool,
  shouldRemoveSkillsForTool,
} from './command-surface.js';

const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');

/**
 * Captures legacy migration side effects so update can refresh newly configured
 * tools and honor workflow subsets inferred from legacy Codex prompt filenames.
 */
type LegacyUpgradeResult = {
  newlyConfiguredTools: string[];
  workflowOverrides: Partial<Record<string, readonly (typeof ALL_WORKFLOWS)[number][]>>;
  deferredGlobalCleanup?: LegacyDetectionResult;
};

/**
 * Options for the update command.
 */
export interface UpdateCommandOptions {
  /** Force update even when tools are up to date */
  force?: boolean;
}

/**
 * Scans installed workflow artifacts (skills and managed commands) across all configured tools.
 * Returns the union of detected workflow IDs that match ALL_WORKFLOWS.
 *
 * Wrapper around the shared migration module's scanInstalledWorkflows that accepts tool IDs.
 */
export function scanInstalledWorkflows(projectPath: string, toolIds: string[]): string[] {
  const tools = toolIds
    .map((id) => AI_TOOLS.find((t) => t.value === id))
    .filter((t): t is NonNullable<typeof t> => t != null);
  return scanInstalledWorkflowsShared(projectPath, tools);
}

export class UpdateCommand {
  private readonly force: boolean;

  constructor(options: UpdateCommandOptions = {}) {
    this.force = options.force ?? false;
  }

  /**
   * Refreshes OpenSpec skills and commands for all configured tools,
   * regenerating artifacts according to the effective profile and delivery mode.
   *
   * @param projectPath - Path to the project root containing the openspec directory
   */
  async execute(projectPath: string): Promise<void> {
    const resolvedProjectPath = path.resolve(projectPath);
    const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);

    // 1. Check openspec directory exists
    if (!await FileSystemUtils.directoryExists(openspecPath)) {
      throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
    }

    // 2. Migrate OpenSpec-managed skills left in renamed tool directories
    // (e.g. .kimi -> .kimi-code) so they stay detected and get refreshed,
    // then perform the one-time profile migration if needed before any
    // legacy upgrade generation.
    for (const migration of migrateLegacySkillDirs(resolvedProjectPath)) {
      console.log(chalk.dim(`Migrated ${migration.movedSkillDirs} skill director${migration.movedSkillDirs === 1 ? 'y' : 'ies'}: ${migration.from}/skills → ${migration.to}/skills`));
    }

    // Use detected tool directories to preserve existing opsx skills/commands.
    const detectedTools = getAvailableTools(resolvedProjectPath);
    migrateIfNeededShared(resolvedProjectPath, detectedTools);

    // 3. Read global config for profile/delivery
    const globalConfig = getGlobalConfig();
    const profile = globalConfig.profile ?? 'core';
    const delivery: Delivery = globalConfig.delivery ?? 'both';
    const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows);
    const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] =>
      (ALL_WORKFLOWS as readonly string[]).includes(workflow)
    );

    // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config
    const legacyUpgrade = await this.handleLegacyCleanup(
      resolvedProjectPath,
      desiredWorkflows,
      delivery
    );
    const {
      newlyConfiguredTools,
      workflowOverrides: legacyWorkflowOverrides,
      deferredGlobalCleanup,
    } = legacyUpgrade;

    // 5. Find configured tools
    const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath);

    if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
      if (deferredGlobalCleanup) {
        await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
      }
      console.log(chalk.yellow('No configured tools found.'));
      console.log(chalk.dim('Run "openspec init" to set up tools.'));
      return;
    }

    // 6. Check version status for all configured tools
    const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath);
    const commandConfiguredSet = new Set(commandConfiguredTools);
    const toolStatuses = configuredTools.map((toolId) => {
      const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION);
      if (!status.configured && commandConfiguredSet.has(toolId)) {
        return { ...status, configured: true };
      }
      return status;
    });
    const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status] as const));

    // 7. Smart update detection
    const toolsNeedingVersionUpdate = toolStatuses
      .filter((s) => s.needsUpdate)
      .map((s) => s.toolId);
    const toolsNeedingConfigSync = getToolsNeedingProfileSync(
      resolvedProjectPath,
      desiredWorkflows,
      delivery,
      configuredTools
    );
    const toolsToUpdateSet = new Set<string>([
      ...toolsNeedingVersionUpdate,
      ...toolsNeedingConfigSync,
    ]);
    const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId));

    if (!this.force && toolsToUpdateSet.size === 0 && newlyConfiguredTools.length === 0) {
      if (deferredGlobalCleanup) {
        await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
      }
      // All tools are up to date
      this.displayUpToDateMessage(toolStatuses);

      // Still check for new tool directories and extra workflows
      this.detectNewTools(resolvedProjectPath, configuredTools);
      this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
      this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
      this.displaySetupNotes(configuredTools);
      return;
    }

    // 8. Display update plan
    if (this.force) {
      console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
    } else if (toolsToUpdateSet.size === 0) {
      console.log('No additional refresh needed after legacy migration.');
    } else {
      this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate);
    }
    console.log();

    // 9. Determine what to generate based on delivery
    const deliveryIncludesCommands = delivery !== 'skills';
    // 10. Update tools (all if force, otherwise only those needing update)
    const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet];
    const updatedTools: string[] = [];
    const failedTools: Array<{ name: string; error: string }> = [];
    const skillsInvocableCommandSkips: string[] = [];
    let removedCommandCount = 0;
    let removedSkillCount = 0;
    let removedDeselectedCommandCount = 0;
    let removedDeselectedSkillCount = 0;

    for (const toolId of toolsToUpdate) {
      const tool = AI_TOOLS.find((t) => t.value === toolId);
      if (!tool?.skillsDir) continue;

      const spinner = ora(`Updating ${tool.name}...`).start();

      try {
        const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
        const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
        const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
        const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows;
        const skillTemplates = getSkillTemplates(toolWorkflows);
        const commandContents = getCommandContents(toolWorkflows);

        // Generate skill files if delivery includes skills
        if (shouldGenerateSkills) {
          for (const { template, dirName } of skillTemplates) {
            const skillDir = path.join(skillsDir, dirName);
            const skillFile = path.join(skillDir, 'SKILL.md');

            const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value));
            const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
            await FileSystemUtils.writeFile(skillFile, skillContent);
          }

          removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, toolWorkflows);
        }

        // Delete skill directories if delivery is commands-only
        if (shouldRemoveSkillsForTool(tool.value, delivery)) {
          removedSkillCount += await this.removeSkillDirs(skillsDir);
        }

        // Generate commands if delivery includes commands
        if (shouldGenerateCommands) {
          const adapter = CommandAdapterRegistry.get(tool.value);
          if (adapter) {
            const generatedCommands = generateCommands(commandContents, adapter);

            for (const cmd of generatedCommands) {
              const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
              await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
            }

            removedDeselectedCommandCount += await this.removeUnselectedCommandFiles(
              resolvedProjectPath,
              toolId,
              toolWorkflows
            );
          }
        } else if (deliveryIncludesCommands && resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') {
          skillsInvocableCommandSkips.push(tool.value);
        }

        // Delete command files if delivery is skills-only
        if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
          removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId);
        }

        spinner.succeed(`Updated ${tool.name}`);
        updatedTools.push(tool.name);
      } catch (error) {
        spinner.fail(`Failed to update ${tool.name}`);
        failedTools.push({
          name: tool.name,
          error: error instanceof Error ? error.message : String(error)
        });
      }
    }

    if (deferredGlobalCleanup) {
      await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup);
    }

    // 11. Summary
    console.log();
    if (updatedTools.length > 0) {
      console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
    }
    if (failedTools.length > 0) {
      console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
    }
    if (skillsInvocableCommandSkips.length > 0) {
      console.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`));
    }
    if (removedCommandCount > 0) {
      console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`));
    }
    if (removedSkillCount > 0) {
      console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`));
    }
    if (removedDeselectedCommandCount > 0) {
      console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`));
    }
    if (removedDeselectedSkillCount > 0) {
      console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`));
    }

    // 12. Show onboarding message for newly configured tools from legacy upgrade.
    // Command tools keep the shared /opsx:* form, skill-only tools get their
    // documented skill invocation, and disagreements (or skills-invocable
    // codex, which has no slash surface) fall back to naming the skill.
    if (newlyConfiguredTools.length > 0) {
      const referenceFor = (command: string): string => {
        const neutralForm = `the ${transformToSkillReferences(command).slice(1)} skill`;
        const forms = new Set(
          newlyConfiguredTools.map((toolId) => {
            if (shouldGenerateCommandsForTool(toolId, delivery)) {
              return command;
            }
            if (resolveCommandSurfaceCapability(toolId) === 'skills-invocable') {
              return neutralForm;
            }
            return getSkillReferenceTransformer(toolId)(command);
          })
        );
        return forms.size === 1 ? [...forms][0] : neutralForm;
      };
      // Only hint at workflows these tools actually received. A legacy upgrade
      // can install a narrower set than the profile (inferred Codex prompts).
      const installedWorkflows = [
        ...new Set(
          newlyConfiguredTools.flatMap(
            (toolId) => legacyWorkflowOverrides[toolId] ?? desiredWorkflows
          )
        ),
      ];
      const entries: Array<[string, string]> = getOnboardingCommands(installedWorkflows).map(
        ({ command, description }) => [referenceFor(command), description]
      );
      console.log();
      if (entries.length > 0) {
        const width = Math.max(...entries.map(([reference]) => reference.length));
        console.log(chalk.bold('Getting started:'));
        for (const [reference, description] of entries) {
          console.log(`  ${reference.padEnd(width)}  ${description}`);
        }
        console.log();
      }
      console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
    }

    const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])];

    // 13. Detect new tool directories not currently configured
    this.detectNewTools(resolvedProjectPath, configuredAndNewTools);

    // 14. Display note about extra workflows not in profile
    this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
    this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows);
    this.displaySetupNotes(configuredAndNewTools);

    // 15. List affected tools
    if (updatedTools.length > 0) {
      const toolDisplayNames = updatedTools;
      console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`));
    }

    console.log();
    console.log(chalk.dim('Restart your IDE for changes to take effect.'));
  }

  /**
   * Display message when all tools are up to date.
   */
  private displayUpToDateMessage(toolStatuses: ToolVersionStatus[]): void {
    const toolNames = toolStatuses.map((s) => s.toolId);
    console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));
    console.log(chalk.dim(`  Tools: ${toolNames.join(', ')}`));
    console.log();
    console.log(chalk.dim('Use --force to refresh files anyway.'));
  }

  /**
   * Display the update plan showing which tools need updating.
   */
  private displayUpdatePlan(
    toolsToUpdate: string[],
    statusByTool: Map<string, ToolVersionStatus>,
    upToDate: ToolVersionStatus[]
  ): void {
    const updates = toolsToUpdate.map((toolId) => {
      const status = statusByTool.get(toolId);
      if (status?.needsUpdate) {
        const fromVersion = status.generatedByVersion ?? 'unknown';
        return `${status.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`;
      }
      return `${toolId} (config sync)`;
    });

    console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`);

    if (upToDate.length > 0) {
      const upToDateNames = upToDate.map((s) => s.toolId);
      console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
    }
  }

  /**
   * Shows manual setup notes for configured tools that need extra
   * configuration before they pick up generated files.
   */
  private displaySetupNotes(toolIds: string[]): void {
    for (const toolId of toolIds) {
      const tool = AI_TOOLS.find((t) => t.value === toolId);
      if (tool?.setupNote) {
        console.log(chalk.yellow(`Setup required for ${tool.name}: ${tool.setupNote}`));
      }
    }
  }

  /**
   * Detects new tool directories that aren't currently configured and displays a hint.
   */
  private detectNewTools(projectPath: string, configuredTools: string[]): void {
    const availableTools = getAvailableTools(projectPath);
    const configuredSet = new Set(configuredTools);

    const newTools = availableTools.filter((t) => !configuredSet.has(t.value));

    if (newTools.length > 0) {
      const newToolNames = newTools.map((tool) => tool.name);
      const isSingleTool = newToolNames.length === 1;
      const toolNoun = isSingleTool ? 'tool' : 'tools';
      const pronoun = isSingleTool ? 'it' : 'them';
      console.log();
      console.log(
        chalk.yellow(
          `Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.`
        )
      );
    }
  }

  /**
   * Displays a note about extra workflows installed that aren't in the current profile.
   */
  private displayExtraWorkflowsNote(
    projectPath: string,
    configuredTools: string[],
    profileWorkflows: readonly string[]
  ): void {
    const installedWorkflows = scanInstalledWorkflows(projectPath, configuredTools);
    const profileSet = new Set(profileWorkflows);
    const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w));

    if (extraWorkflows.length > 0) {
      console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`));
    }
  }

  /**
   * Point out core workflows a custom profile is missing, so releases that
   * grow CORE_WORKFLOWS stay discoverable. Keep custom profiles user-owned;
   * do not mutate them.
   */
  private displayMissingCoreWorkflowsNote(profile: Profile, workflows?: readonly string[]): void {
    if (profile !== 'custom' || !workflows) {
      return;
    }

    const workflowSet = new Set(workflows);
    const missing = CORE_WORKFLOWS.filter((workflow) => !workflowSet.has(workflow));

    if (missing.length === 0) {
      return;
    }

    const label = missing.length === 1 ? 'workflow' : 'workflows';
    const pronoun = missing.length === 1 ? 'it' : 'them';
    console.log(chalk.dim(`Note: Your custom profile is missing ${missing.length} core ${label}: ${missing.join(', ')}`));
    console.log(chalk.dim(`Run \`openspec config profile\` to add ${pronoun}, or \`openspec config profile core\` to use the core set.`));
  }

  /**
   * Removes skill directories for workflows when delivery changed to commands-only.
   * Returns the number of directories removed.
   */
  private async removeSkillDirs(skillsDir: string): Promise<number> {
    let removed = 0;

    for (const workflow of ALL_WORKFLOWS) {
      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
      if (!dirName) continue;

      const skillDir = path.join(skillsDir, dirName);
      try {
        if (fs.existsSync(skillDir)) {
          await fs.promises.rm(skillDir, { recursive: true, force: true });
          removed++;
        }
      } catch {
        // Ignore errors
      }
    }

    return removed;
  }

  /**
   * Removes skill directories for workflows that are no longer selected in the active profile.
   * Returns the number of directories removed.
   */
  private async removeUnselectedSkillDirs(
    skillsDir: string,
    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
  ): Promise<number> {
    const desiredSet = new Set(desiredWorkflows);
    let removed = 0;

    for (const workflow of ALL_WORKFLOWS) {
      if (desiredSet.has(workflow)) continue;
      const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
      if (!dirName) continue;

      const skillDir = path.join(skillsDir, dirName);
      try {
        if (fs.existsSync(skillDir)) {
          await fs.promises.rm(skillDir, { recursive: true, force: true });
          removed++;
        }
      } catch {
        // Ignore errors
      }
    }

    return removed;
  }

  /**
   * Removes command files for workflows when delivery changed to skills-only.
   * Returns the number of files removed.
   */
  private async removeCommandFiles(
    projectPath: string,
    toolId: string,
  ): Promise<number> {
    let removed = 0;

    const adapter = CommandAdapterRegistry.get(toolId);
    if (!adapter) return 0;

    for (const workflow of ALL_WORKFLOWS) {
      const cmdPath = adapter.getFilePath(workflow);
      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);

      try {
        if (fs.existsSync(fullPath)) {
          await fs.promises.unlink(fullPath);
          removed++;
        }
      } catch {
        // Ignore errors
      }
    }

    return removed;
  }

  /**
   * Removes command files for workflows that are no longer selected in the active profile.
   * Returns the number of files removed.
   */
  private async removeUnselectedCommandFiles(
    projectPath: string,
    toolId: string,
    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][]
  ): Promise<number> {
    let removed = 0;

    const adapter = CommandAdapterRegistry.get(toolId);
    if (!adapter) return 0;

    const desiredSet = new Set(desiredWorkflows);

    for (const workflow of ALL_WORKFLOWS) {
      if (desiredSet.has(workflow)) continue;
      const cmdPath = adapter.getFilePath(workflow);
      const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);

      try {
        if (fs.existsSync(fullPath)) {
          await fs.promises.unlink(fullPath);
          removed++;
        }
      } catch {
        // Ignore errors
      }
    }

    return removed;
  }

  /**
   * Detect and handle legacy OpenSpec artifacts.
   * Unlike init, update warns but continues if legacy files found in non-interactive mode.
   * Returns array of tool IDs that were newly configured during legacy upgrade.
   */
  private async handleLegacyCleanup(
    projectPath: string,
    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][],
    delivery: Delivery
  ): Promise<LegacyUpgradeResult> {
    // Detect legacy artifacts
    const detection = await detectLegacyArtifacts(projectPath);

    if (!detection.hasLegacyArtifacts) {
      return { newlyConfiguredTools: [], workflowOverrides: {} }; // No legacy artifacts found
    }

    // Show what was detected
    const immediateSummary = formatDetectionSummary(omitGlobalLegacyPromptFiles(detection));
    const deferredSummary = formatDeferredGlobalPromptSummary(detection);
    if (immediateSummary || deferredSummary) {
      console.log();
      if (immediateSummary) {
        console.log(immediateSummary);
        console.log();
      }
      if (deferredSummary) {
        console.log(deferredSummary);
        console.log();
      }
    }

    const canPrompt = isInteractive();

    if (this.force) {
      const legacyUpgrade = await this.upgradeLegacyTools(
        projectPath,
        detection,
        canPrompt,
        desiredWorkflows,
        delivery
      );
      await this.performImmediateLegacyCleanup(projectPath, detection);
      return {
        ...legacyUpgrade,
        deferredGlobalCleanup: pickGlobalLegacyPromptFiles(
          detection,
          detection.globalSlashCommandFiles
        ),
      };
    }

    if (!canPrompt) {
      // Non-interactive mode without --force: warn and continue
      // (Unlike init, update doesn't abort - user may just want to update skills)
      console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));
      console.log();
      return { newlyConfiguredTools: [], workflowOverrides: {} };
    }

    // Interactive mode: prompt for confirmation
    const { confirm } = await import('@inquirer/prompts');
    const shouldCleanup = await confirm({
      message: 'Upgrade and clean up legacy files?',
      default: true,
    });

    if (shouldCleanup) {
      const legacyUpgrade = await this.upgradeLegacyTools(
        projectPath,
        detection,
        canPrompt,
        desiredWorkflows,
        delivery
      );
      await this.performImmediateLegacyCleanup(projectPath, detection);
      return {
        ...legacyUpgrade,
        deferredGlobalCleanup: pickGlobalLegacyPromptFiles(
          detection,
          detection.globalSlashCommandFiles
        ),
      };
    } else {
      console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
      console.log();
      return { newlyConfiguredTools: [], workflowOverrides: {} };
    }
  }

  /**
   * Cleans approved repo-local legacy artifacts before configured tools refresh.
   */
  private async performImmediateLegacyCleanup(
    projectPath: string,
    detection: LegacyDetectionResult
  ): Promise<void> {
    const immediateDetection = omitGlobalLegacyPromptFiles(detection);
    if (immediateDetection.hasLegacyArtifacts) {
      await this.performLegacyCleanup(projectPath, immediateDetection);
    }
  }

  /**
   * Cleans approved global Codex prompts after configured tools refresh so newly
   * installed replacement skills can retire their prompts in the same run.
   */
  private async performDeferredGlobalPromptCleanup(
    projectPath: string,
    detection: LegacyDetectionResult
  ): Promise<void> {
    const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex']));
    const removableMatches = getLegacyGlobalPromptMatches(detection)
      .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId)));

    if (removableMatches.length > 0) {
      await this.performLegacyCleanup(
        projectPath,
        pickGlobalLegacyPromptFiles(
          detection,
          removableMatches.map((prompt) => prompt.path)
        )
      );
    }

    const blockedMatches = getLegacyGlobalPromptMatches(detection)
      .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path));

    if (blockedMatches.length > 0) {
      console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:'));
      for (const prompt of blockedMatches) {
        console.log(chalk.dim(`  - ${prompt.toolId}: ${prompt.path}`));
      }
      console.log();
    }
  }

  /**
   * Perform cleanup of legacy artifacts.
   */
  private async performLegacyCleanup(projectPath: string, detection: LegacyDetectionResult): Promise<void> {
    const spinner = ora('Cleaning up legacy files...').start();

    const result = await cleanupLegacyArtifacts(projectPath, detection);

    spinner.succeed('Legacy files cleaned up');

    const summary = formatCleanupSummary(result);
    if (summary) {
      console.log();
      console.log(summary);
    }

    console.log();
  }

  /**
   * Upgrades unconfigured legacy tools into the skills-based setup and carries
   * workflow overrides for migrations that should mirror legacy Codex prompts.
   */
  private async upgradeLegacyTools(
    projectPath: string,
    detection: LegacyDetectionResult,
    canPrompt: boolean,
    desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][],
    delivery: Delivery
  ): Promise<LegacyUpgradeResult> {
    // Get tools that had legacy artifacts
    const legacyTools = getToolsFromLegacyArtifacts(detection);

    if (legacyTools.length === 0) {
      return { newlyConfiguredTools: [], workflowOverrides: {} };
    }

    // Get currently configured tools
    const configuredTools = getConfiguredToolsForProfileSync(projectPath);
    const configuredSet = new Set(configuredTools);

    // Filter to tools that aren't already configured
    const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));

    if (unconfiguredLegacyTools.length === 0) {
      return { newlyConfiguredTools: [], workflowOverrides: {} };
    }

    // Get valid tools (those with skillsDir)
    const validToolIds = new Set(getToolsWithSkillsDir());
    const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t));

    if (validUnconfiguredTools.length === 0) {
      return { newlyConfiguredTools: [], workflowOverrides: {} };
    }

    // Show what tools were detected from legacy artifacts
    console.log(chalk.bold('Tools detected from legacy artifacts:'));
    for (const toolId of validUnconfiguredTools) {
      const tool = AI_TOOLS.find((t) => t.value === toolId);
      console.log(`  • ${tool?.name || toolId}`);
    }
    console.log();

    let selectedTools: string[];

    if (this.force || !canPrompt) {
      // Non-interactive with --force: auto-select detected tools
      selectedTools = validUnconfiguredTools;
      console.log(`Setting up skills for: ${selectedTools.join(', ')}`);
    } else {
      // Interactive mode: prompt for tool selection with detected tools pre-selected
      const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');

      const sortedChoices = validUnconfiguredTools.map((toolId) => {
        const tool = AI_TOOLS.find((t) => t.value === toolId);
        return {
          name: tool?.name || toolId,
          value: toolId,
          configured: false,
          preSelected: true, // Pre-select all detected legacy tools
        };
      });

      selectedTools = await searchableMultiSelect({
        message: 'Select tools to set up with the new skill system:',
        pageSize: 15,
        choices: sortedChoices,
        validate: (_selected: string[]) => true, // Allow empty selection (user can skip)
      });

      if (selectedTools.length === 0) {
        console.log(chalk.dim('Skipping tool setup.'));
        console.log();
        return { newlyConfiguredTools: [], workflowOverrides: {} };
      }
    }

    const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex');

    // Create skills/commands for selected tools using effective profile+delivery.
    const newlyConfigured: string[] = [];
    const workflowOverrides: LegacyUpgradeResult['workflowOverrides'] = {};

    for (const toolId of selectedTools) {
      const tool = AI_TOOLS.find((t) => t.value === toolId);
      if (!tool?.skillsDir) continue;

      const spinner = ora(`Setting up ${tool.name}...`).start();

      try {
        const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
        const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
        const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
        const toolWorkflows = (
          tool.value === 'codex' && inferredCodexWorkflows.length > 0
            ? inferredCodexWorkflows
            : desiredWorkflows
        );
        if (tool.value === 'codex' && inferredCodexWorkflows.length > 0) {
          workflowOverrides[tool.value] = inferredCodexWorkflows;
        }
        const skillTemplates = getSkillTemplates(toolWorkflows);
        const commandContents = getCommandContents(toolWorkflows);

        // Create skill files when delivery includes skills
        if (shouldGenerateSkills) {
          for (const { template, dirName } of skillTemplates) {
            const skillDir = path.join(skillsDir, dirName);
            const skillFile = path.join(skillDir, 'SKILL.md');

            const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value));
            const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
            await FileSystemUtils.writeFile(skillFile, skillContent);
          }
        }

        // Create commands when delivery includes commands
        if (shouldGenerateCommands) {
          const adapter = CommandAdapterRegistry.get(tool.value);
          if (adapter) {
            const generatedCommands = generateCommands(commandContents, adapter);

            for (const cmd of generatedCommands) {
              const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
              await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
            }
          }
        }

        spinner.succeed(`Setup complete for ${tool.name}`);
        newlyConfigured.push(toolId);
      } catch (error) {
        spinner.fail(`Failed to set up ${tool.name}`);
        console.log(chalk.red(`  ${error instanceof Error ? error.message : String(error)}`));
      }
    }

    if (newlyConfigured.length > 0) {
      console.log();
    }

    return { newlyConfiguredTools: newlyConfigured, workflowOverrides };
  }
}


# Slack Integration

Steps to create and configure a Slack bot:

1. Create a Slack App:
    *   Go to the Slack API: https://api.slack.com/apps
    *   Click on "Create New App".
    *   Choose "From scratch" and give your app a name and select the workspace.
    *   Provide a name and description for your bot (these are required fields).
2. Configure the Bot:
    *   Navigate to the "OAuth & Permissions" tab on the left side of the screen.
    *   Under "Scopes", add the necessary bot token scopes (add these "chat:write", "channels:history", "im:history").
3. Enable Event Subscriptions:
    *   Navigate to the "Event Subscriptions" tab.
    *   Enable events and add the necessary bot events (add these "message.channels", "message.im").
    *   Add your request URL (you can use ngrok to expose your local server if needed). [See how to set up ngrok](#installing-and-starting-ngrok).
    *   **Note:** The URL provided by ngrok is ephemeral and will change each time ngrok is started. You will need to update the request URL in the bot's settings each time you restart ngrok. [See how to update the request URL](#updating-the-request-url-in-bots-settings).
4. Add the bot to your Slack workspace:
    *   Navigate to the "OAuth & Permissions" tab.
    *   Under "OAuth Tokens for Your Workspace", click on "Install App to Workspace".
    *   Follow the prompts to authorize the app and add it to your workspace.
5. Set up environment variables:
    *   Obtain the `SLACK_SIGNING_SECRET`:
        *   Go to the Slack API: https://api.slack.com/apps
        *   Select your app.
        *   Navigate to the "Basic Information" tab.
        *   Copy the "Signing Secret".
    *   Obtain the `SLACK_BOT_TOKEN`:
        *   Go to the Slack API: https://api.slack.com/apps
        *   Select your app.
        *   Navigate to the "OAuth & Permissions" tab.
        *   Copy the "Bot User OAuth Token".
    *   Create a `.env` file in the root directory of your project and add the following lines:
        ```env
        SLACK_SIGNING_SECRET=your-signing-secret
        SLACK_BOT_TOKEN=your-bot-token
        ```
6. Invite the bot to a channel:
    *   Use the `/invite @your-bot-name` command in the Slack channel where you want the bot to be active.
7. Run the code in `examples/slack_example.py` to start the bot with your bot token and signing secret.
8. Write e.g. "$bu what's the weather in Tokyo?" to start a browser-use task and get a response inside the Slack channel.

## Installing and Starting ngrok

To expose your local server to the internet, you can use ngrok. Follow these steps to install and start ngrok:

1. Download ngrok from the official website: https://ngrok.com/download
2. Create a free account and follow the official steps to install ngrok.
3. Start ngrok by running the following command in your terminal:
    ```sh
    ngrok http 3000
    ```
    Replace `3000` with the port number your local server is running on.

## Updating the Request URL in Bot's Settings

If you need to update the request URL (e.g., when the ngrok URL changes), follow these steps:

1. Go to the Slack API: https://api.slack.com/apps
2. Select your app.
3. Navigate to the "Event Subscriptions" tab.
4. Update the "Request URL" field with the new ngrok URL. The URL should be something like: `https://<ngrok-id>.ngrok-free.app/slack/events`
5. Save the changes.

## Installing Required Packages

To run this example, you need to install the following packages:

- `fastapi`
- `uvicorn`
- `slack_sdk`

You can install these packages using pip:

```sh
pip install fastapi uvicorn slack_sdk


-- Comprehensive results schema for vec0 KNN benchmark runs.
-- Created in WAL mode: PRAGMA journal_mode=WAL

CREATE TABLE IF NOT EXISTS runs (
  run_id          INTEGER PRIMARY KEY AUTOINCREMENT,
  config_name     TEXT    NOT NULL,
  index_type      TEXT    NOT NULL,
  params          TEXT    NOT NULL,   -- JSON: {"R":48,"L":128,"quantizer":"binary"}
  dataset         TEXT    NOT NULL,   -- "cohere1m"
  subset_size     INTEGER NOT NULL,
  k               INTEGER NOT NULL,
  n_queries       INTEGER NOT NULL,
  phase           TEXT    NOT NULL DEFAULT 'both',
    -- 'build', 'query', or 'both'
  status          TEXT    NOT NULL DEFAULT 'pending',
    -- pending → inserting → training → querying → done | built | error
  created_at_ns   INTEGER NOT NULL    -- time.time_ns()
);

CREATE TABLE IF NOT EXISTS run_results (
  run_id              INTEGER PRIMARY KEY REFERENCES runs(run_id),
  insert_started_ns   INTEGER,
  insert_ended_ns     INTEGER,
  insert_duration_ns  INTEGER,
  train_started_ns    INTEGER,       -- NULL if no training
  train_ended_ns      INTEGER,
  train_duration_ns   INTEGER,
  build_duration_ns   INTEGER,       -- insert + train
  db_file_size_bytes  INTEGER,
  db_file_path        TEXT,
  create_sql          TEXT,           -- CREATE VIRTUAL TABLE ...
  insert_sql          TEXT,           -- INSERT INTO vec_items ...
  train_sql           TEXT,           -- NULL if no training step
  query_sql           TEXT,           -- SELECT ... WHERE embedding MATCH ...
  k                   INTEGER,       -- denormalized from runs for easy filtering
  query_mean_ms       REAL,          -- denormalized aggregates
  query_median_ms     REAL,
  query_p99_ms        REAL,
  query_total_ms      REAL,
  qps                 REAL,
  recall              REAL
);

CREATE TABLE IF NOT EXISTS insert_batches (
  batch_id        INTEGER PRIMARY KEY AUTOINCREMENT,
  run_id          INTEGER NOT NULL REFERENCES runs(run_id),
  batch_lo        INTEGER NOT NULL,   -- start index (inclusive)
  batch_hi        INTEGER NOT NULL,   -- end index (exclusive)
  rows_in_batch   INTEGER NOT NULL,
  started_ns      INTEGER NOT NULL,
  ended_ns        INTEGER NOT NULL,
  duration_ns     INTEGER NOT NULL,
  cumulative_rows INTEGER NOT NULL,   -- total rows inserted so far
  rate_rows_per_s REAL    NOT NULL    -- cumulative rate
);

CREATE TABLE IF NOT EXISTS queries (
  query_id          INTEGER PRIMARY KEY AUTOINCREMENT,
  run_id            INTEGER NOT NULL REFERENCES runs(run_id),
  k                 INTEGER NOT NULL,
  query_vector_id   INTEGER NOT NULL,
  started_ns        INTEGER NOT NULL,
  ended_ns          INTEGER NOT NULL,
  duration_ms       REAL    NOT NULL,
  result_ids        TEXT    NOT NULL,  -- JSON array
  result_distances  TEXT    NOT NULL,  -- JSON array
  ground_truth_ids  TEXT    NOT NULL,  -- JSON array
  recall            REAL    NOT NULL,
  UNIQUE(run_id, k, query_vector_id)
);

CREATE INDEX IF NOT EXISTS idx_runs_config  ON runs(config_name);
CREATE INDEX IF NOT EXISTS idx_runs_type    ON runs(index_type);
CREATE INDEX IF NOT EXISTS idx_runs_status  ON runs(status);
CREATE INDEX IF NOT EXISTS idx_batches_run  ON insert_batches(run_id);
CREATE INDEX IF NOT EXISTS idx_queries_run  ON queries(run_id);


use std::env::consts::EXE_EXTENSION;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::LazyLock;
use std::time::Duration;

use anyhow::{Context, Result, bail};
use http::header::ACCEPT;
use semver::{Version, VersionReq};
use target_lexicon::{Architecture, ArmArchitecture, Environment, HOST, OperatingSystem};
use tokio::task::JoinSet;
use tracing::{debug, trace, warn};

use prek_consts::env_vars::{EnvVars, EnvVarsRead};

use crate::archive;
use crate::fs::LockedFile;
use crate::http::{DownloadChecksumPolicy, REQWEST_CLIENT, download_artifact_with};
use crate::process::Cmd;
use crate::store::{CacheBucket, Store};
use crate::version;
use crate::warn_user;

// The version range of `uv` we will install. Should update periodically.
const CUR_UV_VERSION: &str = "0.11.28";
static UV_VERSION_RANGE: LazyLock<VersionReq> =
    LazyLock::new(|| VersionReq::parse(">=0.7.0").unwrap());

fn wheel_platform_tag_for_host(
    operating_system: OperatingSystem,
    architecture: Architecture,
    environment: Environment,
) -> Result<&'static str> {
    let platform_tag = match (operating_system, architecture, environment) {
        // Linux platforms
        (OperatingSystem::Linux, Architecture::X86_64, Environment::Musl) => "musllinux_1_1_x86_64",
        (OperatingSystem::Linux, Architecture::X86_64, _) => {
            "manylinux_2_17_x86_64.manylinux2014_x86_64"
        }
        (OperatingSystem::Linux, Architecture::Aarch64(_), _) => {
            "manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64"
        }
        (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), Environment::Musl) => {
            "manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l"
        }
        (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7), _) => {
            "manylinux_2_17_armv7l.manylinux2014_armv7l"
        }
        (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv6), _) => "linux_armv6l", // Raspberry Pi Zero/1
        (OperatingSystem::Linux, Architecture::X86_32(_), Environment::Musl) => {
            "musllinux_1_1_i686"
        }
        (OperatingSystem::Linux, Architecture::X86_32(_), _) => {
            "manylinux_2_17_i686.manylinux2014_i686"
        }
        (OperatingSystem::Linux, Architecture::Powerpc64, _) => {
            "manylinux_2_17_ppc64.manylinux2014_ppc64"
        }
        (OperatingSystem::Linux, Architecture::Powerpc64le, _) => {
            "manylinux_2_17_ppc64le.manylinux2014_ppc64le"
        }
        (OperatingSystem::Linux, Architecture::S390x, _) => {
            "manylinux_2_17_s390x.manylinux2014_s390x"
        }
        (OperatingSystem::Linux, Architecture::Riscv64(_), _) => "manylinux_2_31_riscv64",

        // macOS platforms
        (OperatingSystem::Darwin(_), Architecture::X86_64, _) => "macosx_10_12_x86_64",
        (OperatingSystem::Darwin(_), Architecture::Aarch64(_), _) => "macosx_11_0_arm64",

        // Windows platforms
        (OperatingSystem::Windows, Architecture::X86_64, _) => "win_amd64",
        (OperatingSystem::Windows, Architecture::X86_32(_), _) => "win32",
        (OperatingSystem::Windows, Architecture::Aarch64(_), _) => "win_arm64",

        _ => bail!(
            "Unsupported platform: operating_system={operating_system:?}, architecture={architecture:?}, environment={environment:?}"
        ),
    };

    Ok(platform_tag)
}

// Get the uv wheel platform tag for the current host.
fn get_wheel_platform_tag() -> Result<String> {
    wheel_platform_tag_for_host(HOST.operating_system, HOST.architecture, HOST.environment)
        .map(ToString::to_string)
}

fn get_uv_version(uv_path: &Path) -> Result<Version> {
    let output = Command::new(uv_path)
        .arg("--version")
        .output()
        .context("Failed to execute uv")?;

    if !output.status.success() {
        bail!("Failed to get uv version");
    }

    let version_output = String::from_utf8_lossy(&output.stdout);
    let version_str = version_output
        .split_whitespace()
        .nth(1)
        .context("Invalid version output format")?;

    Version::parse(version_str).map_err(Into::into)
}

fn validate_uv_binary(uv_path: &Path) -> Result<Version> {
    let version = get_uv_version(uv_path)?;
    if !UV_VERSION_RANGE.matches(&version) {
        bail!(
            "uv version `{version}` does not satisfy required range `{}`",
            *UV_VERSION_RANGE
        );
    }
    Ok(version)
}

async fn replace_uv_binary(source: &Path, target_path: &Path) -> Result<()> {
    if let Some(parent) = target_path.parent() {
        fs_err::tokio::create_dir_all(parent).await?;
    }

    if target_path.exists() {
        debug!(target = %target_path.display(), "Removing existing uv binary");
        fs_err::tokio::remove_file(target_path).await?;
    }

    fs_err::tokio::rename(source, target_path).await?;
    Ok(())
}

static UV_EXE: LazyLock<Option<(PathBuf, Version)>> = LazyLock::new(|| {
    for uv_path in which::which_all("uv").ok()? {
        debug!("Found uv in PATH: {}", uv_path.display());

        match validate_uv_binary(&uv_path) {
            Ok(version) => return Some((uv_path, version)),
            Err(err) => warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible uv"),
        }
    }

    None
});

#[derive(Debug, PartialEq, Eq)]
enum PyPiMirror {
    Pypi,
    Tuna,
    Aliyun,
    Tencent,
    Custom(String),
}

// TODO: support reading pypi source user config, or allow user to set mirror
// TODO: allow opt-out uv

impl PyPiMirror {
    fn url(&self) -> &str {
        match self {
            Self::Pypi => "https://pypi.org/simple/",
            Self::Tuna => "https://pypi.tuna.tsinghua.edu.cn/simple/",
            Self::Aliyun => "https://mirrors.aliyun.com/pypi/simple/",
            Self::Tencent => "https://mirrors.cloud.tencent.com/pypi/simple/",
            Self::Custom(url) => url,
        }
    }

    fn iter() -> impl Iterator<Item = Self> {
        vec![Self::Pypi, Self::Tuna, Self::Aliyun, Self::Tencent].into_iter()
    }
}

#[derive(Debug, PartialEq, Eq)]
enum InstallSource {
    /// Download uv from GitHub releases.
    GitHub,
    /// Download uv from `PyPi`.
    PyPi(PyPiMirror),
    /// Install uv by running `pip install uv`.
    Pip,
}

impl InstallSource {
    async fn install(&self, store: &Store, target: &Path) -> Result<()> {
        match self {
            Self::GitHub => self.install_from_github(store, target).await,
            Self::PyPi(source) => self.install_from_pypi(store, target, source).await,
            Self::Pip => self.install_from_pip(target).await,
        }
    }

    async fn install_from_github(&self, store: &Store, target: &Path) -> Result<()> {
        let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
        let archive_name = format!("uv-{HOST}.{ext}");
        let download_url = format!(
            "https://github.com/astral-sh/uv/releases/download/{CUR_UV_VERSION}/{archive_name}"
        );

        let download = download_artifact_with(
            &download_url,
            &archive_name,
            store,
            DownloadChecksumPolicy::Disabled,
            async || Ok(None),
            |req| req,
        )
        .await
        .context("Failed to download uv")?;
        let extracted = archive::extract_archive(download.path())
            .await
            .context("Failed to extract uv")?;
        let source = extracted.join("uv").with_extension(EXE_EXTENSION);
        let target_path = target.join("uv").with_extension(EXE_EXTENSION);

        debug!(?source, target = %target_path.display(), "Moving uv to target");
        // TODO: retry on Windows
        replace_uv_binary(&source, &target_path).await?;

        Ok(())
    }

    async fn install_from_pypi(
        &self,
        store: &Store,
        target: &Path,
        source: &PyPiMirror,
    ) -> Result<()> {
        let platform_tag = get_wheel_platform_tag()?;
        let wheel_name = format!("uv-{CUR_UV_VERSION}-py3-none-{platform_tag}.whl");

        // Use PyPI JSON API instead of parsing HTML
        let api_url = match source {
            PyPiMirror::Pypi => format!("https://pypi.org/pypi/uv/{CUR_UV_VERSION}/json"),
            // For mirrors, we'll fall back to simple API approach
            _ => return self.install_from_simple_api(store, target, source).await,
        };

        debug!("Fetching uv metadata from: {}", api_url);
        let response = REQWEST_CLIENT
            .get(&api_url)
            .header("Accept", "*/*")
            .send()
            .await
            .and_then(reqwest::Response::error_for_status)
            .with_context(|| format!("Failed to fetch uv metadata from PyPI at {api_url}"))?;

        let metadata: serde_json::Value = response.json().await?;
        let files = metadata["urls"]
            .as_array()
            .context("Invalid PyPI response: missing urls")?;

        let wheel_file = files
            .iter()
            .find(|file| {
                file["filename"].as_str() == Some(&wheel_name)
                    && file["packagetype"].as_str() == Some("bdist_wheel")
                    && file["yanked"].as_bool() != Some(true)
            })
            .with_context(|| format!("Could not find wheel for {wheel_name} in PyPI response"))?;

        let download_url = wheel_file["url"]
            .as_str()
            .context("Missing download URL in PyPI response")?;

        self.install_from_wheel_url(store, target, &wheel_name, download_url)
            .await
    }

    async fn install_from_simple_api(
        &self,
        store: &Store,
        target: &Path,
        source: &PyPiMirror,
    ) -> Result<()> {
        // Fallback for mirrors that don't support JSON API
        let platform_tag = get_wheel_platform_tag()?;
        let wheel_name = format!("uv-{CUR_UV_VERSION}-py3-none-{platform_tag}.whl");

        let simple_url = format!("{}uv/", source.url());

        debug!("Fetching from simple API: {}", simple_url);
        let response = REQWEST_CLIENT
            .get(&simple_url)
            .header(ACCEPT, "*/*")
            .send()
            .await?;
        let html = response.text().await?;

        // Simple string search to find the wheel download link
        let search_pattern = r#"href=""#.to_string();

        let download_path = html
            .lines()
            .find(|line| line.contains(&wheel_name))
            .and_then(|line| {
                if let Some(start) = line.find(&search_pattern) {
                    let start = start + search_pattern.len();
                    if let Some(end) = line[start..].find('"') {
                        return Some(&line[start..start + end]);
                    }
                }
                None
            })
            .with_context(|| {
                format!(
                    "Could not find wheel download link for {wheel_name} in simple API response"
                )
            })?;

        // Resolve relative URLs
        let download_url = if download_path.starts_with("http") {
            download_path.to_string()
        } else {
            format!("{simple_url}{download_path}")
        };

        self.install_from_wheel_url(store, target, &wheel_name, &download_url)
            .await
    }

    async fn install_from_wheel_url(
        &self,
        store: &Store,
        target: &Path,
        filename: &str,
        download_url: &str,
    ) -> Result<()> {
        let download = download_artifact_with(
            download_url,
            filename,
            store,
            DownloadChecksumPolicy::Disabled,
            async || Ok(None),
            |req| req,
        )
        .await
        .context("Failed to download uv wheel")?;
        let extracted = archive::extract_archive(download.path())
            .await
            .context("Failed to extract uv wheel")?;

        // Find the uv binary in the extracted contents
        let data_dir = format!("uv-{CUR_UV_VERSION}.data");
        let extracted_uv = extracted
            .join(data_dir)
            .join("scripts")
            .join("uv")
            .with_extension(EXE_EXTENSION);

        // Copy the binary to the target location
        let target_path = target.join("uv").with_extension(EXE_EXTENSION);

        debug!(?extracted_uv, target = %target_path.display(), "Moving uv to target");
        replace_uv_binary(&extracted_uv, &target_path).await?;

        // Set executable permissions on Unix
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let metadata = fs_err::tokio::metadata(&target_path).await?;
            let mut perms = metadata.permissions();
            perms.set_mode(0o755);
            fs_err::tokio::set_permissions(&target_path, perms).await?;
        }

        Ok(())
    }

    async fn install_from_pip(&self, target: &Path) -> Result<()> {
        // When running `pip install` in multiple threads, it can fail
        // without extracting files properly.
        Cmd::new("python3")
            .arg("-m")
            .arg("pip")
            .arg("install")
            .arg("--prefix")
            .arg(target)
            .arg("--only-binary=:all:")
            .arg("--progress-bar=off")
            .arg("--disable-pip-version-check")
            .arg(format!("uv=={CUR_UV_VERSION}"))
            .check(true)
            .output()
            .await?;

        let local_dir = target.join("local");
        let uv_src = if local_dir.is_dir() {
            &local_dir
        } else {
            target
        };

        let bin_dir = uv_src.join(if cfg!(windows) { "Scripts" } else { "bin" });
        let lib_dir = uv_src.join(if cfg!(windows) { "Lib" } else { "lib" });

        let uv = uv_src
            .join(&bin_dir)
            .join("uv")
            .with_extension(EXE_EXTENSION);
        fs_err::tokio::rename(&uv, target.join("uv").with_extension(EXE_EXTENSION)).await?;
        fs_err::tokio::remove_dir_all(bin_dir).await?;
        fs_err::tokio::remove_dir_all(lib_dir).await?;

        Ok(())
    }
}

pub(crate) struct Uv {
    path: PathBuf,
}

impl Uv {
    pub(crate) fn new(path: PathBuf) -> Self {
        Self { path }
    }

    pub(crate) fn cmd(&self, store: &Store) -> Cmd {
        let mut cmd = Cmd::new(&self.path);
        cmd.env(EnvVars::UV_CACHE_DIR, store.cache_path(CacheBucket::Uv));
        cmd
    }

    async fn select_source() -> Result<InstallSource> {
        async fn check_github() -> Result<bool> {
            let url = format!(
                "https://github.com/astral-sh/uv/releases/download/{CUR_UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz"
            );
            let response = REQWEST_CLIENT
                .head(url)
                .timeout(Duration::from_secs(3))
                .send()
                .await?;
            trace!(?response, "Checked GitHub");
            Ok(response.status().is_success())
        }

        async fn select_best_pypi() -> Result<PyPiMirror> {
            let mut best = PyPiMirror::Pypi;
            let mut tasks = PyPiMirror::iter()
                .map(|source| {
                    let client = REQWEST_CLIENT.clone();
                    async move {
                        let url = format!("{}uv/", source.url());
                        let response = client
                            .head(&url)
                            .header("User-Agent", format!("prek/{}", version::version().version))
                            .header("Accept", "*/*")
                            .timeout(Duration::from_secs(2))
                            .send()
                            .await;
                        (source, response)
                    }
                })
                .collect::<JoinSet<_>>();

            while let Some(result) = tasks.join_next().await {
                if let Ok((source, response)) = result {
                    if let Ok(resp) = response
                        && resp.status().is_success()
                    {
                        best = source;
                        break;
                    }
                }
            }

            Ok(best)
        }

        let source = tokio::select! {
                Ok(true) = check_github() => InstallSource::GitHub,
                Ok(source) = select_best_pypi() => InstallSource::PyPi(source),
                else => {
                    warn!("Failed to check uv source availability, falling back to pip install");
                    InstallSource::Pip
                }

        };

        trace!(?source, "Selected uv source");
        Ok(source)
    }

    pub(crate) async fn install(store: &Store, uv_dir: &Path) -> Result<Self> {
        // 1) Check `uv` alongside `prek` binary (e.g. `uv tool install prek --with uv`)
        let prek_exe = std::env::current_exe()?.canonicalize()?;
        if let Some(prek_dir) = prek_exe.parent() {
            let uv_path = prek_dir.join("uv").with_extension(EXE_EXTENSION);
            if uv_path.is_file() {
                match validate_uv_binary(&uv_path) {
                    Ok(_) => {
                        trace!(uv = %uv_path.display(), "Found compatible uv alongside prek binary");
                        return Ok(Self::new(uv_path));
                    }
                    Err(err) => {
                        warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible uv");
                    }
                }
            }
        }

        // 2) Check if system `uv` meets minimum version requirement
        if let Some((uv_path, version)) = UV_EXE.as_ref() {
            trace!(
                "Using system uv version {} at {}",
                version,
                uv_path.display()
            );
            return Ok(Self::new(uv_path.clone()));
        }

        // 3) Use or install managed `uv`
        let uv_path = uv_dir.join("uv").with_extension(EXE_EXTENSION);

        if uv_path.is_file() {
            match validate_uv_binary(&uv_path) {
                Ok(_) => {
                    trace!(uv = %uv_path.display(), "Found compatible managed uv");
                    return Ok(Self::new(uv_path));
                }
                Err(err) => {
                    warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible managed uv");
                }
            }
        }

        // Install new managed uv with proper locking
        fs_err::tokio::create_dir_all(&uv_dir).await?;
        let _lock = LockedFile::acquire(uv_dir.join(".lock"), "uv").await?;

        if uv_path.is_file() {
            match validate_uv_binary(&uv_path) {
                Ok(_) => {
                    trace!(uv = %uv_path.display(), "Found compatible managed uv");
                    return Ok(Self::new(uv_path));
                }
                Err(err) => {
                    warn!(uv = %uv_path.display(), error = %err, "Skipping incompatible managed uv");
                }
            }
        }

        let source = if let Some(uv_source) = uv_source_from_env(&EnvVars) {
            uv_source
        } else {
            Self::select_source().await?
        };
        source.install(store, uv_dir).await?;

        // Downloaded `uv` binaries can be present on disk but still fail to execute in the
        // current runtime environment, such as when the libc variant or dynamic loader path
        // does not match the host. Validate immediately so we can surface a clear error here.
        match validate_uv_binary(&uv_path) {
            Ok(version) => trace!(version = %version, "Successfully installed uv"),
            Err(err) => bail!(
                "Installed uv at `{}` failed validation: {err}. \
                This usually means the downloaded uv binary is incompatible with the \
                current runtime environment, for example due to a libc mismatch or a \
                missing dynamic loader path. If this keeps happening, please report it \
                with details about your environment and the full error output.",
                uv_path.display()
            ),
        }

        Ok(Self::new(uv_path))
    }
}

fn uv_source_from_env(env_vars: &impl EnvVarsRead) -> Option<InstallSource> {
    let var = env_vars.var(EnvVars::PREK_UV_SOURCE).ok()?;
    match var.as_str() {
        "github" => Some(InstallSource::GitHub),
        "pypi" => Some(InstallSource::PyPi(PyPiMirror::Pypi)),
        "tuna" => Some(InstallSource::PyPi(PyPiMirror::Tuna)),
        "aliyun" => Some(InstallSource::PyPi(PyPiMirror::Aliyun)),
        "tencent" => Some(InstallSource::PyPi(PyPiMirror::Tencent)),
        "pip" => Some(InstallSource::Pip),
        custom if custom.starts_with("http") => Some(InstallSource::PyPi(PyPiMirror::Custom(var))),
        _ => {
            warn_user!(
                "Invalid value for {}: {:?}. Expected github, pypi, tuna, aliyun, tencent, pip, or an http(s) URL; using default ({:?})",
                EnvVars::PREK_UV_SOURCE,
                var,
                "auto",
            );
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ensure_cur_uv_version_in_range() {
        let version = Version::parse(CUR_UV_VERSION).expect("Invalid CUR_UV_VERSION");
        assert!(
            UV_VERSION_RANGE.matches(&version),
            "CUR_UV_VERSION {CUR_UV_VERSION} does not satisfy the version requirement {}",
            *UV_VERSION_RANGE
        );
    }

    #[test]
    fn uv_source_from_env_reads_source_override() {
        assert_eq!(uv_source_from_env(&EnvVars::from_map(&[])), None);
        assert_eq!(
            uv_source_from_env(&EnvVars::from_map(&[(EnvVars::PREK_UV_SOURCE, "github")])),
            Some(InstallSource::GitHub)
        );
        assert_eq!(
            uv_source_from_env(&EnvVars::from_map(&[(EnvVars::PREK_UV_SOURCE, "pypi")])),
            Some(InstallSource::PyPi(PyPiMirror::Pypi))
        );
        assert_eq!(
            uv_source_from_env(&EnvVars::from_map(&[(EnvVars::PREK_UV_SOURCE, "pip")])),
            Some(InstallSource::Pip)
        );
        assert_eq!(
            uv_source_from_env(&EnvVars::from_map(&[(
                EnvVars::PREK_UV_SOURCE,
                "https://example.com/simple",
            )])),
            Some(InstallSource::PyPi(PyPiMirror::Custom(
                "https://example.com/simple".to_string()
            )))
        );
        assert_eq!(
            uv_source_from_env(&EnvVars::from_map(&[(EnvVars::PREK_UV_SOURCE, "unknown")])),
            None
        );
    }

    #[test]
    fn wheel_platform_tag_x86_64_linux_gnu() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::X86_64,
            Environment::Gnu,
        )?;
        assert_eq!(tag, "manylinux_2_17_x86_64.manylinux2014_x86_64");
        Ok(())
    }

    #[test]
    fn wheel_platform_tag_x86_64_linux_musl() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::X86_64,
            Environment::Musl,
        )?;
        assert_eq!(tag, "musllinux_1_1_x86_64");
        Ok(())
    }

    #[test]
    fn wheel_platform_tag_i686_linux_gnu() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::X86_32(target_lexicon::X86_32Architecture::I686),
            Environment::Gnu,
        )?;
        assert_eq!(tag, "manylinux_2_17_i686.manylinux2014_i686");
        Ok(())
    }

    #[test]
    fn wheel_platform_tag_i686_linux_musl() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::X86_32(target_lexicon::X86_32Architecture::I686),
            Environment::Musl,
        )?;
        assert_eq!(tag, "musllinux_1_1_i686");
        Ok(())
    }

    #[test]
    fn wheel_platform_tag_aarch64_linux_gnu() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64),
            Environment::Gnu,
        )?;
        assert_eq!(
            tag,
            "manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64"
        );
        Ok(())
    }

    #[test]
    fn wheel_platform_tag_aarch64_linux_musl() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64),
            Environment::Musl,
        )?;
        // aarch64 uses a single dual-tagged wheel for both glibc and musl
        assert_eq!(
            tag,
            "manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64"
        );
        Ok(())
    }

    #[test]
    fn wheel_platform_tag_armv7_linux_gnu() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::Arm(ArmArchitecture::Armv7),
            Environment::Gnu,
        )?;
        assert_eq!(tag, "manylinux_2_17_armv7l.manylinux2014_armv7l");
        Ok(())
    }

    #[test]
    fn wheel_platform_tag_armv7_linux_musl() -> Result<()> {
        let tag = wheel_platform_tag_for_host(
            OperatingSystem::Linux,
            Architecture::Arm(ArmArchitecture::Armv7),
            Environment::Musl,
        )?;
        assert_eq!(
            tag,
            "manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l"
        );
        Ok(())
    }

    #[tokio::test]
    async fn replace_uv_binary_overwrites_existing_file() -> Result<()> {
        let temp = tempfile::tempdir()?;
        let source = temp.path().join("source-uv");
        let target_dir = temp.path().join("tools").join("uv");
        let target_path = target_dir.join("uv").with_extension(EXE_EXTENSION);

        fs_err::create_dir_all(&target_dir)?;
        fs_err::write(&source, b"new")?;
        fs_err::write(&target_path, b"old")?;

        replace_uv_binary(&source, &target_path).await?;

        assert!(!source.exists());
        assert_eq!(fs_err::read(&target_path)?, b"new");

        Ok(())
    }

    #[tokio::test]
    async fn replace_uv_binary_recreates_missing_parent_dir() -> Result<()> {
        let temp = tempfile::tempdir()?;
        let source = temp.path().join("source-uv");
        let target_dir = temp.path().join("tools").join("uv");
        let target_path = target_dir.join("uv").with_extension(EXE_EXTENSION);

        fs_err::create_dir_all(&target_dir)?;
        fs_err::write(&target_path, b"old")?;
        fs_err::remove_dir_all(&target_dir)?;
        fs_err::write(&source, b"new")?;

        replace_uv_binary(&source, &target_path).await?;

        assert!(target_dir.exists());
        assert_eq!(fs_err::read(&target_path)?, b"new");

        Ok(())
    }
}


package policy

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/data"
	"github.com/TecharoHQ/anubis/internal"
	"github.com/TecharoHQ/anubis/lib/config"
	"github.com/TecharoHQ/anubis/lib/thoth/thothmock"
)

func TestDefaultPolicyMustParse(t *testing.T) {
	ctx := thothmock.WithMockThoth(t)

	fin, err := data.BotPolicies.Open("botPolicies.yaml")
	if err != nil {
		t.Fatal(err)
	}
	defer fin.Close() //nolint:errcheck

	if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info", false); err != nil {
		t.Fatalf("can't parse config: %v", err)
	}
}

func TestGoodConfigs(t *testing.T) {

	finfos, err := os.ReadDir("../config/testdata/good")
	if err != nil {
		t.Fatal(err)
	}

	for _, st := range finfos {
		t.Run(st.Name(), func(t *testing.T) {
			t.Run("with-thoth", func(t *testing.T) {
				fin, err := os.Open(filepath.Join("..", "config", "testdata", "good", st.Name()))
				if err != nil {
					t.Fatal(err)
				}
				defer fin.Close() //nolint:errcheck

				ctx := thothmock.WithMockThoth(t)
				if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
					t.Fatal(err)
				}
			})

			t.Run("without-thoth", func(t *testing.T) {
				fin, err := os.Open(filepath.Join("..", "config", "testdata", "good", st.Name()))
				if err != nil {
					t.Fatal(err)
				}
				defer fin.Close() //nolint:errcheck

				if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
					t.Fatal(err)
				}
			})
		})
	}
}

func TestBadConfigs(t *testing.T) {
	ctx := thothmock.WithMockThoth(t)

	finfos, err := os.ReadDir("../config/testdata/bad")
	if err != nil {
		t.Fatal(err)
	}

	for _, st := range finfos {
		t.Run(st.Name(), func(t *testing.T) {
			fin, err := os.Open(filepath.Join("..", "config", "testdata", "bad", st.Name()))
			if err != nil {
				t.Fatal(err)
			}
			defer fin.Close() //nolint:errcheck

			if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err == nil {
				t.Fatal(err)
			} else {
				t.Log(err)
			}
		})
	}
}

func TestPathCheckerStripsForwardedURIQuery(t *testing.T) {
	checker, err := NewPathChecker("^/admin$", true)
	if err != nil {
		t.Fatal(err)
	}
	req := httptest.NewRequest(http.MethodGet, "https://anubis.local/.within.website/x/cmd/anubis/api/check", nil)
	req.Header.Set("X-Forwarded-Uri", "/admin?x=1")
	matched, err := checker.Check(req)
	if err != nil {
		t.Fatal(err)
	}
	if !matched {
		t.Fatalf("expected exact path checker to match forwarded URI when query string is appended")
	}
	req.Header.Set("X-Forwarded-Uri", "/admin")
	matched, err = checker.Check(req)
	if err != nil {
		t.Fatal(err)
	}
	if !matched {
		t.Fatalf("expected exact path checker to match forwarded URI without query string")
	}
}

func TestConfigReferencesJA4H(t *testing.T) {
	for _, tt := range []struct {
		name string
		bots []config.BotConfig
		want bool
	}{
		{
			name: "no bots",
			bots: nil,
			want: false,
		},
		{
			name: "unrelated rules",
			bots: []config.BotConfig{
				{Name: "ua", HeadersRegex: map[string]string{"User-Agent": "curl"}},
				{Name: "expr", Expression: &config.ExpressionOrList{Expression: `userAgent.contains("bot")`}},
			},
			want: false,
		},
		{
			name: "headers_regex exact match",
			bots: []config.BotConfig{
				{Name: "ja4h", HeadersRegex: map[string]string{internal.JA4HHeaderName: "t13d.*"}},
			},
			want: true,
		},
		{
			name: "headers_regex case-insensitive match",
			bots: []config.BotConfig{
				{Name: "ja4h", HeadersRegex: map[string]string{"x-http-fingerprint-ja4h": ".*"}},
			},
			want: true,
		},
		{
			name: "expression references header",
			bots: []config.BotConfig{
				{Name: "ja4h", Expression: &config.ExpressionOrList{Expression: `headers["X-Http-Fingerprint-Ja4h"] == "t13d"`}},
			},
			want: true,
		},
		{
			name: "expression list references header",
			bots: []config.BotConfig{
				{Name: "ja4h", Expression: &config.ExpressionOrList{Any: []string{
					`userAgent.contains("bot")`,
					`headers["X-Http-Fingerprint-Ja4h"] == "t13d"`,
				}}},
			},
			want: true,
		},
		{
			name: "expression missingHeader references header",
			bots: []config.BotConfig{
				{Name: "ja4h", Expression: &config.ExpressionOrList{Expression: `!missingHeader(headers, "X-Http-Fingerprint-Ja4h")`}},
			},
			want: true,
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			if got := configReferencesJA4H(tt.bots); got != tt.want {
				t.Errorf("configReferencesJA4H() = %v, want %v", got, tt.want)
			}
		})
	}
}


// Copyright (c) Epic Games Tools
// Licensed under the MIT license (https://opensource.org/license/mit/)

////////////////////////////////
//~ rjf: Expression Evaluation Functions

internal DW_Eval
dw_eval(Arena *arena, DW_Format fmt, Arch arch, MemoryMap *memory_map, void *regs, U64 frame_base, U64 cfa, U64 tls, DW_EvalState *state, String8 expr, U64 op_idx_cap)
{
  DW_Eval result = {0};
  ARCH_Info *arch_info = arch_info_from_arch(arch);
  B32 need_a_break = 0;
  B32 done = 0;
  for(;state->off < expr.size && !need_a_break && !done;)
  {
    U64 start_off = state->off;
    U64 off = state->off;

    //- rjf: too many ops? -> error
    if(state->op_idx >= op_idx_cap)
    {
      result.status = DW_EvalStatus_ExecOpLimitReached;
      break;
    }

    //- rjf: read next opcode
    DW_ExprOp opcode = 0;
    off += str8_deserial_read_struct(expr, off, &opcode);

    //- rjf: unpack opcode
    U8 push_count = 0;
    U8 pop_count = 0;
    U8 operand_count = 0;
    DW_ExprOperandKind operand_kinds[2] = {DW_ExprOperandKind_Null};
    {
      DW_ExprOpInfo *op_info = dw_info_from_expr_op(DW_Version_5, DW_ExtFlag_All, opcode);
      push_count = op_info->push_count;
      pop_count = op_info->pop_count;
      operand_count = op_info->operand_count;
      MemoryCopy(operand_kinds, op_info->operand_kinds, sizeof(operand_kinds));
    }

    //- rjf: read operands
    DW_EvalVal operands[2] = {0};
    for(U8 operand_idx = 0; operand_idx < operand_count; operand_idx += 1)
    {
      U64 fixed_bytes_to_read = 0;
      switch(operand_kinds[operand_idx])
      {
        default:{}break;
        case DW_ExprOperandKind_U8:       {fixed_bytes_to_read = 1;}goto fixed_read;
        case DW_ExprOperandKind_U16:      {fixed_bytes_to_read = 2;}goto fixed_read;
        case DW_ExprOperandKind_U32:      {fixed_bytes_to_read = 4;}goto fixed_read;
        case DW_ExprOperandKind_U64:      {fixed_bytes_to_read = 8;}goto fixed_read;
        case DW_ExprOperandKind_S8:       {fixed_bytes_to_read = 1;}goto fixed_read;
        case DW_ExprOperandKind_S16:      {fixed_bytes_to_read = 2;}goto fixed_read;
        case DW_ExprOperandKind_S32:      {fixed_bytes_to_read = 4;}goto fixed_read;
        case DW_ExprOperandKind_S64:      {fixed_bytes_to_read = 8;}goto fixed_read;
        case DW_ExprOperandKind_Addr:     {fixed_bytes_to_read = byte_size_from_arch(arch);}goto fixed_read;
        case DW_ExprOperandKind_DwarfUInt:{fixed_bytes_to_read = dw_addr_size_from_format(fmt);}goto fixed_read;
        fixed_read:;
        {
          off += str8_deserial_read(expr, off, &operands[operand_idx].u512.u64[0], fixed_bytes_to_read, fixed_bytes_to_read);
        }break;
        case DW_ExprOperandKind_ULEB128:
        {
          off += str8_deserial_read_uleb128(expr, off, &operands[operand_idx].u512.u64[0]);
        }break;
        case DW_ExprOperandKind_SLEB128:
        {
          off += str8_deserial_read_sleb128(expr, off, &operands[operand_idx].s64);
        }break;
        case DW_ExprOperandKind_Block:
        {
          U8 block_size = 0;
          off += str8_deserial_read_struct(expr, off, &block_size);
          operands[operand_idx].data = str8_prefix(str8_skip(expr, off), block_size);
        }break;
      }
    }

    //- rjf: do pops
    DW_EvalVal popped_vals[3] = {0};
    {
      pop_count = Min(pop_count, ArrayCount(popped_vals));
      for(U8 pop_idx = 0; pop_idx < pop_count; pop_idx += 1)
      {
        if(state->top_val != 0)
        {
          DW_EvalValNode *popped = state->top_val;
          SLLStackPop(state->top_val);
          popped_vals[pop_idx] = popped->v;
          SLLStackPush(state->free_val, popped);
        }
      }
    }

    //- rjf: apply opcode
    DW_EvalVal push_vals[3] = {0};
    U64 pick_target_idx = 0;
    U64 deref_read_size = 0;
    DW_RegCode reg_code_dw = 0;
    switch(opcode)
    {
      //- rjf: unsuported ops
      case DW_ExprOp_Call2:             // DWARF function calls
      case DW_ExprOp_Call4:             // DWARF function calls
      case DW_ExprOp_CallRef:           // DWARF function calls
      case DW_ExprOp_Addrx:             // address in .debug_addr; not currently present for us, need to find how to preserve if we care
      case DW_ExprOp_Constx:            // address in .debug_addr; not currently present for us, need to find how to preserve if we care
      case DW_ExprOp_EntryValue:        // encodes a sub-expression as a data block - need to recursively evaluate, lol
      case DW_ExprOp_ConstType:         // pushes a constant - but refers into DWARF debug info to specify the type. we don't have that, need to figure out how to preserve mapping from DW type ID -> RDI type, if we care
      case DW_ExprOp_DerefType:         // same as above - refers to DWARF debug info
      case DW_ExprOp_RegvalType:        // same as above - refers to DWARF debug info
      case DW_ExprOp_Piece:             // pieces - used to stitch multiple sub-values together
      case DW_ExprOp_BitPiece:          // pieces - used to stitch multiple sub-values together
      case DW_ExprOp_XDeref:            // two entries popped from stack - second one is 'address space identifier'. not possible in this layer right now
      case DW_ExprOp_XDerefSize:        // does multiple address spaces
      case DW_ExprOp_XDerefType:        // refers to DWARF debug info, *and* does multiple address spaces
      case DW_ExprOp_PushObjectAddress: // pushes "the address of the object currently being evaluated" ???
      case DW_ExprOp_ImplicitPointer:   // refers to DWARF debug info
      default:
      {
        done = 1;
        result.status = DW_EvalStatus_UnsupportedOp;
      }break;

      //- rjf: address ops
      case DW_ExprOp_Deref:{deref_read_size = byte_size_from_arch(arch);}goto deref;
      case DW_ExprOp_DerefSize:{deref_read_size = operands[0].u512.u64[0];}goto deref;
      deref:;
      {
        U64 vaddr = popped_vals[0].u512.u64[0];
        U64 read_size = Min(deref_read_size, sizeof(push_vals[0].u512));
        Rng1U64 read_vaddr_range = r1u64(vaddr, vaddr+read_size);
        if(!memory_map_read(memory_map, read_vaddr_range, &push_vals[0].u512))
        {
          need_a_break = 1;
          result.status = DW_EvalStatus_FailedMemoryRead;
          result.missed_read_vaddr_range = read_vaddr_range;
        }
      }break;

      //- rjf: generic constants
      case DW_ExprOp_Const1U: case DW_ExprOp_Const1S:
      case DW_ExprOp_Const2U: case DW_ExprOp_Const2S:
      case DW_ExprOp_Const4U: case DW_ExprOp_Const4S:
      case DW_ExprOp_Const8U: case DW_ExprOp_Const8S:
      case DW_ExprOp_ConstU: case DW_ExprOp_ConstS:
      case DW_ExprOp_Addr:
      {
        push_vals[0] = operands[0];
      }break;

      //- rjf: stack ops
      case DW_ExprOp_Dup:{push_vals[0] = popped_vals[0];}break;
      case DW_ExprOp_Drop:{/* NOTE(rjf): nothing to do here - just pops */}break;
      case DW_ExprOp_Swap:{push_vals[0] = popped_vals[1]; push_vals[1] = popped_vals[0];}break;
      case DW_ExprOp_Rot: {push_vals[0] = popped_vals[0]; push_vals[1] = popped_vals[1]; push_vals[2] = popped_vals[2];}break;
      case DW_ExprOp_Over:{pick_target_idx = 1;}break;
      case DW_ExprOp_Pick:{pick_target_idx = operands[0].u512.u64[0];}goto pick_stack_val;
      pick_stack_val:;
      {
        U64 idx = 0;
        for(DW_EvalValNode *n = state->top_val; n != 0; n = n->next, idx += 1)
        {
          if(idx == pick_target_idx)
          {
            push_vals[0] = n->v;
            break;
          }
        }
      }break;

      //- rjf: arithmetic ops
#define UniOpU(k, op)   case k:{push_vals[0].u512.u64[0] = op(popped_vals[0].u512.u64[0]);}break
#define UniOpS(k, op)   case k:{push_vals[0].s64 = op(popped_vals[0].s64);}break
#define BinOpS(k, op)   case k:{push_vals[0].s64 = popped_vals[1].s64 op popped_vals[0].s64;}break
#define BinOpU(k, op)   case k:{push_vals[0].u512.u64[0] = popped_vals[1].u512.u64[0] op popped_vals[0].u512.u64[0];}break
#define BinOpDiv(k, op) case k:if(popped_vals[0].u512.u64[0] != 0){push_vals[0].u512.u64[0] = popped_vals[1].u512.u64[0] op popped_vals[0].u512.u64[0];}break
      UniOpS(DW_ExprOp_Abs,   abs_s64);
      UniOpS(DW_ExprOp_Neg,   -);
      UniOpU(DW_ExprOp_Not,   !);
      BinOpU(DW_ExprOp_Minus, -);
      BinOpU(DW_ExprOp_Mul,   *);
      BinOpU(DW_ExprOp_Plus,  +);
      BinOpU(DW_ExprOp_Shl,   <<);
      BinOpU(DW_ExprOp_Shr,   >>);
      BinOpU(DW_ExprOp_And,   &);
      BinOpU(DW_ExprOp_Or,    |);
      BinOpU(DW_ExprOp_Xor,   ^);
      BinOpS(DW_ExprOp_Shra,  >>);
      BinOpDiv(DW_ExprOp_Div, /);
      BinOpDiv(DW_ExprOp_Mod, %);
      BinOpU(DW_ExprOp_Eq,    ==);
      BinOpU(DW_ExprOp_Ne,    !=);
      BinOpS(DW_ExprOp_Ge,    >=);
      BinOpS(DW_ExprOp_Gt,    >);
      BinOpS(DW_ExprOp_Le,    <=);
      BinOpS(DW_ExprOp_Lt,    <);
#undef UniOpU
#undef UniOpS
#undef BinOpS
#undef BinOpU
#undef BinOpDiv
      case DW_ExprOp_PlusUConst:
      {
        push_vals[0].u512.u64[0] = popped_vals[0].u512.u64[0] + operands[0].u512.u64[0];
      }break;

      //- rjf: jumps/branches
      case DW_ExprOp_Bra:
      {
        if(popped_vals[0].u512.u64[0] == 0)
        {
          break;
        }
      } // fallthrough
      case DW_ExprOp_Skip:
      {
        S64 jump_delta = operands[0].s64;
        if((jump_delta < 0 && -jump_delta > off) || (off + jump_delta >= expr.size))
        {
          done = 1;
          result.status = DW_EvalStatus_Error;
        }
        else
        {
          off += jump_delta;
        }
      }break;

      //- rjf: literals
      case DW_ExprOp_Lit0: case DW_ExprOp_Lit1: case DW_ExprOp_Lit2: case DW_ExprOp_Lit3:
      case DW_ExprOp_Lit4: case DW_ExprOp_Lit5: case DW_ExprOp_Lit6: case DW_ExprOp_Lit7:
      case DW_ExprOp_Lit8: case DW_ExprOp_Lit9: case DW_ExprOp_Lit10: case DW_ExprOp_Lit11:
      case DW_ExprOp_Lit12: case DW_ExprOp_Lit13: case DW_ExprOp_Lit14: case DW_ExprOp_Lit15:
      case DW_ExprOp_Lit16: case DW_ExprOp_Lit17: case DW_ExprOp_Lit18: case DW_ExprOp_Lit19:
      case DW_ExprOp_Lit20: case DW_ExprOp_Lit21: case DW_ExprOp_Lit22: case DW_ExprOp_Lit23:
      case DW_ExprOp_Lit24: case DW_ExprOp_Lit25: case DW_ExprOp_Lit26: case DW_ExprOp_Lit27:
      case DW_ExprOp_Lit28: case DW_ExprOp_Lit29: case DW_ExprOp_Lit30: case DW_ExprOp_Lit31:
      {
        U8 val = (opcode - DW_ExprOp_Lit0);
        push_vals[0].u512.u8[0] = val;
      }break;

      //- rjf: register reads (opcode-encoded reg)
      case DW_ExprOp_Reg0: case DW_ExprOp_Reg1: case DW_ExprOp_Reg2: case DW_ExprOp_Reg3:
      case DW_ExprOp_Reg4: case DW_ExprOp_Reg5: case DW_ExprOp_Reg6: case DW_ExprOp_Reg7:
      case DW_ExprOp_Reg8: case DW_ExprOp_Reg9: case DW_ExprOp_Reg10: case DW_ExprOp_Reg11:
      case DW_ExprOp_Reg12: case DW_ExprOp_Reg13: case DW_ExprOp_Reg14: case DW_ExprOp_Reg15:
      case DW_ExprOp_Reg16: case DW_ExprOp_Reg17: case DW_ExprOp_Reg18: case DW_ExprOp_Reg19:
      case DW_ExprOp_Reg20: case DW_ExprOp_Reg21: case DW_ExprOp_Reg22: case DW_ExprOp_Reg23:
      case DW_ExprOp_Reg24: case DW_ExprOp_Reg25: case DW_ExprOp_Reg26: case DW_ExprOp_Reg27:
      case DW_ExprOp_Reg28: case DW_ExprOp_Reg29: case DW_ExprOp_Reg30: case DW_ExprOp_Reg31:
      {reg_code_dw = (opcode - DW_ExprOp_Reg0);}goto reg_read;
      case DW_ExprOp_RegX:
      {reg_code_dw = operands[0].u512.u64[0];}goto reg_read;
      reg_read:;
      {
        ARCH_RegCode reg_code = arch_reg_code_from_dw(arch, reg_code_dw);
        Rng1U16 reg_rng = arch_info->reg_code_rng_table[reg_code];
        U64 read_size = dim_1u16(reg_rng);
        read_size = Min(read_size, sizeof(push_vals[0].u512));
        reg_rng.max = reg_rng.min + read_size;
        arch_reg_block_read_range(arch_info, regs, reg_rng, &push_vals[0].u512);
      }break;

      //- rjf: register reads (opcode-encoded reg + offset)
      case DW_ExprOp_BReg0: case DW_ExprOp_BReg1: case DW_ExprOp_BReg2: case DW_ExprOp_BReg3:
      case DW_ExprOp_BReg4: case DW_ExprOp_BReg5: case DW_ExprOp_BReg6: case DW_ExprOp_BReg7:
      case DW_ExprOp_BReg8: case DW_ExprOp_BReg9: case DW_ExprOp_BReg10: case DW_ExprOp_BReg11:
      case DW_ExprOp_BReg12: case DW_ExprOp_BReg13: case DW_ExprOp_BReg14: case DW_ExprOp_BReg15:
      case DW_ExprOp_BReg16: case DW_ExprOp_BReg17: case DW_ExprOp_BReg18: case DW_ExprOp_BReg19:
      case DW_ExprOp_BReg20: case DW_ExprOp_BReg21: case DW_ExprOp_BReg22: case DW_ExprOp_BReg23:
      case DW_ExprOp_BReg24: case DW_ExprOp_BReg25: case DW_ExprOp_BReg26: case DW_ExprOp_BReg27:
      case DW_ExprOp_BReg28: case DW_ExprOp_BReg29: case DW_ExprOp_BReg30: case DW_ExprOp_BReg31:
      {reg_code_dw = (opcode - DW_ExprOp_BReg0);}goto based_reg_read;
      case DW_ExprOp_BRegX:
      {reg_code_dw = operands[0].u512.u64[0];}goto based_reg_read;
      based_reg_read:;
      {
        ARCH_RegCode reg_code = arch_reg_code_from_dw(arch, reg_code_dw);
        Rng1U16 reg_rng = arch_info->reg_code_rng_table[reg_code];
        U64 read_size = dim_1u16(reg_rng);
        read_size = Min(read_size, sizeof(push_vals[0].u512));
        reg_rng.max = reg_rng.min + read_size;
        arch_reg_block_read_range(arch_info, regs, reg_rng, &push_vals[0].u512);
        push_vals[0].u512.u64[0] += operands[0].s64;
      }break;

      //- rjf: frame base register read
      case DW_ExprOp_FBReg:
      {
        push_vals[0].u512.u64[0] = (U64)(frame_base + operands[0].s64);
      }break;

      //- rjf: TLS
      case DW_ExprOp_FormTlsAddress:
      {
        U64 tls_off = popped_vals[0].u512.u64[0];
        U64 tls_addr = tls + tls_off;
        push_vals[0].u512.u64[0] = tls_addr;
      }break;

      //- rjf: call frame CFA
      case DW_ExprOp_CallFrameCfa:
      {
        push_vals[0].u512.u64[0] = cfa;
      }break;

      //- rjf: implicit location descriptions (value is known, location is not)
      case DW_ExprOp_ImplicitValue:
      {
        push_vals[0] = operands[0];
      }break;
      case DW_ExprOp_StackValue:
      {
        // NOTE(rjf): nothing to do here - program is over, value (but not location) has
        // been evaluated and exists as the top entry on the stack.
      }break;

      //- rjf: conversions
      case DW_ExprOp_Convert:
      case DW_ExprOp_Reinterpret:
      {
        if(operands[0].u512.u64[0] == 0)
        {
          push_vals[0] = popped_vals[0];
        }
        else
        {
          // NOTE(rjf): with a nonzero operand, it is expecting us to read into the
          // DWARF debug info and push as that base type. we do not have access to that
          // in the general case here, and we need to figure out how to preserve that info
          // if we care.
          done = 1;
          result.status = DW_EvalStatus_UnsupportedOp;
        }
      }break;
    }

    //- rjf: do pushes
    if(!done && !need_a_break)
    {
      push_count = Min(push_count, ArrayCount(push_vals));
      for(U8 push_idx = 0; push_idx < push_count; push_idx += 1)
      {
        DW_EvalValNode *n = state->free_val;
        if(n != 0)
        {
          SLLStackPop(state->free_val);
        }
        else
        {
          n = push_array(arena, DW_EvalValNode, 1);
        }
        n->v = push_vals[push_idx];
        SLLStackPush(state->top_val, n);
      }
    }

    //- rjf: do not need a break? advance state.
    //
    // (if we *do* need a break, this means we need to ask the caller for
    // more info, like memory reads, and so we want to resume from the
    // offset just before this opcode)
    //
    if(!need_a_break)
    {
      state->op_idx += 1;
      state->off = off;
    }

    //- rjf: at the end of the expression? -> set success, fill result
    if(off == expr.size)
    {
      result.status = DW_EvalStatus_Good;
      if(state->top_val != 0)
      {
        result.val = state->top_val->v;
      }
    }

    //- rjf: did not advance? -> error
    if(off == start_off)
    {
      result.status = DW_EvalStatus_Error;
      break;
    }
  }
  return result;
}