/**
 * Per-repo retrieval tuning — derived from the ranking ledger and persisted to
 * ~/.trace-mcp/tuning.jsonc.
 *
 * The tuning file holds simple per-repo weight overrides that the search/fusion
 * pipeline picks up at startup. The format is intentionally human-readable so a
 * developer can hand-edit when needed.
 *
 * Schema (versioned):
 *   {
 *     "version": 1,
 *     "repos": {
 *       "/abs/path/to/project": {
 *         "fusion_weights": { "lexical": 0.45, "structural": 0.20, "similarity": 0.20, "identity": 0.15 },
 *         "events_used": 124,
 *         "tuned_at": "2026-04-30T12:00:00.000Z"
 *       }
 *     }
 *   }
 */

import fs from 'node:fs';
import path from 'node:path';
import { ensureGlobalDirs, TRACE_MCP_HOME } from '../global.js';
import { logger } from '../logger.js';
import { atomicWriteString } from '../utils/atomic-write.js';
import type { Channel, ChannelStats, RankingLedger } from './ranking-ledger.js';

export const TUNING_PATH = path.join(TRACE_MCP_HOME, 'tuning.jsonc');

/** Default fusion weights — must mirror src/scoring/signal-fusion.ts defaults. */
const DEFAULT_WEIGHTS: Record<Channel, number> = {
  lexical: 0.4,
  structural: 0.25,
  similarity: 0.2,
  identity: 0.15,
};

/** Minimum events required before we'll write a tuning override. Below this the
 *  acceptance signal is too noisy to act on. */
const MIN_EVENTS_FOR_TUNE = 25;

/** Smoothing constant — how aggressively we move weights toward the new ratios.
 *  alpha=0.5 means we average the old and new weights; alpha=1 replaces them. */
const SMOOTHING_ALPHA = 0.5;

interface RepoTuning {
  fusion_weights: Record<Channel, number>;
  events_used: number;
  tuned_at: string;
}

interface TuningFile {
  version: 1;
  repos: Record<string, RepoTuning>;
}

export interface TuneResult {
  applied: boolean;
  reason: string;
  weights?: Record<Channel, number>;
  events_used?: number;
  before?: Record<Channel, number>;
}

/**
 * Look up tuned fusion weights for a single repo. Returns null when the tuning
 * file is missing, malformed, or has no entry for this repo — the caller falls
 * back to the global defaults in that case.
 */
export function loadTunedWeights(
  repo: string,
  filePath: string = TUNING_PATH,
): Record<Channel, number> | null {
  const t = loadTuning(filePath);
  return t?.repos[repo]?.fusion_weights ?? null;
}

/**
 * Read the persisted tuning file. Returns null when absent or malformed —
 * callers should use defaults in that case. Never throws.
 */
export function loadTuning(filePath: string = TUNING_PATH): TuningFile | null {
  try {
    if (!fs.existsSync(filePath)) return null;
    const raw = fs.readFileSync(filePath, 'utf-8');
    const stripped = raw.replace(/^\s*\/\/.*$/gm, ''); // tolerate // comments
    const parsed = JSON.parse(stripped);
    if (parsed?.version !== 1) return null;
    return parsed as TuningFile;
  } catch (e) {
    logger.warn({ error: e, filePath }, 'tuning.jsonc read failed');
    return null;
  }
}

/** Atomic write of the tuning file with a friendly comment header. */
function saveTuning(data: TuningFile, filePath: string = TUNING_PATH): void {
  ensureGlobalDirs();
  const header =
    '// Auto-generated by tune_weights. Hand-edit at your own risk — re-running tune_weights\n' +
    '// will overwrite per-repo blocks based on the latest ranking ledger.\n';
  atomicWriteString(filePath, `${header}${JSON.stringify(data, null, 2)}`);
}

/**
 * Compute new fusion weights from per-channel acceptance rates and merge with
 * the existing weights via {@link SMOOTHING_ALPHA}. Optionally persist to disk.
 */
export function computeTunedWeights(
  channelStats: ChannelStats[],
  current: Record<Channel, number> = DEFAULT_WEIGHTS,
): Record<Channel, number> {
  // Treat acceptance_rate as the channel's new contribution. Add a small floor
  // so a zero-rate channel doesn't disappear entirely.
  const floor = 0.05;
  const raw: Record<Channel, number> = {
    lexical: floor,
    structural: floor,
    similarity: floor,
    identity: floor,
  };
  for (const c of channelStats) {
    raw[c.channel] = Math.max(floor, c.acceptance_rate);
  }
  const sum = raw.lexical + raw.structural + raw.similarity + raw.identity;
  const normalized: Record<Channel, number> = {
    lexical: raw.lexical / sum,
    structural: raw.structural / sum,
    similarity: raw.similarity / sum,
    identity: raw.identity / sum,
  };
  // Smooth toward the new normalized weights.
  return {
    lexical: round3(SMOOTHING_ALPHA * normalized.lexical + (1 - SMOOTHING_ALPHA) * current.lexical),
    structural: round3(
      SMOOTHING_ALPHA * normalized.structural + (1 - SMOOTHING_ALPHA) * current.structural,
    ),
    similarity: round3(
      SMOOTHING_ALPHA * normalized.similarity + (1 - SMOOTHING_ALPHA) * current.similarity,
    ),
    identity: round3(
      SMOOTHING_ALPHA * normalized.identity + (1 - SMOOTHING_ALPHA) * current.identity,
    ),
  };
}

/**
 * Run a tuning pass for a single repo. Returns a structured result describing
 * whether anything was written and why. When `dryRun` is true, the new weights
 * are returned but NOT persisted.
 */
export function tuneRepoWeights(
  ledger: RankingLedger,
  repo: string,
  opts: { dryRun?: boolean; minEvents?: number; filePath?: string } = {},
): TuneResult {
  const stats = ledger.getStats(repo);
  if (!stats) {
    return { applied: false, reason: 'ranking ledger is unavailable (telemetry disabled)' };
  }
  const minEvents = opts.minEvents ?? MIN_EVENTS_FOR_TUNE;
  if (stats.total_events < minEvents) {
    return {
      applied: false,
      reason: `only ${stats.total_events} events for repo (need ≥ ${minEvents}); keeping defaults`,
      events_used: stats.total_events,
    };
  }
  if (stats.total_accepted === 0) {
    return {
      applied: false,
      reason: `no acceptance signal yet (${stats.total_events} events, 0 accepted); keeping defaults`,
      events_used: stats.total_events,
    };
  }

  const file = opts.filePath ?? TUNING_PATH;
  const existing = loadTuning(file) ?? { version: 1 as const, repos: {} };
  const current = existing.repos[repo]?.fusion_weights ?? DEFAULT_WEIGHTS;
  const newWeights = computeTunedWeights(stats.by_channel, current);

  if (!opts.dryRun) {
    existing.repos[repo] = {
      fusion_weights: newWeights,
      events_used: stats.total_events,
      tuned_at: new Date().toISOString(),
    };
    saveTuning(existing, file);
  }

  return {
    applied: !opts.dryRun,
    reason: opts.dryRun
      ? 'dry run — weights computed but not persisted'
      : `tuned weights for repo from ${stats.total_events} events`,
    weights: newWeights,
    events_used: stats.total_events,
    before: current,
  };
}

function round3(n: number): number {
  return Math.round(n * 1000) / 1000;
}
