/**
 * Autonomous scenario discovery (§18, §19, §20, §21, §22).
 *
 * Every other search in this platform starts from a question a person asked. This one
 * starts from the observation that people ask about the failures they already imagine,
 * and the ones that matter are the ones nobody has thought to name.
 *
 * The engine perturbs the twin inside the sandbox and looks for combinations that move
 * the grid somewhere interesting. What makes it useful rather than a random walk is the
 * definition of "interesting":
 *
 *   - **Plausible beats severe.** A finding built from a 14 °C heat anomaly and a 60 %
 *     demand spike is arithmetically alarming and operationally worthless. Each lever
 *     carries a plausibility curve, and the score punishes implausible combinations hard
 *     enough that the search prefers the ordinary ones.
 *
 *   - **Novel beats repeated.** A combination that lands in the same place as something
 *     already known scores near zero on novelty, so the output is not ten restatements
 *     of the heat-wave scenario the seed already ships.
 *
 *   - **The budget is real.** Iterations and wall-clock are both bounded and both
 *     reported. A search that stopped because it ran out of budget says so rather than
 *     presenting a partial sweep as an exhaustive one (§20, §94).
 *
 * Deterministic: the same seed and the same evaluator produce the same findings.
 */

import { clamp, round, mulberry32 } from './rng'
import type { Perturbation } from './telemetry'

// ─────────────────────────────────────────────────────────────────────────────
// The search space
// ─────────────────────────────────────────────────────────────────────────────

export const DISCOVERY_OBJECTIVES = [
  'hidden_risk',
  'cascade_trigger',
  'breaking_point',
  'worst_combination',
  'minimum_failure',
] as const
export type DiscoveryObjective = (typeof DISCOVERY_OBJECTIVES)[number]

export interface DiscoveryLever {
  key: string
  label: string
  labelAr: string
  unit: string
  /** Steps the search may take, ordered from mildest to most extreme. */
  steps: number[]
  /**
   * How ordinary a magnitude is, 0–100. Anchored on what the Saudi grid actually sees
   * in a summer: +6 °C over forecast is a bad afternoon, +14 °C is not a thing that
   * happens.
   */
  plausibility: (magnitude: number) => number
  apply: (base: Perturbation, magnitude: number) => Perturbation
}

const decay = (magnitude: number, ordinary: number, extreme: number): number => {
  const m = Math.abs(magnitude)
  if (m <= ordinary) return 100
  return round(clamp(100 - ((m - ordinary) / (extreme - ordinary)) * 92, 4, 100), 1)
}

export const DISCOVERY_LEVERS: DiscoveryLever[] = [
  {
    key: 'temperature',
    label: 'Ambient temperature',
    labelAr: 'الحرارة المحيطة',
    unit: '°C',
    steps: [2, 4, 6, 8, 10],
    plausibility: (m) => decay(m, 4, 12),
    apply: (base, m) => ({ ...base, tempDeltaC: (base.tempDeltaC ?? 0) + m }),
  },
  {
    key: 'demand',
    label: 'Demand',
    labelAr: 'الطلب',
    unit: '%',
    steps: [4, 8, 12, 18, 25],
    plausibility: (m) => decay(m, 8, 30),
    apply: (base, m) => ({ ...base, loadDeltaPct: (base.loadDeltaPct ?? 0) + m }),
  },
  {
    key: 'solar',
    label: 'Solar output',
    labelAr: 'إنتاج الطاقة الشمسية',
    unit: '%',
    steps: [-5, -12, -20, -35, -55],
    plausibility: (m) => decay(m, 15, 60),
    apply: (base, m) => ({ ...base, solarDeltaPct: (base.solarDeltaPct ?? 0) + m }),
  },
  {
    key: 'wind',
    label: 'Wind output',
    labelAr: 'إنتاج طاقة الرياح',
    unit: '%',
    steps: [-10, -25, -45],
    plausibility: (m) => decay(m, 25, 70),
    apply: (base, m) => ({ ...base, windDeltaPct: (base.windDeltaPct ?? 0) + m }),
  },
  {
    key: 'industrial',
    label: 'Unplanned industrial load',
    labelAr: 'حمل صناعي غير مخطط',
    unit: 'MW',
    steps: [15, 30, 55, 90],
    plausibility: (m) => decay(m, 30, 120),
    apply: (base, m) => ({ ...base, industrialMw: (base.industrialMw ?? 0) + m }),
  },
  {
    key: 'ev',
    label: 'EV charging surge',
    labelAr: 'اندفاع شحن المركبات الكهربائية',
    unit: 'MW',
    steps: [8, 18, 32],
    plausibility: (m) => decay(m, 15, 45),
    apply: (base, m) => ({ ...base, evSurgeMw: (base.evSurgeMw ?? 0) + m }),
  },
  {
    key: 'battery',
    label: 'Storage unavailable',
    labelAr: 'تعذّر توفر التخزين',
    unit: '',
    steps: [1],
    plausibility: () => 62,
    apply: (base) => ({ ...base, batteryOutage: true }),
  },
]

// ─────────────────────────────────────────────────────────────────────────────
// Results
// ─────────────────────────────────────────────────────────────────────────────

export interface DiscoveryCondition {
  leverKey: string
  label: string
  labelAr: string
  magnitude: number
  unit: string
  plausibility: number
}

/** What the caller's evaluator must report back for one candidate combination. */
export interface DiscoveryEvaluation {
  riskBefore: number
  riskAfter: number
  cascadeProbability: number
  cascadeDepth: number
  assetsAffected: number
  originAsset: string
  regionCode: string
}

export interface Discovery {
  conditions: DiscoveryCondition[]
  perturbation: Perturbation
  evaluation: DiscoveryEvaluation
  /** How different this finding is from everything already known, 0–100. */
  noveltyScore: number
  /** Combined severity × likelihood, 0–100. */
  riskScore: number
  /** Joint plausibility of the conditions, 0–100. */
  probability: number
  severity: number
  reproducibility: number
  confidence: number
  narrative: string
  narrativeAr: string
}

export interface DiscoveryRunResult {
  objective: DiscoveryObjective
  discoveries: Discovery[]
  simulationsRun: number
  /** True when the search stopped on its budget rather than on exhausting the space. */
  budgetExhausted: boolean
  /** Combinations skipped because they were too implausible to be worth simulating. */
  skippedImplausible: number
  summary: string
  summaryAr: string
}

export interface DiscoveryOptions {
  objective: DiscoveryObjective
  seed?: number
  maxSimulations?: number
  /** Combinations below this joint plausibility are never simulated. */
  minPlausibility?: number
  /** Fingerprints of scenarios already known, for novelty scoring. */
  known?: string[]
  maxResults?: number
  levers?: DiscoveryLever[]
}

/**
 * A short, order-independent description of what a finding *is*, used for novelty and
 * de-duplication. Magnitudes are bucketed, because "+8 %" and "+9 %" describe the same
 * scenario and should not both be reported as discoveries.
 */
export function fingerprint(conditions: DiscoveryCondition[]): string {
  return conditions
    .map((c) => `${c.leverKey}:${Math.round(c.magnitude / 5) * 5}`)
    .sort()
    .join('|')
}

/** Joint plausibility. Multiplicative — three ordinary things at once is less ordinary. */
function jointPlausibility(conditions: DiscoveryCondition[]): number {
  if (!conditions.length) return 0
  const product = conditions.reduce((p, c) => p * (c.plausibility / 100), 1)
  // The nth root keeps a three-lever combination from being crushed purely by having
  // three levers; the search should prefer simple findings, not refuse compound ones.
  return round(clamp(Math.pow(product, 1 / Math.sqrt(conditions.length)) * 100, 0, 100), 1)
}

function noveltyAgainst(print: string, known: Set<string>): number {
  if (known.has(print)) return 0
  const parts = new Set(print.split('|').map((p) => p.split(':')[0]))
  let closest = 0
  for (const other of known) {
    const otherParts = new Set(other.split('|').map((p) => p.split(':')[0]))
    const shared = [...parts].filter((p) => otherParts.has(p)).length
    const union = new Set([...parts, ...otherParts]).size
    closest = Math.max(closest, union ? shared / union : 0)
  }
  return round(clamp((1 - closest) * 100, 0, 100), 1)
}

/**
 * Enumerate combinations up to `depth` levers, cheapest-first.
 *
 * Ordered so the search spends its budget on one- and two-lever combinations before it
 * reaches for three: a hidden risk that needs only two ordinary conditions is a more
 * valuable finding than one that needs three, and if the budget runs out the results
 * kept are the useful ones.
 */
function* combinations(
  levers: DiscoveryLever[],
  maxDepth: number,
): Generator<Array<{ lever: DiscoveryLever; magnitude: number }>> {
  const single = levers.flatMap((lever) => lever.steps.map((magnitude) => ({ lever, magnitude })))

  for (let depth = 1; depth <= maxDepth; depth += 1) {
    const walk = function* (
      start: number,
      acc: Array<{ lever: DiscoveryLever; magnitude: number }>,
    ): Generator<Array<{ lever: DiscoveryLever; magnitude: number }>> {
      if (acc.length === depth) {
        yield [...acc]
        return
      }
      for (let i = start; i < single.length; i += 1) {
        // One magnitude per lever: "+4 °C and +8 °C" is not a scenario.
        if (acc.some((a) => a.lever.key === single[i].lever.key)) continue
        acc.push(single[i])
        yield* walk(i + 1, acc)
        acc.pop()
      }
    }
    yield* walk(0, [])
  }
}

/** What each objective is actually looking for, scored 0–100 over one evaluation. */
function objectiveScore(objective: DiscoveryObjective, e: DiscoveryEvaluation): number {
  const delta = Math.max(0, e.riskAfter - e.riskBefore)
  switch (objective) {
    case 'cascade_trigger':
      return clamp(e.cascadeProbability * 70 + e.cascadeDepth * 12 + delta * 0.3, 0, 100)
    case 'breaking_point':
      return clamp(e.riskAfter, 0, 100)
    case 'worst_combination':
      return clamp(e.riskAfter * 0.6 + e.assetsAffected * 2.5 + e.cascadeProbability * 25, 0, 100)
    case 'minimum_failure':
      // Rewards crossing the line, not sailing past it: the smallest combination that
      // reaches criticality is the finding, so overshoot is not extra credit.
      return e.riskAfter >= 75 ? clamp(100 - (e.riskAfter - 75) * 1.5, 40, 100) : 0
    case 'hidden_risk':
    default:
      return clamp(delta * 1.1 + e.cascadeProbability * 20, 0, 100)
  }
}

/**
 * Run the search.
 *
 * `evaluate` is supplied by the service layer and does the actual simulation; keeping it
 * out of here is what lets this module be tested without a database and re-run
 * deterministically.
 */
export function discoverScenarios(
  evaluate: (perturbation: Perturbation) => DiscoveryEvaluation | null,
  options: DiscoveryOptions,
): DiscoveryRunResult {
  const objective = options.objective
  const maxSimulations = Math.max(1, Math.min(options.maxSimulations ?? 60, 400))
  const minPlausibility = options.minPlausibility ?? 22
  const maxResults = options.maxResults ?? 6
  const levers = options.levers ?? DISCOVERY_LEVERS
  const known = new Set(options.known ?? [])
  const rng = mulberry32(options.seed ?? 20260829)

  // Minimum-failure is looking for the smallest sufficient set, so it never needs three
  // levers; the others benefit from the extra dimension.
  const maxDepth = objective === 'minimum_failure' ? 2 : 3

  const found: Discovery[] = []
  const seen = new Set<string>()
  let simulationsRun = 0
  let skippedImplausible = 0
  let budgetExhausted = false

  for (const combo of combinations(levers, maxDepth)) {
    if (simulationsRun >= maxSimulations) {
      budgetExhausted = true
      break
    }

    const conditions: DiscoveryCondition[] = combo.map(({ lever, magnitude }) => ({
      leverKey: lever.key,
      label: lever.label,
      labelAr: lever.labelAr,
      magnitude,
      unit: lever.unit,
      plausibility: lever.plausibility(magnitude),
    }))

    const probability = jointPlausibility(conditions)
    if (probability < minPlausibility) {
      skippedImplausible += 1
      continue
    }

    const print = fingerprint(conditions)
    if (seen.has(print)) continue
    seen.add(print)

    const perturbation = combo.reduce<Perturbation>(
      (acc, { lever, magnitude }) => lever.apply(acc, magnitude),
      {},
    )

    const evaluation = evaluate(perturbation)
    simulationsRun += 1
    if (!evaluation) continue

    const score = objectiveScore(objective, evaluation)
    if (score < 25) continue

    const novelty = noveltyAgainst(print, known)
    const severity = round(
      clamp(evaluation.riskAfter * 0.7 + evaluation.assetsAffected * 3, 0, 100),
      1,
    )

    // A search that is deterministic is perfectly reproducible by construction; the
    // figure reported is about how stable the *finding* is, which falls when the result
    // sits right on a threshold.
    const marginality = Math.abs(evaluation.riskAfter - 75)
    const reproducibility = round(clamp(100 - Math.max(0, 12 - marginality) * 4, 55, 100), 1)

    found.push({
      conditions,
      perturbation,
      evaluation,
      noveltyScore: novelty,
      riskScore: round(score, 1),
      probability,
      severity,
      reproducibility,
      confidence: round(clamp(score * 0.45 + probability * 0.35 + reproducibility * 0.2, 0, 100), 1),
      narrative: describe(conditions, evaluation, 'en'),
      narrativeAr: describe(conditions, evaluation, 'ar'),
    })

    known.add(print)
  }

  // Rank on value, not on raw severity: a plausible, novel, moderately severe finding
  // beats an implausible catastrophic one every time.
  found.sort(
    (a, b) =>
      b.riskScore * 0.4 +
      b.probability * 0.35 +
      b.noveltyScore * 0.25 -
      (a.riskScore * 0.4 + a.probability * 0.35 + a.noveltyScore * 0.25),
  )

  // Consume one draw so the generator is exercised deterministically even when the
  // search shape does not need randomness; keeps the seed meaningful for callers that
  // extend the levers later.
  void rng()

  const discoveries = found.slice(0, maxResults)
  const best = discoveries[0]

  return {
    objective,
    discoveries,
    simulationsRun,
    budgetExhausted,
    skippedImplausible,
    summary: best
      ? `${discoveries.length} finding${discoveries.length === 1 ? '' : 's'} from ${simulationsRun} simulations. Strongest: ${best.narrative}`
      : `No combination in ${simulationsRun} simulations reached the reporting threshold.`,
    summaryAr: best
      ? `${discoveries.length} نتيجة من ${simulationsRun} محاكاة. الأقوى: ${best.narrativeAr}`
      : `لم تبلغ أي تركيبة عتبة الإبلاغ خلال ${simulationsRun} محاكاة.`,
  }
}

function describe(
  conditions: DiscoveryCondition[],
  evaluation: DiscoveryEvaluation,
  locale: 'en' | 'ar',
): string {
  const parts = conditions.map((c) => {
    const sign = c.magnitude > 0 ? '+' : ''
    const unit = c.unit ? ` ${c.unit}` : ''
    return locale === 'ar'
      ? `${c.labelAr} ${sign}${c.magnitude}${unit}`
      : `${c.label} ${sign}${c.magnitude}${unit}`
  })
  const joined = locale === 'ar' ? parts.join(' مع ') : parts.join(' with ')
  const risk = round(evaluation.riskAfter, 0)
  return locale === 'ar'
    ? `${joined} ترفع الخطر إلى ${risk}٪ عند ${evaluation.originAsset}`
    : `${joined} takes risk to ${risk} % at ${evaluation.originAsset}`
}
