/**
 * NABDH database seed.
 *
 * Produces a coherent demonstration grid: topology, condition history, a Failure DNA
 * library, a model registry, live predictions with explanations, alerts, incidents with
 * replayable timelines, maintenance history and forecasts.
 *
 * The important property is that *nothing is invented twice*. Sensor history is written
 * by the same telemetry model the live view uses, predictions come from the same risk
 * engine, and Failure DNA signatures are placed at measured distances in the same vector
 * space the matcher searches. Re-running the seed reproduces the same grid.
 */

import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'

import { REGIONS } from '../src/lib/data/regions'
import { toAssetProfile } from '../src/lib/data/asset-profile'
import { riskToAlertLevel } from '../src/lib/domain/enums'
import { dnaSimilarity, type DnaVector } from '../src/lib/engine/dna'
import { sampleEnvironment } from '../src/lib/engine/environment'
import {
  buildGridSnapshot,
  extractAssetDna,
  resourcesFor,
  type DnaPatternRef,
} from '../src/lib/engine/grid'
import { forecastLoad, forecastRenewable } from '../src/lib/engine/forecast'
import { predictFailure } from '../src/lib/engine/prediction'
import { evaluateCandidates, generateCandidates } from '../src/lib/engine/actions'
import { FACTOR_COPY, describePrediction } from '../src/lib/engine/explain'
import { sampleAsset, type AssetProfile } from '../src/lib/engine/telemetry'
import { seededInt, seededPick, seededRange, round, clamp } from '../src/lib/engine/rng'
import {
  DEMO_ASSET_CODE,
  DEMO_REGION_CODE,
  demoBaseTimestamp,
  demoPerturbation,
} from '../src/lib/demo/config'

import { ASSET_TYPES, SENSOR_PLAN, buildTopology } from './seed/topology'
import { DEMO_USERS, PERMISSIONS, ROLES } from './seed/rbac'
import { resetIntelligence, seedIntelligence } from './seed/intelligence'
import { resetTwins, seedTwins } from './seed/twin'
import { resetAssurance, seedAssurance } from './seed/assurance'
import { resetIntegrations, seedIntegrations } from './seed/integrations'
import {
  ESCALATION_RULES,
  FAILURE_PATTERNS,
  INTEGRATIONS,
  MODELS,
  SCENARIOS,
  SETTINGS,
  deriveDnaAtSimilarity,
} from './seed/knowledge'

const prisma = new PrismaClient()

const MIN = 60_000
const HOUR = 3_600_000
const DAY = 86_400_000

const NOW = Date.now()

function log(step: string, detail = '') {
  const stamp = new Date().toISOString().slice(11, 19)
  console.log(`  [${stamp}] ${step}${detail ? ` — ${detail}` : ''}`)
}

/** Insert in chunks so SQLite's bound-variable limit is never approached. */
async function createManyChunked<T>(
  label: string,
  rows: T[],
  insert: (chunk: T[]) => Promise<unknown>,
  size = 1000,
) {
  for (let i = 0; i < rows.length; i += size) {
    await insert(rows.slice(i, i + size))
  }
  log(label, `${rows.length.toLocaleString()} rows`)
}

// ─────────────────────────────────────────────────────────────────────────────
// 1. Reset
// ─────────────────────────────────────────────────────────────────────────────

async function reset() {
  // The 4.1 integration tables stand alone — no asset foreign keys — so they can go at
  // any point; first, to keep the ordering comment below about what actually constrains.
  await resetIntegrations(prisma)
  // The 3.0 twin tables reference assets, sensors and the 2.0 layer, so they go first.
  await resetAssurance(prisma)
  await resetTwins(prisma)
  // Then the 2.0 decision-intelligence tables, which reference assets and sensors.
  await resetIntelligence(prisma)

  // Ordered child-first so foreign keys never block a delete.
  await prisma.$transaction([
    prisma.predictionFactor.deleteMany(),
    prisma.recommendation.deleteMany(),
    prisma.notification.deleteMany(),
    prisma.alert.deleteMany(),
    prisma.prediction.deleteMany(),
    prisma.incidentEvent.deleteMany(),
    prisma.incident.deleteMany(),
    prisma.maintenanceRecord.deleteMany(),
    prisma.simulationImpact.deleteMany(),
    prisma.simulationResult.deleteMany(),
    prisma.simulation.deleteMany(),
    prisma.scenario.deleteMany(),
    prisma.sensorReading.deleteMany(),
    prisma.sensor.deleteMany(),
    prisma.loadForecast.deleteMany(),
    prisma.renewableForecast.deleteMany(),
    prisma.weatherData.deleteMany(),
    prisma.failureDna.deleteMany(),
    prisma.failurePattern.deleteMany(),
    prisma.modelAudit.deleteMany(),
    prisma.modelVersion.deleteMany(),
    prisma.asset.deleteMany(),
    prisma.site.deleteMany(),
    prisma.assetType.deleteMany(),
    prisma.region.deleteMany(),
    prisma.auditLog.deleteMany(),
    prisma.session.deleteMany(),
    prisma.userRole.deleteMany(),
    prisma.rolePermission.deleteMany(),
    prisma.user.deleteMany(),
    prisma.role.deleteMany(),
    prisma.permission.deleteMany(),
    prisma.escalationRule.deleteMany(),
    prisma.integration.deleteMany(),
    prisma.appSetting.deleteMany(),
    prisma.demoRun.deleteMany(),
  ])
  log('reset', 'all tables cleared')
}

// ─────────────────────────────────────────────────────────────────────────────
// 2. Access control
// ─────────────────────────────────────────────────────────────────────────────

async function seedAccessControl() {
  await prisma.permission.createMany({ data: PERMISSIONS })
  const permissions = await prisma.permission.findMany()
  const permissionByKey = new Map(permissions.map((p) => [p.key, p.id]))

  for (const role of ROLES) {
    const created = await prisma.role.create({
      data: {
        key: role.key,
        name: role.name,
        nameAr: role.nameAr,
        description: role.description,
        descriptionAr: role.descriptionAr,
        level: role.level,
        isSystem: true,
      },
    })
    await prisma.rolePermission.createMany({
      data: [...new Set(role.permissions)].map((key) => ({
        roleId: created.id,
        permissionId: permissionByKey.get(key)!,
      })),
    })
  }
  log('access control', `${PERMISSIONS.length} permissions, ${ROLES.length} roles`)

  const demoEnabled = process.env.DEMO_ACCOUNTS_ENABLED !== 'false'
  if (!demoEnabled) {
    log('users', 'skipped — DEMO_ACCOUNTS_ENABLED is false')
    return new Map<string, string>()
  }

  const password = process.env.DEMO_ACCOUNT_PASSWORD || 'NabdhDemo!2026'
  const passwordHash = await bcrypt.hash(password, 11)
  const roles = await prisma.role.findMany()
  const roleByKey = new Map(roles.map((r) => [r.key, r.id]))
  const userIdByRole = new Map<string, string>()

  for (const user of DEMO_USERS) {
    const created = await prisma.user.create({
      data: {
        email: user.email,
        passwordHash,
        name: user.name,
        nameAr: user.nameAr,
        employeeId: user.employeeId,
        department: user.department,
        departmentAr: user.departmentAr,
        phone: user.phone,
        locale: user.locale,
        isActive: true,
        lastLoginAt: new Date(NOW - seededRange(user.employeeId.length * 31, 3, 1, 72) * HOUR),
      },
    })
    await prisma.userRole.create({
      data: { userId: created.id, roleId: roleByKey.get(user.roleKey)! },
    })
    userIdByRole.set(user.roleKey, created.id)
  }
  log('users', `${DEMO_USERS.length} demo accounts`)
  return userIdByRole
}

// ─────────────────────────────────────────────────────────────────────────────
// 3. Topology
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The plant that is instrumented at the fast cadence.
 *
 * Derived in one place because two things depend on it — how often a sensor is *expected*
 * to report, and how often the seeded telemetry actually reports — and the moment those
 * two definitions drift apart, every asset on one side of the gap is scored as though its
 * instruments were failing.
 */
function deriveWatchlist(profiles: Iterable<AssetProfile>): Set<string> {
  const list = new Set(
    [...profiles]
      .filter((p) => p.healthScore < 70 || p.code === DEMO_ASSET_CODE)
      .slice(0, 24)
      .map((p) => p.code),
  )
  list.add(DEMO_ASSET_CODE)
  return list
}

/** Polling cadence in minutes, by whether the asset is on the watch list. */
const WATCH_INTERVAL_MIN = 15
const STANDARD_INTERVAL_MIN = 60

async function seedTopology() {
  await prisma.region.createMany({
    data: REGIONS.map((r) => ({
      code: r.code,
      name: r.name,
      nameAr: r.nameAr,
      lat: r.lat,
      lon: r.lon,
      populationK: r.populationK,
      peakDemandMw: r.peakDemandMw,
      capacityMw: r.capacityMw,
      climate: r.climate,
      climateAr: r.climateAr,
    })),
  })

  await prisma.assetType.createMany({
    data: ASSET_TYPES.map((t) => ({
      key: t.key,
      name: t.name,
      nameAr: t.nameAr,
      category: t.category,
      icon: t.icon,
      expectedLifeYears: t.expectedLifeYears,
      criticalityWeight: t.criticalityWeight,
    })),
  })

  const regions = await prisma.region.findMany()
  const regionIdByCode = new Map(regions.map((r) => [r.code, r.id]))
  const assetTypes = await prisma.assetType.findMany()
  const typeIdByKey = new Map(assetTypes.map((t) => [t.key, t.id]))

  const topology = buildTopology(NOW)

  await createManyChunked('sites', topology.sites, (chunk) =>
    prisma.site.createMany({
      data: chunk.map((s) => ({
        code: s.code,
        name: s.name,
        nameAr: s.nameAr,
        regionId: regionIdByCode.get(s.regionCode)!,
        kind: s.kind,
        lat: s.lat,
        lon: s.lon,
        voltageLevelKv: s.voltageLevelKv,
        commissionedAt: s.commissionedAt,
      })),
    }),
  )

  const sites = await prisma.site.findMany()
  const siteIdByCode = new Map(sites.map((s) => [s.code, s.id]))

  await createManyChunked('assets', topology.assets, (chunk) =>
    prisma.asset.createMany({
      data: chunk.map((a) => ({
        code: a.code,
        name: a.name,
        nameAr: a.nameAr,
        assetTypeId: typeIdByKey.get(a.assetTypeKey)!,
        siteId: siteIdByCode.get(a.siteCode)!,
        regionId: regionIdByCode.get(a.regionCode)!,
        lat: a.lat,
        lon: a.lon,
        capacityMw: a.capacityMw,
        voltageKv: a.voltageKv,
        ratedTempC: a.ratedTempC,
        coolingType: a.coolingType,
        manufacturer: a.manufacturer,
        model: a.model,
        serialNumber: a.serialNumber,
        installedAt: a.installedAt,
        lastMaintenanceAt: a.lastMaintenanceAt,
        nextMaintenanceAt: a.nextMaintenanceAt,
        healthScore: a.healthScore,
        criticality: a.criticality,
        status: a.status,
        customersServed: a.customersServed,
        seed: a.seed,
        profileKey: a.profileKey,
        baselineLoadPct: a.baselineLoadPct,
        peakHour: a.peakHour,
        volatility: a.volatility,
        agingFactor: a.agingFactor,
        isFlexibleLoad: a.isFlexibleLoad,
        hasBatterySupport: a.hasBatterySupport,
      })),
    }),
  )

  // Parent every transformer to the substation asset that sits at its site.
  const assets = await prisma.asset.findMany({
    select: { id: true, code: true, siteId: true, assetType: { select: { key: true } } },
  })
  const substationBySite = new Map(
    assets.filter((a) => a.assetType.key === 'substation').map((a) => [a.siteId, a.id]),
  )
  for (const asset of assets) {
    if (asset.assetType.key === 'substation') continue
    const parentId = substationBySite.get(asset.siteId)
    if (parentId) {
      await prisma.asset.update({ where: { id: asset.id }, data: { parentAssetId: parentId } })
    }
  }

}



// ─────────────────────────────────────────────────────────────────────────────
// 4. Environmental and telemetry history
// ─────────────────────────────────────────────────────────────────────────────

async function seedWeather() {
  const regions = await prisma.region.findMany()
  const rows: Array<Record<string, unknown>> = []

  for (const region of regions) {
    const seed = REGIONS.find((r) => r.code === region.code)!
    for (let h = 7 * 24; h >= 0; h -= 1) {
      const ts = NOW - h * HOUR
      const env = sampleEnvironment(seed, ts)
      rows.push({
        regionId: region.id,
        ts: new Date(ts),
        tempC: round(env.tempC, 2),
        humidityPct: round(env.humidityPct, 1),
        windSpeedMs: round(env.windSpeedMs, 2),
        windDirDeg: round(env.windDirDeg, 1),
        irradianceWm2: round(env.irradianceWm2, 1),
        cloudCoverPct: round(env.cloudCoverPct, 1),
        dustIndex: round(env.dustIndex, 3),
        visibilityKm: round(env.visibilityKm, 1),
        source: 'simulated',
      })
    }
  }

  await createManyChunked('weather', rows, (chunk) =>
    prisma.weatherData.createMany({ data: chunk as never }),
  )
}

/**
 * Instrumentation.
 *
 * Split out of `seedTopology` so it can be given the watch list: a sensor's expected
 * polling cadence has to match the cadence its telemetry is actually written at, and the
 * only way to guarantee that is for both to read the same derivation.
 */
async function seedSensors(watchlist: Set<string>) {
  const sensorRows: Array<{
    code: string
    assetId: string
    kind: string
    unit: string
    minValue: number
    maxValue: number
    accuracy: number
    status: string
    firmware: string
    lastSeenAt: Date
    installedAt: Date
    lastCalibratedAt: Date | null
    samplingIntervalMin: number
  }> = []

  const assetRows = await prisma.asset.findMany({
    select: { id: true, code: true, installedAt: true, seed: true, assetType: { select: { key: true } } },
  })

  for (const asset of assetRows) {
    const plan = SENSOR_PLAN[asset.assetType.key] ?? SENSOR_PLAN.transformer
    plan.forEach((sensor, index) => {
      // A small number of sensors are deliberately degraded so the Sensor Status panel
      // has something real to report.
      const roll = seededRange(asset.seed, 90 + index, 0, 1)

      // Calibration history. Instruments are calibrated on a cycle rather than left
      // alone since installation, and without this the trust engine charged every
      // sensor in the fleet the maximum drift penalty for a record that simply was not
      // seeded — which then propagated into every confidence figure in the product.
      //
      // Most sit inside a two-year cycle; a deliberate minority are overdue, so the
      // data-health page has a genuine finding to report rather than a uniform wall.
      const calibrationRoll = seededRange(asset.seed, 111 + index, 0, 1)
      const monthsAgo =
        calibrationRoll > 0.88
          ? seededRange(asset.seed, 115 + index, 26, 40) // overdue
          : seededRange(asset.seed, 115 + index, 1, 20)
      const calibratedAt = NOW - monthsAgo * 30 * DAY
      sensorRows.push({
        code: `${asset.code}-${sensor.kind.toUpperCase().slice(0, 4)}-${index + 1}`,
        assetId: asset.id,
        kind: sensor.kind,
        unit: sensor.unit,
        minValue: sensor.min,
        maxValue: sensor.max,
        accuracy: round(seededRange(asset.seed, 95 + index, 0.1, 0.9), 2),
        status: roll > 0.97 ? 'faulty' : roll > 0.92 ? 'degraded' : 'healthy',
        firmware: seededPick(asset.seed, 99 + index, ['1.4.2', '1.5.0', '2.0.1', '2.1.3']),
        lastSeenAt: new Date(NOW - seededRange(asset.seed, 103 + index, 0, 4) * MIN),
        installedAt: asset.installedAt,
        // Never calibrated before installation, however the roll came out.
        lastCalibratedAt: new Date(Math.max(calibratedAt, asset.installedAt.getTime())),
        // Watch-list plant is polled at the fast cadence the recorded telemetry actually
        // carries; the rest of the fleet is polled hourly, which is what it is given.
        // The expectation has to match the instrumentation or every slow-polled sensor
        // reads as broken.
        samplingIntervalMin: watchlist.has(asset.code) ? WATCH_INTERVAL_MIN : STANDARD_INTERVAL_MIN,
      })
    })
  }

  await createManyChunked('sensors', sensorRows, (chunk) =>
    prisma.sensor.createMany({ data: chunk }),
  )
}
/**
 * Recorded telemetry. Written from the same model the live view samples, so a chart
 * spanning "yesterday to five minutes from now" is one continuous curve.
 *
 * Resolution is tiered on purpose: an hour is plenty for a week-long trend, but the last
 * two days are stored at higher resolution because that is what operators actually zoom
 * into, and the watch-list assets finer still.
 */
async function seedReadings(profiles: Map<string, AssetProfile>, watchlist: Set<string>) {
  const assets = await prisma.asset.findMany({
    select: { id: true, code: true, regionId: true, region: { select: { code: true } } },
  })
  const sensors = await prisma.sensor.findMany({
    select: { id: true, assetId: true, kind: true },
  })
  const sensorsByAsset = new Map<string, typeof sensors>()
  for (const sensor of sensors) {
    const list = sensorsByAsset.get(sensor.assetId) ?? []
    list.push(sensor)
    sensorsByAsset.set(sensor.assetId, list)
  }

  const regionSeeds = new Map(REGIONS.map((r) => [r.code, r]))
  const envCache = new Map<string, ReturnType<typeof sampleEnvironment>>()
  const envAt = (regionCode: string, ts: number) => {
    const key = `${regionCode}:${ts}`
    let env = envCache.get(key)
    if (!env) {
      env = sampleEnvironment(regionSeeds.get(regionCode)!, ts)
      envCache.set(key, env)
    }
    return env
  }

  const rows: Array<Record<string, unknown>> = []
  let flushed = 0

  const flush = async (force = false) => {
    if (rows.length >= 2000 || (force && rows.length > 0)) {
      const batch = rows.splice(0, rows.length)
      await prisma.sensorReading.createMany({ data: batch as never })
      flushed += batch.length
    }
  }

  const valueFor = (
    kind: string,
    s: ReturnType<typeof sampleAsset>,
  ): number | null => {
    switch (kind) {
      case 'load':
        return s.loadPct
      case 'temperature':
        return s.tempC
      case 'voltage':
        return s.voltageKv
      case 'current':
        return s.currentA
      case 'power_factor':
        return s.powerFactor
      case 'thd':
        return s.thdPct
      case 'frequency':
        return null // system-level, derived at snapshot time rather than per asset
      default:
        return null
    }
  }

  for (const asset of assets) {
    const profile = profiles.get(asset.code)
    if (!profile) continue
    const assetSensors = sensorsByAsset.get(asset.id) ?? []
    const isWatch = watchlist.has(asset.code)

    // Tier 1 — 5 days before yesterday, 4-hourly.
    const instants = new Set<number>()
    for (let t = NOW - 7 * DAY; t < NOW - 2 * DAY; t += 4 * HOUR) instants.add(t)
    // Tier 2 — the last 48 hours at the standard cadence.
    for (let t = NOW - 2 * DAY; t <= NOW; t += STANDARD_INTERVAL_MIN * MIN) instants.add(t)
    // Tier 3 — the last 24 hours at 15 minutes, for the watch list only.
    //
    // The window and the cadence both match what the data-quality assessment expects
    // (24 hours at 15 minutes). They were previously 12 hours, which meant a perfectly
    // healthy watch-list asset was permanently scored as missing a fifth of its
    // readings — a seeding artefact that the 4.0 uncertainty engine then correctly, and
    // very visibly, degraded every prediction for.
    if (isWatch) {
      for (let t = NOW - 24 * HOUR; t <= NOW; t += WATCH_INTERVAL_MIN * MIN) instants.add(t)
    }

    // Deduplicated: the tiers overlap at the hour boundaries, and a doubled row would
    // inflate the coverage figure rather than reflect it.
    const timestamps = [...instants].sort((a, b) => a - b)

    for (const ts of timestamps) {
      const sample = sampleAsset(profile, ts, envAt(asset.region.code, ts))
      for (const sensor of assetSensors) {
        const value = valueFor(sensor.kind, sample)
        if (value === null) continue
        rows.push({
          sensorId: sensor.id,
          assetId: asset.id,
          kind: sensor.kind,
          ts: new Date(ts),
          value: round(value, 3),
          quality: 'good',
        })
      }
      await flush()
    }
  }
  await flush(true)
  log('sensor readings', `${flushed.toLocaleString()} rows`)
}

// ─────────────────────────────────────────────────────────────────────────────
// 5. Failure DNA library
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The reference signature: T-108 at the pinned demo instant under the demo scenario.
 * Historical patterns are then placed at chosen distances from it, which is what turns
 * "87 % similar" into a measured quantity rather than a caption.
 */
function demoReferenceDna(profiles: AssetProfile[]): DnaVector {
  const ts = demoBaseTimestamp(NOW)
  const perturbation = demoPerturbation(NOW)
  const region = REGIONS.find((r) => r.code === DEMO_REGION_CODE)!
  const envAt = (t: number) => sampleEnvironment(region, t, { tempDeltaC: perturbation.tempDeltaC })

  // Run the real snapshot so neighbour stress and renewable variability are the values
  // the runtime will compute, then reduce with the shared extractor. Deriving the library
  // from anything else would make the seeded similarity scores unreproducible.
  const snapshot = buildGridSnapshot(profiles, ts, { perturbation })
  const target = snapshot.assets.find((a) => a.profile.code === DEMO_ASSET_CODE)!
  const regionSnapshot = snapshot.regions.find((r) => r.code === DEMO_REGION_CODE)!

  return extractAssetDna({
    profile: target.profile,
    ts,
    sample: target.sample,
    env: envAt(ts),
    envAt,
    perturbation,
    neighbourStress: target.neighbourStress,
    renewableVariabilityPct: regionSnapshot.renewableVariabilityPct,
  })
}

async function seedFailureDna(reference: DnaVector) {
  const regions = await prisma.region.findMany()
  const regionIdByCode = new Map(regions.map((r) => [r.code, r.id]))

  for (const pattern of FAILURE_PATTERNS) {
    const dna = deriveDnaAtSimilarity(reference, pattern.targetSimilarity, pattern.direction)
    const created = await prisma.failurePattern.create({
      data: {
        code: pattern.code,
        name: pattern.name,
        nameAr: pattern.nameAr,
        description: pattern.description,
        descriptionAr: pattern.descriptionAr,
        eventType: pattern.eventType,
        severity: pattern.severity,
        occurredAt: new Date(NOW - pattern.daysAgo * DAY),
        regionId: pattern.regionCode ? regionIdByCode.get(pattern.regionCode)! : null,
        assetTypeKey: pattern.assetTypeKey,
        durationMin: pattern.durationMin,
        customersAffect: pattern.customersAffect,
        energyLostMwh: pattern.energyLostMwh,
        outcome: pattern.outcome,
        outcomeAr: pattern.outcomeAr,
      },
    })

    await prisma.failureDna.create({
      data: {
        patternId: created.id,
        code: pattern.dnaCode,
        version: 'dna-v1',
        voltageDeviation: dna.voltageDeviation,
        frequencyDeviation: dna.frequencyDeviation,
        loadPercentage: dna.loadPercentage,
        temperature: dna.temperature,
        loadGrowthRate: dna.loadGrowthRate,
        renewableVariability: dna.renewableVariability,
        assetAge: dna.assetAge,
        maintenanceScore: dna.maintenanceScore,
        neighboringFailures: dna.neighboringFailures,
        weatherTemperature: dna.weatherTemperature,
      },
    })
  }

  const check = FAILURE_PATTERNS.slice(0, 3).map((p) => {
    const dna = deriveDnaAtSimilarity(reference, p.targetSimilarity, p.direction)
    return `${p.dnaCode}=${dnaSimilarity(reference, dna).toFixed(1)}%`
  })
  log('failure DNA', `${FAILURE_PATTERNS.length} patterns (${check.join(', ')})`)
}

// ─────────────────────────────────────────────────────────────────────────────
// 6. Reference data
// ─────────────────────────────────────────────────────────────────────────────

async function seedReferenceData(userIdByRole: Map<string, string>) {
  for (const model of MODELS) {
    const created = await prisma.modelVersion.create({
      data: {
        key: model.key,
        name: model.name,
        nameAr: model.nameAr,
        version: model.version,
        task: model.task,
        algorithm: model.algorithm,
        accuracy: model.accuracy,
        precisionScore: model.precisionScore,
        recallScore: model.recallScore,
        f1Score: model.f1Score,
        trainedAt: new Date(NOW - model.daysAgo * DAY),
        datasetVersion: model.datasetVersion,
        status: model.status,
        driftScore: model.driftScore,
        driftStatus: model.driftStatus,
        notes: model.notes,
        notesAr: model.notesAr,
      },
    })
    await prisma.modelAudit.createMany({
      data: [
        {
          modelVersionId: created.id,
          ts: new Date(NOW - model.daysAgo * DAY),
          actor: 'ml-pipeline',
          action: 'trained',
          detail: `Trained on ${model.datasetVersion} (synthetic benchmark).`,
          detailAr: `دُرّب على ${model.datasetVersion} (مجموعة اصطناعية).`,
        },
        {
          modelVersionId: created.id,
          ts: new Date(NOW - model.daysAgo * DAY + 2 * HOUR),
          actor: 'admin@nabdh.sa',
          action: model.status === 'active' ? 'promoted' : 'staged',
          detail:
            model.status === 'active'
              ? `Promoted to active after review; F1 ${model.f1Score}.`
              : `Held in staging pending validation; F1 ${model.f1Score}.`,
          detailAr:
            model.status === 'active'
              ? `رُقّي إلى النشط بعد المراجعة، معامل F1 ${model.f1Score}.`
              : `أُبقي في مرحلة التهيئة بانتظار التحقق، معامل F1 ${model.f1Score}.`,
        },
        {
          modelVersionId: created.id,
          ts: new Date(NOW - 2 * DAY),
          actor: 'drift-monitor',
          action: 'drift_check',
          detail: `Drift score ${model.driftScore} — ${model.driftStatus}.`,
          detailAr: `مؤشر الانحراف ${model.driftScore} — ${model.driftStatus}.`,
        },
      ],
    })
  }
  log('models', `${MODELS.length} versions`)

  const demoAsset = await prisma.asset.findUnique({ where: { code: DEMO_ASSET_CODE } })
  await prisma.scenario.createMany({
    data: SCENARIOS.map((s) => ({
      key: s.key,
      name: s.name,
      nameAr: s.nameAr,
      description: s.description,
      descriptionAr: s.descriptionAr,
      category: s.category,
      icon: s.icon,
      isBuiltIn: true,
      targetAssetId: s.assetOutage ? demoAsset?.id ?? null : null,
      targetRegion: s.key === 'heatwave_and_surge' ? DEMO_REGION_CODE : null,
      loadDeltaPct: s.loadDeltaPct ?? 0,
      solarDeltaPct: s.solarDeltaPct ?? 0,
      windDeltaPct: s.windDeltaPct ?? 0,
      tempDeltaC: s.tempDeltaC ?? 0,
      assetOutage: s.assetOutage ?? false,
      lineOutage: s.lineOutage ?? false,
      batteryOutage: s.batteryOutage ?? false,
      evSurgeMw: s.evSurgeMw ?? 0,
      industrialMw: s.industrialMw ?? 0,
      durationMin: s.durationMin,
      createdById: userIdByRole.get('system_admin') ?? null,
    })),
  })

  await prisma.integration.createMany({ data: INTEGRATIONS })
  await prisma.appSetting.createMany({
    data: SETTINGS.map((s) => ({
      key: s.key,
      value: s.value,
      valueType: s.valueType,
      groupKey: s.groupKey,
      label: s.label,
      labelAr: s.labelAr,
      description: s.description,
      descriptionAr: s.descriptionAr,
      minValue: s.minValue ?? null,
      maxValue: s.maxValue ?? null,
    })),
  })
  await prisma.escalationRule.createMany({ data: ESCALATION_RULES })
  log('reference data', `${SCENARIOS.length} scenarios, ${INTEGRATIONS.length} integrations, ${SETTINGS.length} settings`)
}

// ─────────────────────────────────────────────────────────────────────────────
// 7. Predictions, recommendations and alerts
// ─────────────────────────────────────────────────────────────────────────────

async function seedOperations(profiles: Map<string, AssetProfile>, userIdByRole: Map<string, string>) {
  const patterns = await prisma.failurePattern.findMany({ include: { dna: true } })
  const patternRefs: DnaPatternRef[] = patterns
    .filter((p) => p.dna)
    .map((p) => ({
      code: p.dna!.code,
      name: p.name,
      nameAr: p.nameAr,
      eventType: p.eventType,
      severity: p.severity,
      occurredAt: p.occurredAt.getTime(),
      dna: {
        voltageDeviation: p.dna!.voltageDeviation,
        frequencyDeviation: p.dna!.frequencyDeviation,
        loadPercentage: p.dna!.loadPercentage,
        temperature: p.dna!.temperature,
        loadGrowthRate: p.dna!.loadGrowthRate,
        renewableVariability: p.dna!.renewableVariability,
        assetAge: p.dna!.assetAge,
        maintenanceScore: p.dna!.maintenanceScore,
        neighboringFailures: p.dna!.neighboringFailures,
        weatherTemperature: p.dna!.weatherTemperature,
      },
    }))

  const all = [...profiles.values()]
  const snapshot = buildGridSnapshot(all, NOW, { patterns: patternRefs })

  // Grid stress is strongly diurnal, so seeding only at "now" would leave the alert and
  // prediction history empty whenever the seed happens to run overnight. Predictions are
  // therefore generated at the most recent afternoon peak as well as at the present
  // instant — which is also how a continuously running platform would have recorded them.
  const lastLocalPeak = (() => {
    const ksaOffset = 3 * HOUR
    const localMidnight = Math.floor((NOW + ksaOffset) / DAY) * DAY - ksaOffset
    const peak = localMidnight + 15 * HOUR
    return peak > NOW ? peak - DAY : peak
  })()
  const peakSnapshot = buildGridSnapshot(all, lastLocalPeak, { patterns: patternRefs })

  const byRisk = (a: { risk: { score: number } }, b: { risk: { score: number } }) =>
    b.risk.score - a.risk.score

  type Target = {
    asset: (typeof snapshot.assets)[number]
    ts: number
    source: typeof snapshot
    historic: boolean
  }
  const targets: Target[] = [
    ...[...peakSnapshot.assets].sort(byRisk).slice(0, 14).map((asset) => ({
      asset,
      ts: lastLocalPeak,
      source: peakSnapshot,
      historic: true,
    })),
    ...[...snapshot.assets].sort(byRisk).slice(0, 6).map((asset) => ({
      asset,
      ts: NOW,
      source: snapshot,
      historic: false,
    })),
  ]

  const assets = await prisma.asset.findMany({ select: { id: true, code: true, regionId: true } })
  const assetIdByCode = new Map(assets.map((a) => [a.code, a.id]))
  const regionIdByAsset = new Map(assets.map((a) => [a.code, a.regionId]))

  const riskModel = await prisma.modelVersion.findFirst({ where: { key: 'risk-engine' } })
  const operatorId = userIdByRole.get('control_operator') ?? null
  const engineerId = userIdByRole.get('engineer') ?? null

  let predictionCount = 0
  let recommendationCount = 0
  let alertCount = 0

  for (const [index, entry] of targets.entries()) {
    const target = entry.asset
    const observedAt = entry.ts
    const region = REGIONS.find((r) => r.code === target.profile.regionCode)!
    const envAt = (ts: number) => sampleEnvironment(region, ts)
    const prediction = predictFailure(target.profile, observedAt, envAt, {
      dnaSimilarity: target.dnaSimilarity,
      neighbourStress: target.neighbourStress,
      horizonMin: 180,
      stepMin: 5,
    })

    const explanationInput = {
      profile: target.profile,
      sample: target.sample,
      risk: target.risk,
      dnaSimilarity: target.dnaSimilarity,
      etaMinutes: prediction.etaMinutes,
      confidence: prediction.confidence,
    }

    const code = `PRD-${String(1000 + index)}`
    const created = await prisma.prediction.create({
      data: {
        code,
        assetId: assetIdByCode.get(target.profile.code)!,
        createdAt: new Date(observedAt - seededInt(target.profile.seed, 2, 1, 40) * MIN),
        horizonMin: prediction.horizonMin,
        eventType: prediction.eventType,
        probability: prediction.probability,
        confidence: prediction.confidence,
        severity: prediction.severity,
        riskScore: prediction.currentRisk,
        etaMinutes: prediction.etaMinutes ?? prediction.horizonMin,
        etaAt: new Date(prediction.etaAt ?? observedAt + prediction.horizonMin * MIN),
        // A prediction whose window has already passed without the event is recorded as
        // prevented or expired rather than being quietly deleted.
        status: !entry.historic
          ? 'active'
          : index % 5 === 0
            ? 'prevented'
            : (prediction.etaAt ?? observedAt) < NOW
              ? 'expired'
              : 'active',
        source: 'live',
        modelVersionId: riskModel!.id,
        dnaCode: target.dnaCode,
        dnaSimilarity: target.dnaSimilarity,
        explanation: describePrediction(explanationInput, 'en'),
        explanationAr: describePrediction(explanationInput, 'ar'),
      },
    })
    predictionCount += 1

    await prisma.predictionFactor.createMany({
      data: target.risk.topFactors.map((factor) => ({
        predictionId: created.id,
        key: factor.key,
        label: FACTOR_COPY[factor.key].label,
        labelAr: FACTOR_COPY[factor.key].labelAr,
        contribution: factor.contribution,
        value: factor.rawValue,
        unit: factor.unit,
        direction: 'increase',
        detail: FACTOR_COPY[factor.key].detail,
        detailAr: FACTOR_COPY[factor.key].detailAr,
      })),
    })

    // Recommendations, sized and scored against the real resource picture.
    const resources = resourcesFor(target.profile, entry.source.assets)
    const ctx = {
      profile: target.profile,
      ts: observedAt,
      envAt,
      dnaSimilarity: target.dnaSimilarity,
      neighbourStress: target.neighbourStress,
      resources,
      evaluateAtMin: prediction.etaMinutes ?? 45,
    }
    const evaluated = evaluateCandidates(ctx, generateCandidates(ctx))
    for (const [rank, action] of evaluated.slice(0, 4).entries()) {
      await prisma.recommendation.create({
        data: {
          code: `REC-${String(2000 + recommendationCount)}`,
          predictionId: created.id,
          assetId: assetIdByCode.get(target.profile.code)!,
          actionType: action.actionType,
          title: action.title,
          titleAr: action.titleAr,
          description: action.description,
          descriptionAr: action.descriptionAr,
          magnitudeMw: action.magnitudeMw,
          riskReduction: action.riskReduction,
          costEstimateSar: action.costEstimateSar,
          durationMin: action.durationMin,
          priority: rank + 1,
          status: 'proposed',
          requiresRole: action.requiresRole,
          createdById: null,
        },
      })
      recommendationCount += 1
    }

    // Alerts for the ones that clear the control-centre threshold.
    if (prediction.currentRisk >= 55) {
      const level = riskToAlertLevel(prediction.currentRisk)
      const lifecycle = (['new', 'acknowledged', 'in_progress', 'resolved', 'closed'] as const)[
        entry.historic ? index % 5 : 0
      ]
      const acknowledged = lifecycle !== 'new'
      const alert = await prisma.alert.create({
        data: {
          code: `ALT-${String(3000 + alertCount)}`,
          level,
          assetId: assetIdByCode.get(target.profile.code)!,
          regionId: regionIdByAsset.get(target.profile.code)!,
          predictionId: created.id,
          title: `${target.profile.code} — ${prediction.eventType.replace(/_/g, ' ')} predicted`,
          titleAr: `${target.profile.code} — تنبؤ بحدوث ${eventTypeAr(prediction.eventType)}`,
          message: `Risk ${prediction.currentRisk.toFixed(0)} % with ${(prediction.confidence * 100).toFixed(0)} % confidence. Loading ${target.sample.loadPct.toFixed(0)} %, temperature ${target.sample.tempC.toFixed(0)} °C.`,
          messageAr: `درجة الخطر ${prediction.currentRisk.toFixed(0)}٪ بثقة ${(prediction.confidence * 100).toFixed(0)}٪. التحميل ${target.sample.loadPct.toFixed(0)}٪ والحرارة ${target.sample.tempC.toFixed(0)} مئوية.`,
          riskScore: prediction.currentRisk,
          confidence: prediction.confidence,
          expectedAt: prediction.etaAt ? new Date(prediction.etaAt) : null,
          recommendedAct: evaluated[0]?.title ?? 'Review asset condition',
          recommendedActAr: evaluated[0]?.titleAr ?? 'مراجعة حالة الأصل',
          status: lifecycle,
          escalationLevel: prediction.currentRisk >= 95 ? 3 : prediction.currentRisk >= 85 ? 2 : 1,
          channels: prediction.currentRisk >= 85 ? 'dashboard,email,sms' : 'dashboard,email',
          assignedToId: acknowledged ? operatorId : null,
          acknowledgedById: acknowledged ? operatorId : null,
          resolvedAt:
            lifecycle === 'resolved' || lifecycle === 'closed'
              ? new Date(observedAt + 38 * MIN)
              : null,
          resolution:
            lifecycle === 'resolved' || lifecycle === 'closed'
              ? 'Load transferred to the adjacent feeder; temperature returned inside the envelope.'
              : null,
          resolutionAr:
            lifecycle === 'resolved' || lifecycle === 'closed'
              ? 'حُوّل الحمل إلى المغذي المجاور وعادت الحرارة داخل النطاق التشغيلي.'
              : null,
          acknowledgedAt: acknowledged ? new Date(observedAt - 12 * MIN) : null,
          createdAt: new Date(observedAt - seededInt(target.profile.seed, 5, 2, 90) * MIN),
        },
      })
      alertCount += 1

      await prisma.notification.create({
        data: {
          userId: operatorId,
          roleKey: 'control_operator',
          alertId: alert.id,
          level: level === 'emergency' || level === 'critical' ? 'critical' : 'warning',
          title: alert.title,
          titleAr: alert.titleAr,
          body: alert.message,
          bodyAr: alert.messageAr,
          link: `/alerts?focus=${alert.code}`,
          channel: 'dashboard',
          status: 'sent',
          sentAt: alert.createdAt,
        },
      })
    }
  }

  log('predictions', `${predictionCount} predictions, ${recommendationCount} recommendations, ${alertCount} alerts`)

  // A handful of informational alerts so the Alerts Center shows the full level range.
  const infoAssets = snapshot.assets.filter((a) => a.risk.score < 45).slice(0, 6)
  for (const [i, asset] of infoAssets.entries()) {
    await prisma.alert.create({
      data: {
        code: `ALT-${String(3500 + i)}`,
        level: i % 2 === 0 ? 'information' : 'warning',
        assetId: assetIdByCode.get(asset.profile.code)!,
        regionId: regionIdByAsset.get(asset.profile.code)!,
        title: `${asset.profile.code} — sensor firmware behind current release`,
        titleAr: `${asset.profile.code} — إصدار برمجية المستشعر أقدم من الحالي`,
        message: 'Condition monitoring firmware is two minor versions behind. No operational impact.',
        messageAr: 'برمجية مراقبة الحالة أقدم بإصدارين فرعيين. لا يوجد أثر تشغيلي.',
        riskScore: asset.risk.score,
        confidence: 0.99,
        recommendedAct: 'Schedule firmware update in the next maintenance window',
        recommendedActAr: 'جدولة تحديث البرمجية في نافذة الصيانة القادمة',
        status: i < 2 ? 'resolved' : 'new',
        resolvedAt: i < 2 ? new Date(NOW - 3 * HOUR) : null,
        resolution: i < 2 ? 'Firmware updated to 2.1.3 during the night window.' : null,
        resolutionAr: i < 2 ? 'حُدّثت البرمجية إلى 2.1.3 خلال النافذة الليلية.' : null,
        assignedToId: engineerId,
        createdAt: new Date(NOW - (i + 1) * 5 * HOUR),
      },
    })
  }

  return snapshot
}

function eventTypeAr(eventType: string): string {
  const map: Record<string, string> = {
    transformer_overload: 'تحميل زائد على المحول',
    thermal_runaway: 'انفلات حراري',
    voltage_collapse: 'انهيار جهد',
    frequency_deviation: 'انحراف تردد',
    line_overload: 'تحميل زائد على الخط',
    insulation_failure: 'انهيار عزل',
    battery_derate: 'خفض قدرة البطاريات',
    renewable_ramp_shortfall: 'نقص في صعود المتجددة',
    cascading_outage: 'انقطاع متتابع',
  }
  return map[eventType] ?? eventType
}

// ─────────────────────────────────────────────────────────────────────────────
// 8. Incidents, replay timelines and maintenance
// ─────────────────────────────────────────────────────────────────────────────

const INCIDENTS = [
  {
    code: 'INC-2041',
    title: 'Bulk transformer thermal trip during evening peak',
    titleAr: 'فصل حراري لمحول رئيسي أثناء الذروة المسائية',
    description:
      'Top-oil temperature crossed the emergency limit after an unplanned industrial block was energised during a high-ambient evening.',
    descriptionAr:
      'تجاوزت حرارة الزيت العلوي الحد الطارئ بعد تشغيل كتلة أحمال صناعية غير مخططة في أمسية مرتفعة الحرارة.',
    regionCode: 'RYD',
    severity: 'critical',
    daysAgo: 34,
    durationMin: 196,
    customersAffected: 118_400,
    energyNotServedMwh: 164.2,
    economicImpactSar: 4_920_000,
    rootCause: 'Loading exceeded the cyclic rating for 47 minutes with one cooling stage unavailable.',
    rootCauseAr: 'تجاوز التحميل التصنيف الدوري لمدة ٤٧ دقيقة مع تعطل إحدى مراحل التبريد.',
    nabdhLeadTimeMin: 43,
    thresholdLeadMin: 4,
    wasPrevented: false,
  },
  {
    code: 'INC-2038',
    title: 'Voltage excursion on the coastal 132 kV network',
    titleAr: 'انحراف جهد على شبكة ١٣٢ ك.ف الساحلية',
    description:
      'Reactive support fell short as photovoltaic output collapsed into the evening ramp.',
    descriptionAr: 'قصور في دعم القدرة غير الفعّالة مع انهيار الإنتاج الكهروضوئي عند الصعود المسائي.',
    regionCode: 'JED',
    severity: 'high',
    daysAgo: 61,
    durationMin: 48,
    customersAffected: 42_100,
    energyNotServedMwh: 21.6,
    economicImpactSar: 780_000,
    rootCause: 'Capacitor bank switching lagged the solar ramp-down by roughly eleven minutes.',
    rootCauseAr: 'تأخر تشغيل مجموعة المكثفات نحو إحدى عشرة دقيقة عن هبوط الإنتاج الشمسي.',
    nabdhLeadTimeMin: 27,
    thresholdLeadMin: 2,
    wasPrevented: false,
  },
  {
    code: 'INC-2044',
    title: 'Prevented overload at Qassim bulk supply point',
    titleAr: 'تفادي تحميل زائد في نقطة التغذية الرئيسية بالقصيم',
    description:
      'An early prediction allowed 14 MW to be transferred before the thermal limit was approached. No customer was interrupted.',
    descriptionAr:
      'أتاح تنبؤ مبكر تحويل ١٤ ميجاواط قبل الاقتراب من الحد الحراري، ولم ينقطع التيار عن أي مشترك.',
    regionCode: 'QSM',
    severity: 'medium',
    daysAgo: 12,
    durationMin: 0,
    customersAffected: 0,
    energyNotServedMwh: 0,
    economicImpactSar: 0,
    rootCause: 'Forecast miss on irrigation pumping load coinciding with a dust event.',
    rootCauseAr: 'خطأ في توقع أحمال ضخ الري تزامن مع حدث غباري.',
    nabdhLeadTimeMin: 52,
    thresholdLeadMin: 0,
    wasPrevented: true,
  },
  {
    code: 'INC-2031',
    title: 'Line overload during planned outage — Madinah',
    titleAr: 'تحميل زائد على خط أثناء فصل مخطط — المدينة المنورة',
    description: 'The parallel 380 kV path exceeded its continuous rating while a circuit was out for maintenance.',
    descriptionAr: 'تجاوز مسار ٣٨٠ ك.ف الموازي حده المستمر أثناء فصل دائرة للصيانة.',
    regionCode: 'MED',
    severity: 'high',
    daysAgo: 88,
    durationMin: 134,
    customersAffected: 0,
    energyNotServedMwh: 0,
    economicImpactSar: 210_000,
    rootCause: 'Outage scheduled against a load forecast that under-predicted the afternoon peak by 8 %.',
    rootCauseAr: 'جُدول الفصل وفق توقع أحمال قلّل ذروة بعد الظهر بنسبة ٨٪.',
    nabdhLeadTimeMin: 66,
    thresholdLeadMin: 9,
    wasPrevented: false,
  },
  {
    code: 'INC-2027',
    title: 'Battery fleet derate in extreme ambient — Dammam',
    titleAr: 'خفض قدرة أسطول البطاريات في حرارة قصوى — الدمام',
    description: 'Storage available power fell to 68 % of rating, reducing the evening reserve margin.',
    descriptionAr: 'انخفضت القدرة المتاحة للتخزين إلى ٦٨٪ من التصنيف، مما قلّص احتياطي المساء.',
    regionCode: 'DMM',
    severity: 'medium',
    daysAgo: 21,
    durationMin: 228,
    customersAffected: 0,
    energyNotServedMwh: 0,
    economicImpactSar: 95_000,
    rootCause: 'Chiller capacity sized for a 45 °C design point; ambient reached 47.4 °C.',
    rootCauseAr: 'صُممت سعة التبريد لنقطة ٤٥ مئوية بينما بلغت حرارة الجو ٤٧٫٤ مئوية.',
    nabdhLeadTimeMin: 94,
    thresholdLeadMin: 0,
    wasPrevented: true,
  },
  {
    code: 'INC-2019',
    title: 'Frequency excursion after generation trip',
    titleAr: 'انحراف تردد بعد فصل وحدة توليد',
    description: 'Loss of 1 180 MW produced a 0.31 Hz excursion arrested by primary reserve.',
    descriptionAr: 'أدى فقد ١١٨٠ ميجاواط إلى انحراف قدره ٠٫٣١ هرتز أوقفه الاحتياطي الأولي.',
    regionCode: null,
    severity: 'high',
    daysAgo: 119,
    durationMin: 17,
    customersAffected: 0,
    energyNotServedMwh: 0,
    economicImpactSar: 340_000,
    rootCause: 'Gas turbine trip on lube-oil pressure; reserve responded as designed.',
    rootCauseAr: 'فصل توربين غازي على ضغط زيت التزييت، واستجاب الاحتياطي كما هو مصمم.',
    nabdhLeadTimeMin: 0,
    thresholdLeadMin: 0,
    wasPrevented: false,
  },
  {
    code: 'INC-2012',
    title: 'Dust storm photovoltaic shortfall — Qassim',
    titleAr: 'نقص الإنتاج الشمسي بسبب عاصفة غبارية — القصيم',
    description: 'Irradiance fell 62 % over three hours, removing 780 MW of expected output.',
    descriptionAr: 'انخفض الإشعاع ٦٢٪ خلال ثلاث ساعات، مما أزال ٧٨٠ ميجاواط من الإنتاج المتوقع.',
    regionCode: 'QSM',
    severity: 'medium',
    daysAgo: 143,
    durationMin: 187,
    customersAffected: 0,
    energyNotServedMwh: 0,
    economicImpactSar: 620_000,
    rootCause: 'Dust index not yet an input to the renewable forecast at the time.',
    rootCauseAr: 'لم يكن مؤشر الغبار حينها ضمن مدخلات توقع الإنتاج المتجدد.',
    nabdhLeadTimeMin: 38,
    thresholdLeadMin: 0,
    wasPrevented: false,
  },
  {
    code: 'INC-2008',
    title: 'Prevented cascade after transformer isolation — Dammam',
    titleAr: 'تفادي انتشار العطل بعد عزل محول — الدمام',
    description:
      'When one unit was isolated, load was pre-emptively spread across three feeders rather than two, holding every asset below 80 %.',
    descriptionAr:
      'عند عزل إحدى الوحدات وُزّع الحمل استباقيًا على ثلاثة مغذيات بدل اثنين، مما أبقى جميع الأصول دون ٨٠٪.',
    regionCode: 'DMM',
    severity: 'high',
    daysAgo: 167,
    durationMin: 0,
    customersAffected: 0,
    energyNotServedMwh: 0,
    economicImpactSar: 0,
    rootCause: 'Planned isolation for bushing replacement.',
    rootCauseAr: 'عزل مخطط لاستبدال عازل طرفي.',
    nabdhLeadTimeMin: 71,
    thresholdLeadMin: 0,
    wasPrevented: true,
  },
]

async function seedIncidents(profiles: Map<string, AssetProfile>) {
  const regions = await prisma.region.findMany()
  const regionIdByCode = new Map(regions.map((r) => [r.code, r.id]))
  const assets = await prisma.asset.findMany({
    select: { id: true, code: true, region: { select: { code: true } }, assetType: { select: { key: true } } },
  })

  for (const incident of INCIDENTS) {
    const candidate =
      assets.find(
        (a) => a.region.code === incident.regionCode && a.assetType.key === 'transformer',
      ) ?? assets[0]
    const startedAt = NOW - incident.daysAgo * DAY

    const created = await prisma.incident.create({
      data: {
        code: incident.code,
        title: incident.title,
        titleAr: incident.titleAr,
        description: incident.description,
        descriptionAr: incident.descriptionAr,
        assetId: candidate.id,
        regionId: incident.regionCode ? regionIdByCode.get(incident.regionCode)! : null,
        severity: incident.severity,
        status: 'closed',
        startedAt: new Date(startedAt),
        endedAt: new Date(startedAt + incident.durationMin * MIN),
        durationMin: incident.durationMin,
        customersAffected: incident.customersAffected,
        energyNotServedMwh: incident.energyNotServedMwh,
        economicImpactSar: incident.economicImpactSar,
        rootCause: incident.rootCause,
        rootCauseAr: incident.rootCauseAr,
        detectedBy: incident.nabdhLeadTimeMin > incident.thresholdLeadMin ? 'nabdh' : 'threshold',
        nabdhLeadTimeMin: incident.nabdhLeadTimeMin,
        thresholdLeadMin: incident.thresholdLeadMin,
        wasPrevented: incident.wasPrevented,
      },
    })

    // Replay timeline: 90 minutes of build-up at 5-minute frames, with the two detection
    // markers placed at their respective lead times.
    const profile = profiles.get(candidate.code)
    const region = REGIONS.find((r) => r.code === (incident.regionCode ?? 'RYD'))!
    const frames: Array<Record<string, unknown>> = []
    const totalFrames = 19
    for (let i = 0; i < totalFrames; i += 1) {
      const offsetMin = -90 + i * 5
      const ts = startedAt + offsetMin * MIN
      const ramp = clamp((offsetMin + 90) / 90, 0, 1)
      const env = sampleEnvironment(region, ts, { tempDeltaC: 2 * ramp })
      const sample = profile
        ? sampleAsset(profile, ts, env, { loadDeltaPct: 26 * ramp, regionCode: region.code })
        : null

      const riskScore = round(clamp(22 + ramp * ramp * 72, 0, 100), 1)
      const marker =
        offsetMin === -Math.round(incident.nabdhLeadTimeMin / 5) * 5 && incident.nabdhLeadTimeMin > 0
          ? 'nabdh_detect'
          : offsetMin === -Math.round(incident.thresholdLeadMin / 5) * 5 && incident.thresholdLeadMin > 0
            ? 'threshold_trigger'
            : offsetMin === 0
              ? incident.wasPrevented
                ? 'prevented'
                : 'failure'
              : null

      frames.push({
        incidentId: created.id,
        ts: new Date(ts),
        offsetMin,
        phase: ramp < 0.35 ? 'normal' : ramp < 0.6 ? 'rising' : ramp < 0.85 ? 'critical' : 'event',
        label:
          ramp < 0.35
            ? 'Within normal envelope'
            : ramp < 0.6
              ? 'Load and temperature rising'
              : ramp < 0.85
                ? 'Approaching operating limit'
                : incident.wasPrevented
                  ? 'Preventive action in force'
                  : 'Limit exceeded',
        labelAr:
          ramp < 0.35
            ? 'ضمن النطاق الطبيعي'
            : ramp < 0.6
              ? 'ارتفاع الحمل والحرارة'
              : ramp < 0.85
                ? 'الاقتراب من الحد التشغيلي'
                : incident.wasPrevented
                  ? 'الإجراء الوقائي ساري'
                  : 'تجاوز الحد التشغيلي',
        loadPct: round(sample?.loadPct ?? 60 + ramp * 45, 2),
        tempC: round(sample?.tempC ?? 60 + ramp * 50, 2),
        voltageKv: round(sample?.voltageKv ?? 132 * (1 - ramp * 0.03), 2),
        frequencyHz: round(60 - ramp * 0.06, 3),
        riskScore,
        marker,
        note:
          marker === 'nabdh_detect'
            ? `NABDH raised an early warning ${incident.nabdhLeadTimeMin} minutes before the event.`
            : marker === 'threshold_trigger'
              ? `The conventional threshold alarm fired ${incident.thresholdLeadMin} minutes before the event.`
              : marker === 'failure'
                ? 'The operating limit was exceeded and the asset tripped.'
                : marker === 'prevented'
                  ? 'The preventive action held the asset inside its envelope.'
                  : '',
        noteAr:
          marker === 'nabdh_detect'
            ? `أطلق نَبْض إنذارًا مبكرًا قبل ${incident.nabdhLeadTimeMin} دقيقة من الحدث.`
            : marker === 'threshold_trigger'
              ? `أطلق إنذار العتبة التقليدي قبل ${incident.thresholdLeadMin} دقيقة من الحدث.`
              : marker === 'failure'
                ? 'تم تجاوز الحد التشغيلي وفصل الأصل.'
                : marker === 'prevented'
                  ? 'أبقى الإجراء الوقائي الأصل داخل نطاقه التشغيلي.'
                  : '',
      })
    }
    await prisma.incidentEvent.createMany({ data: frames as never })
  }
  log('incidents', `${INCIDENTS.length} incidents with replay timelines`)
}

async function seedMaintenance(userIdByRole: Map<string, string>) {
  const assets = await prisma.asset.findMany({
    select: {
      id: true,
      code: true,
      seed: true,
      healthScore: true,
      lastMaintenanceAt: true,
      nextMaintenanceAt: true,
      assetType: { select: { key: true } },
    },
  })
  const technicianId = userIdByRole.get('maintenance_engineer') ?? null
  const rows: Array<Record<string, unknown>> = []
  let counter = 0

  for (const asset of assets) {
    const records = seededInt(asset.seed, 200, 1, 3)
    for (let i = 0; i < records; i += 1) {
      const daysAgo = seededRange(asset.seed, 210 + i, 30, 900)
      const kind = seededPick(asset.seed, 220 + i, ['preventive', 'inspection', 'corrective', 'predictive'])
      const healthBefore = clamp(asset.healthScore - seededRange(asset.seed, 230 + i, 2, 12), 20, 100)
      counter += 1
      rows.push({
        code: `WO-${String(5000 + counter)}`,
        assetId: asset.id,
        kind,
        status: 'completed',
        priority: seededPick(asset.seed, 240 + i, ['low', 'medium', 'high']),
        scheduledAt: new Date(NOW - (daysAgo + 3) * DAY),
        performedAt: new Date(NOW - daysAgo * DAY),
        technicianId,
        findings:
          kind === 'corrective'
            ? 'Cooling fan bearing noise and elevated oil temperature under load.'
            : 'Dissolved gas analysis within limits; bushing tan-delta stable.',
        findingsAr:
          kind === 'corrective'
            ? 'ضجيج في محمل مروحة التبريد وارتفاع حرارة الزيت تحت الحمل.'
            : 'تحليل الغازات الذائبة ضمن الحدود، ومعامل الفقد للعازل الطرفي مستقر.',
        actions:
          kind === 'corrective'
            ? 'Fan bank replaced and oil topped up; unit returned to full rating.'
            : 'Routine inspection completed; silica gel replaced.',
        actionsAr:
          kind === 'corrective'
            ? 'استُبدلت مجموعة المراوح وأُكمل الزيت، وأُعيدت الوحدة لقدرتها الكاملة.'
            : 'أُنجز الفحص الدوري واستُبدل هلام السيليكا.',
        costSar: round(seededRange(asset.seed, 250 + i, 3_000, 180_000), 0),
        downtimeMin: kind === 'corrective' ? seededInt(asset.seed, 260 + i, 60, 480) : 0,
        healthBefore: round(healthBefore, 1),
        healthAfter: round(clamp(healthBefore + seededRange(asset.seed, 270 + i, 1, 9), 20, 100), 1),
        nextDueAt: asset.nextMaintenanceAt,
        createdAt: new Date(NOW - (daysAgo + 5) * DAY),
      })
    }
  }

  // Upcoming and overdue work so the Predictive Maintenance page has live entries.
  const attention = assets
    .filter((a) => a.healthScore < 72)
    .sort((a, b) => a.healthScore - b.healthScore)
    .slice(0, 24)
  for (const [i, asset] of attention.entries()) {
    const overdue = i % 3 === 0
    counter += 1
    rows.push({
      code: `WO-${String(5000 + counter)}`,
      assetId: asset.id,
      kind: 'predictive',
      status: overdue ? 'overdue' : 'scheduled',
      priority: asset.healthScore < 58 ? 'critical' : 'high',
      scheduledAt: new Date(NOW + (overdue ? -1 : 1) * seededRange(asset.seed, 300, 2, 40) * DAY),
      performedAt: null,
      technicianId,
      findings: 'Raised automatically from the condition model: health and thermal margin trending down.',
      findingsAr: 'أُنشئ تلقائيًا من نموذج الحالة: تراجع في مؤشر الصحة والهامش الحراري.',
      actions: 'Thermographic survey, dissolved gas analysis and cooling system check.',
      actionsAr: 'مسح حراري وتحليل غازات ذائبة وفحص منظومة التبريد.',
      costSar: 0,
      downtimeMin: 0,
      healthBefore: asset.healthScore,
      healthAfter: asset.healthScore,
      nextDueAt: asset.nextMaintenanceAt,
      createdAt: new Date(NOW - 2 * DAY),
    })
  }

  await createManyChunked('maintenance', rows, (chunk) =>
    prisma.maintenanceRecord.createMany({ data: chunk as never }),
  )
}

// ─────────────────────────────────────────────────────────────────────────────
// 9. Forecasts and audit trail
// ─────────────────────────────────────────────────────────────────────────────

async function seedForecasts(profiles: Map<string, AssetProfile>) {
  const list = [...profiles.values()]
  const regions = await prisma.region.findMany()
  const regionIdByCode = new Map(regions.map((r) => [r.code, r.id]))
  const loadModel = await prisma.modelVersion.findFirst({ where: { key: 'load-forecaster' } })

  const nationalCapacity = REGIONS.reduce((sum, r) => sum + r.capacityMw, 0)
  const national = forecastLoad(list, NOW, { horizonMin: 24 * 60, stepMin: 60 })

  // Annotated because the national rows carry a null regionId, which would otherwise
  // narrow the array's inferred type and reject the per-region rows appended below.
  const loadRows: Array<{
    regionId: string | null
    ts: Date
    horizonMin: number
    expectedMw: number
    lowerMw: number
    upperMw: number
    confidence: number
    capacityMw: number
    reserveMarginPct: number
    modelVersionId: string
  }> = national.points.map((p) => ({
    regionId: null,
    ts: new Date(p.ts),
    horizonMin: p.offsetMin,
    expectedMw: p.expectedMw,
    lowerMw: p.lowerMw,
    upperMw: p.upperMw,
    confidence: p.confidence,
    capacityMw: national.capacityMw,
    reserveMarginPct: round(((national.capacityMw - p.expectedMw) / national.capacityMw) * 100, 2),
    modelVersionId: loadModel!.id,
  }))

  for (const region of REGIONS) {
    const forecast = forecastLoad(list, NOW, { horizonMin: 24 * 60, stepMin: 60, regionCode: region.code })
    for (const p of forecast.points) {
      loadRows.push({
        regionId: regionIdByCode.get(region.code)!,
        ts: new Date(p.ts),
        horizonMin: p.offsetMin,
        expectedMw: p.expectedMw,
        lowerMw: p.lowerMw,
        upperMw: p.upperMw,
        confidence: p.confidence,
        capacityMw: forecast.capacityMw,
        reserveMarginPct: round(
          forecast.capacityMw > 0 ? ((forecast.capacityMw - p.expectedMw) / forecast.capacityMw) * 100 : 0,
          2,
        ),
        modelVersionId: loadModel!.id,
      })
    }
  }

  await createManyChunked('load forecasts', loadRows, (chunk) =>
    prisma.loadForecast.createMany({ data: chunk as never }),
  )

  const renewableRows: Array<Record<string, unknown>> = []
  for (const kind of ['solar', 'wind'] as const) {
    const national2 = forecastRenewable(list, NOW, kind, { horizonMin: 24 * 60, stepMin: 60 })
    for (const p of national2.points) {
      renewableRows.push({
        regionId: null,
        siteId: null,
        kind,
        ts: new Date(p.ts),
        horizonMin: p.offsetMin,
        expectedMw: p.expectedMw,
        lowerMw: p.lowerMw,
        upperMw: p.upperMw,
        confidence: p.confidence,
        variabilityMw: national2.variabilityMw,
        modelVersionId: loadModel!.id,
      })
    }
    for (const region of REGIONS) {
      const forecast = forecastRenewable(list, NOW, kind, {
        horizonMin: 24 * 60,
        stepMin: 180,
        regionCode: region.code,
      })
      if (forecast.capacityMw <= 0) continue
      for (const p of forecast.points) {
        renewableRows.push({
          regionId: regionIdByCode.get(region.code)!,
          siteId: null,
          kind,
          ts: new Date(p.ts),
          horizonMin: p.offsetMin,
          expectedMw: p.expectedMw,
          lowerMw: p.lowerMw,
          upperMw: p.upperMw,
          confidence: p.confidence,
          variabilityMw: forecast.variabilityMw,
          modelVersionId: loadModel!.id,
        })
      }
    }
  }

  await createManyChunked('renewable forecasts', renewableRows, (chunk) =>
    prisma.renewableForecast.createMany({ data: chunk as never }),
  )
  log('forecast capacity', `${round(nationalCapacity, 0).toLocaleString()} MW installed`)
}

async function seedAuditTrail() {
  const users = await prisma.user.findMany({
    include: { roles: { include: { role: true } } },
  })
  if (users.length === 0) return

  const actions = [
    ['auth.login', 'session', 'Signed in from the control room workstation'],
    ['prediction.viewed', 'prediction', 'Opened prediction detail'],
    ['alert.acknowledged', 'alert', 'Acknowledged a high-level alert'],
    ['simulation.started', 'simulation', 'Ran a what-if simulation'],
    ['scenario.created', 'scenario', 'Created a custom scenario'],
    ['recommendation.approved', 'recommendation', 'Approved a preventive action'],
    ['settings.changed', 'app_setting', 'Adjusted a risk weight'],
    ['user.role_changed', 'user', 'Changed a user role assignment'],
    ['auth.logout', 'session', 'Signed out'],
  ]

  const rows: Array<Record<string, unknown>> = []
  for (let i = 0; i < 64; i += 1) {
    const user = users[i % users.length]
    const [action, entity, detail] = actions[i % actions.length]
    rows.push({
      ts: new Date(NOW - i * 47 * MIN),
      userId: user.id,
      userEmail: user.email,
      userRole: user.roles[0]?.role.key ?? 'viewer',
      action,
      entity,
      entityId: null,
      ip: '10.20.0.' + (12 + (i % 40)),
      userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) NABDH-Console',
      beforeJson: action === 'settings.changed' ? JSON.stringify({ value: '0.24' }) : null,
      afterJson: action === 'settings.changed' ? JSON.stringify({ value: '0.26' }) : null,
      status: 'success',
      detail,
    })
  }
  await prisma.auditLog.createMany({ data: rows as never })
  log('audit log', `${rows.length} entries`)
}

// ─────────────────────────────────────────────────────────────────────────────
// Orchestrator
// ─────────────────────────────────────────────────────────────────────────────

async function main() {
  console.log('\nNABDH | نَبْض — seeding demonstration grid\n')
  const started = Date.now()

  await reset()
  const userIdByRole = await seedAccessControl()
  await seedTopology()

  const assetRows = await prisma.asset.findMany({
    include: { assetType: { select: { key: true } }, region: { select: { code: true } } },
  })
  const profiles = new Map<string, AssetProfile>(
    assetRows.map((a) => [a.code, toAssetProfile(a)]),
  )
  log('profiles', `${profiles.size} assets loaded into the engine`)

  if (!profiles.has(DEMO_ASSET_CODE)) {
    throw new Error(`Demo asset ${DEMO_ASSET_CODE} was not generated`)
  }

  await seedFailureDna(demoReferenceDna([...profiles.values()]))
  await seedReferenceData(userIdByRole)
  await seedWeather()

  // Watch list gets the finest reading resolution.
  const watchlist = deriveWatchlist(profiles.values())
  await seedSensors(watchlist)
  await seedReadings(profiles, watchlist)

  const snapshot = await seedOperations(profiles, userIdByRole)
  await seedIncidents(profiles)
  await seedMaintenance(userIdByRole)
  await seedForecasts(profiles)
  const intelligence = await seedIntelligence(prisma, profiles, userIdByRole, log)
  const twins = await seedTwins(prisma, profiles, userIdByRole, log)

  // 4.0 last: the verification and decision-memory rows are computed from the engines
  // against the twins and profiles seeded above, so they need both to exist first.
  await seedAssurance({ prisma, now: NOW, profiles, userIdByRole, demoAssetCode: DEMO_ASSET_CODE })
  log('assurance', 'physics constraints, verification history, decision memory, discoveries')

  const connectorCount = await seedIntegrations({ prisma, now: NOW })
  log('integrations', `${connectorCount} demo connectors with mappings and health history`)

  await seedAuditTrail()

  console.log('')
  log('grid check', `${snapshot.totalLoadMw.toLocaleString()} MW load / ${snapshot.totalCapacityMw.toLocaleString()} MW capacity`)
  log('grid check', `stability ${snapshot.stabilityIndex}, frequency ${snapshot.frequencyHz} Hz, renewables ${snapshot.renewableSharePct}%`)
  log(
    'intelligence',
    `${intelligence.agents} agents, ${intelligence.graphNodes} graph nodes / ${intelligence.graphEdges} edges, ` +
      `${intelligence.knowledgeNodes} knowledge nodes, ${intelligence.strategies} strategies`,
  )
  log(
    'digital twin',
    `${twins.twins} twins, ${twins.members} placed members, ${twins.bindings} sensor bindings, ` +
      `${twins.scenarios} preset scenarios`,
  )
  log('done', `${((Date.now() - started) / 1000).toFixed(1)}s`)

  if (process.env.DEMO_ACCOUNTS_ENABLED !== 'false') {
    console.log('\n  Demo accounts (password: ' + (process.env.DEMO_ACCOUNT_PASSWORD || 'NabdhDemo!2026') + ')')
    for (const user of DEMO_USERS) console.log(`    ${user.email.padEnd(24)} ${user.roleKey}`)
  }
  console.log('')
}

main()
  .catch((error) => {
    console.error('\nSeed failed:\n', error)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })
