/**
 * The Integration Hub's transformation layer.
 *
 * Everything a record goes through between arriving from a source and being fit for the
 * digital twin: unit conversion, schema and range validation, duplicate and outlier
 * detection, and the six-factor quality score that follows it downstream.
 *
 * Two design commitments shape this file:
 *
 *   1. **A rejected record is rejected with a reason.** Nothing is silently coerced,
 *      clamped or dropped. A pipeline that quietly fixes bad data is a pipeline that
 *      hides a broken sensor, and the whole point of the quality score downstream is to
 *      make that visible.
 *
 *   2. **The original unit survives the conversion.** An operator debugging a wrong
 *      reading needs to see that the source sent kilowatts and the platform stored
 *      megawatts — not just the megawatts.
 *
 * Pure functions: no clock reads, no randomness, no I/O. The service layer supplies the
 * timestamp and the connector configuration.
 */

import { clamp, round } from './rng'

// ─────────────────────────────────────────────────────────────────────────────
// Connector vocabulary
// ─────────────────────────────────────────────────────────────────────────────

export const CONNECTOR_KINDS = [
  'scada',
  'ems',
  'dms',
  'ami',
  'iot',
  'mqtt',
  'opcua',
  'rest',
  'websocket',
  'csv',
  'weather',
  'gis',
  'sap',
  'cmms',
  'historian',
] as const
export type ConnectorKind = (typeof CONNECTOR_KINDS)[number]

export const CONNECTOR_STATUSES = [
  'connected',
  'connecting',
  'degraded',
  'disconnected',
  'error',
  'disabled',
] as const
export type ConnectorStatus = (typeof CONNECTOR_STATUSES)[number]

/**
 * Whether a connector reaches a real system.
 *
 * Kept separate from `status` on purpose. A demo connector can legitimately be
 * "connected" to its generator — what must never happen is the interface saying
 * "CONNECTED" in a way that reads as a live utility link. The label shown to a user is
 * derived from this pair, never from the status alone.
 */
export type ConnectorMode = 'live' | 'demo'

/** The label a connector shows, given both facts. Never one without the other. */
export function connectorLabel(mode: ConnectorMode, status: ConnectorStatus): string {
  if (mode === 'demo') {
    return status === 'connected' ? 'demo_connected' : `demo_${status}`
  }
  return status
}

// ─────────────────────────────────────────────────────────────────────────────
// Unit normalisation
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Conversions to the platform's canonical units.
 *
 * Expressed as explicit pairs rather than a general dimensional-analysis library: the set
 * of units a grid actually sends is small and closed, and a table that can be read in one
 * screen is worth more here than one that can convert furlongs.
 */
const CONVERSIONS: Record<string, { to: string; apply: (value: number) => number }> = {
  W: { to: 'MW', apply: (v) => v / 1_000_000 },
  kW: { to: 'MW', apply: (v) => v / 1_000 },
  MW: { to: 'MW', apply: (v) => v },
  GW: { to: 'MW', apply: (v) => v * 1_000 },
  Wh: { to: 'MWh', apply: (v) => v / 1_000_000 },
  kWh: { to: 'MWh', apply: (v) => v / 1_000 },
  MWh: { to: 'MWh', apply: (v) => v },
  F: { to: 'C', apply: (v) => ((v - 32) * 5) / 9 },
  K: { to: 'C', apply: (v) => v - 273.15 },
  C: { to: 'C', apply: (v) => v },
  V: { to: 'kV', apply: (v) => v / 1_000 },
  kV: { to: 'kV', apply: (v) => v },
  mA: { to: 'A', apply: (v) => v / 1_000 },
  A: { to: 'A', apply: (v) => v },
  kA: { to: 'A', apply: (v) => v * 1_000 },
  Hz: { to: 'Hz', apply: (v) => v },
  pct: { to: '%', apply: (v) => v },
  '%': { to: '%', apply: (v) => v },
  ratio: { to: '%', apply: (v) => v * 100 },
}

export interface ConversionResult {
  value: number
  unit: string
  /** Preserved so the original reading is never lost behind its converted form. */
  sourceValue: number
  sourceUnit: string
  converted: boolean
}

/**
 * Convert to the canonical unit for a quantity.
 *
 * An unrecognised unit is passed through unchanged and flagged rather than guessed at:
 * inventing a conversion factor is how a 400 kV reading becomes 400 V.
 */
export function normaliseUnit(value: number, sourceUnit: string): ConversionResult {
  const rule = CONVERSIONS[sourceUnit]
  if (!rule) {
    return { value, unit: sourceUnit, sourceValue: value, sourceUnit, converted: false }
  }
  return {
    value: round(rule.apply(value), 6),
    unit: rule.to,
    sourceValue: value,
    sourceUnit,
    converted: rule.to !== sourceUnit,
  }
}

/** Whether a unit pair is one this layer knows how to convert between. */
export function canConvert(sourceUnit: string, targetUnit: string): boolean {
  const rule = CONVERSIONS[sourceUnit]
  return Boolean(rule) && rule.to === targetUnit
}

// ─────────────────────────────────────────────────────────────────────────────
// Mapping and validation
// ─────────────────────────────────────────────────────────────────────────────

export interface FieldMapping {
  sourceField: string
  targetField: string
  dataType: 'number' | 'string' | 'boolean' | 'timestamp'
  sourceUnit: string
  targetUnit: string
  defaultValue: string
  minValue: number | null
  maxValue: number | null
  isRequired: boolean
}

export type RejectionReason =
  | 'missing_required'
  | 'wrong_type'
  | 'out_of_range'
  | 'bad_timestamp'
  | 'unknown_asset'
  | 'duplicate'
  | 'outlier'
  | 'unmapped'

export interface NormalisedField {
  targetField: string
  value: number | string | boolean
  unit: string
  sourceField: string
  sourceValue: unknown
  sourceUnit: string
  converted: boolean
}

export interface RecordRejection {
  sourceField: string
  reason: RejectionReason
  detail: string
  detailAr: string
}

export interface NormalisedRecord {
  assetCode: string
  ts: number
  fields: NormalisedField[]
  rejections: RecordRejection[]
  accepted: boolean
}

export interface NormaliseOptions {
  mappings: FieldMapping[]
  /** Asset codes the platform knows about. An unknown asset is a rejection, not a create. */
  knownAssets: Set<string>
  /** Fingerprints already seen in this batch, for duplicate detection. */
  seen?: Set<string>
  /** Reference values per target field, for outlier detection. */
  expected?: Record<string, { mean: number; spread: number }>
  /** Records more than this many minutes from `now` are rejected as bad timestamps. */
  maxSkewMin?: number
  now: number
}

const DEFAULT_SKEW_MIN = 120

/**
 * Turn one raw record into the platform's model, or explain why it cannot be.
 *
 * Ordered so the cheapest rejections happen first and a record that fails several checks
 * reports all of them — an operator fixing a feed wants the whole list, not the first
 * problem followed by four more round trips.
 */
export function normaliseRecord(
  raw: Record<string, unknown>,
  options: NormaliseOptions,
): NormalisedRecord {
  const rejections: RecordRejection[] = []
  const fields: NormalisedField[] = []
  const maxSkewMs = (options.maxSkewMin ?? DEFAULT_SKEW_MIN) * 60_000

  // ── Asset ────────────────────────────────────────────────────────────────
  const assetRaw = raw.assetCode ?? raw.asset_id ?? raw.asset ?? raw.tag
  const assetCode = typeof assetRaw === 'string' ? assetRaw.trim().toUpperCase() : ''
  if (!assetCode) {
    rejections.push({
      sourceField: 'assetCode',
      reason: 'missing_required',
      detail: 'The record carries no asset identifier',
      detailAr: 'السجل لا يحمل معرّف أصل',
    })
  } else if (!options.knownAssets.has(assetCode)) {
    rejections.push({
      sourceField: 'assetCode',
      reason: 'unknown_asset',
      detail: `${assetCode} is not in the asset register`,
      detailAr: `${assetCode} غير موجود في سجل الأصول`,
    })
  }

  // ── Timestamp ────────────────────────────────────────────────────────────
  const tsRaw = raw.ts ?? raw.timestamp ?? raw.time
  let ts = options.now
  if (tsRaw !== undefined && tsRaw !== null) {
    const parsed =
      typeof tsRaw === 'number' ? tsRaw : Date.parse(String(tsRaw))
    if (!Number.isFinite(parsed)) {
      rejections.push({
        sourceField: 'ts',
        reason: 'bad_timestamp',
        detail: `"${String(tsRaw)}" is not a timestamp`,
        detailAr: `"${String(tsRaw)}" ليس طابعًا زمنيًا`,
      })
    } else if (Math.abs(parsed - options.now) > maxSkewMs) {
      const driftMin = Math.round(Math.abs(parsed - options.now) / 60_000)
      rejections.push({
        sourceField: 'ts',
        reason: 'bad_timestamp',
        detail: `Timestamp is ${driftMin} min from now, beyond the accepted skew`,
        detailAr: `الطابع الزمني يبعد ${driftMin} دقيقة عن الآن، خارج الانحراف المقبول`,
      })
    } else {
      ts = parsed
    }
  }

  // ── Fields ───────────────────────────────────────────────────────────────
  for (const mapping of options.mappings) {
    const present = Object.prototype.hasOwnProperty.call(raw, mapping.sourceField)
    let value = raw[mapping.sourceField]

    if (!present || value === null || value === undefined || value === '') {
      if (mapping.defaultValue !== '') {
        value = mapping.dataType === 'number' ? Number(mapping.defaultValue) : mapping.defaultValue
      } else if (mapping.isRequired) {
        rejections.push({
          sourceField: mapping.sourceField,
          reason: 'missing_required',
          detail: `Required field "${mapping.sourceField}" is absent`,
          detailAr: `الحقل المطلوب "${mapping.sourceField}" غير موجود`,
        })
        continue
      } else {
        continue
      }
    }

    if (mapping.dataType === 'number') {
      const numeric = typeof value === 'number' ? value : Number(value)
      if (!Number.isFinite(numeric)) {
        rejections.push({
          sourceField: mapping.sourceField,
          reason: 'wrong_type',
          detail: `"${String(value)}" is not a number`,
          detailAr: `"${String(value)}" ليس رقمًا`,
        })
        continue
      }

      const conversion = normaliseUnit(numeric, mapping.sourceUnit)

      // Range is checked against the *converted* value, because the bounds are expressed
      // in the platform's units. Checking before conversion would compare megawatts to a
      // kilowatt limit.
      if (mapping.minValue !== null && conversion.value < mapping.minValue) {
        rejections.push({
          sourceField: mapping.sourceField,
          reason: 'out_of_range',
          detail: `${round(conversion.value, 3)} ${conversion.unit} is below the minimum of ${mapping.minValue}`,
          detailAr: `${round(conversion.value, 3)} ${conversion.unit} دون الحد الأدنى ${mapping.minValue}`,
        })
        continue
      }
      if (mapping.maxValue !== null && conversion.value > mapping.maxValue) {
        rejections.push({
          sourceField: mapping.sourceField,
          reason: 'out_of_range',
          detail: `${round(conversion.value, 3)} ${conversion.unit} is above the maximum of ${mapping.maxValue}`,
          detailAr: `${round(conversion.value, 3)} ${conversion.unit} فوق الحد الأقصى ${mapping.maxValue}`,
        })
        continue
      }

      // Outlier detection is separate from range: a value can be inside its physical
      // bounds and still be nothing like what this channel has been reading.
      const reference = options.expected?.[mapping.targetField]
      if (reference && reference.spread > 0) {
        const z = Math.abs(conversion.value - reference.mean) / reference.spread
        if (z > 6) {
          rejections.push({
            sourceField: mapping.sourceField,
            reason: 'outlier',
            detail: `${round(conversion.value, 2)} ${conversion.unit} is ${round(z, 1)} spreads from this channel's recent mean`,
            detailAr: `${round(conversion.value, 2)} ${conversion.unit} تبعد ${round(z, 1)} انتشارًا عن متوسط القناة الأخير`,
          })
          continue
        }
      }

      fields.push({
        targetField: mapping.targetField,
        value: conversion.value,
        unit: conversion.unit,
        sourceField: mapping.sourceField,
        sourceValue: conversion.sourceValue,
        sourceUnit: conversion.sourceUnit,
        converted: conversion.converted,
      })
      continue
    }

    if (mapping.dataType === 'boolean') {
      const truthy = value === true || value === 'true' || value === 1 || value === '1'
      const falsy = value === false || value === 'false' || value === 0 || value === '0'
      if (!truthy && !falsy) {
        rejections.push({
          sourceField: mapping.sourceField,
          reason: 'wrong_type',
          detail: `"${String(value)}" is not a boolean`,
          detailAr: `"${String(value)}" ليس قيمة منطقية`,
        })
        continue
      }
      fields.push({
        targetField: mapping.targetField,
        value: truthy,
        unit: '',
        sourceField: mapping.sourceField,
        sourceValue: value,
        sourceUnit: '',
        converted: false,
      })
      continue
    }

    fields.push({
      targetField: mapping.targetField,
      value: String(value),
      unit: '',
      sourceField: mapping.sourceField,
      sourceValue: value,
      sourceUnit: '',
      converted: false,
    })
  }

  // ── Duplicates ───────────────────────────────────────────────────────────
  // Checked last because the fingerprint needs the normalised asset and timestamp, and a
  // record that failed either of those is already rejected for a better reason.
  if (options.seen && assetCode && rejections.length === 0) {
    const print = `${assetCode}@${ts}`
    if (options.seen.has(print)) {
      rejections.push({
        sourceField: 'ts',
        reason: 'duplicate',
        detail: `A record for ${assetCode} at this instant has already been accepted`,
        detailAr: `سُجّل بالفعل سجل لـ${assetCode} في هذه اللحظة`,
      })
    } else {
      options.seen.add(print)
    }
  }

  return { assetCode, ts, fields, rejections, accepted: rejections.length === 0 }
}

// ─────────────────────────────────────────────────────────────────────────────
// Data quality (§Integration Data Quality)
// ─────────────────────────────────────────────────────────────────────────────

export interface QualityFactors {
  completeness: number
  freshness: number
  validity: number
  consistency: number
  availability: number
  trust: number
}

export interface SourceQuality extends QualityFactors {
  score: number
  band: 'excellent' | 'good' | 'limited' | 'low'
  /** The factor doing the most damage, so the score points at a remedy. */
  weakest: { key: keyof QualityFactors; value: number }
}

const QUALITY_WEIGHTS: Record<keyof QualityFactors, number> = {
  completeness: 0.22,
  freshness: 0.2,
  validity: 0.22,
  consistency: 0.12,
  availability: 0.14,
  trust: 0.1,
}

export interface QualityInput {
  /** Records the source was expected to send in the window, and what arrived. */
  expectedRecords: number
  receivedRecords: number
  acceptedRecords: number
  /** Age of the most recent record, in minutes. */
  ageMin: number
  /** The cadence the source is supposed to report at, in minutes. */
  expectedIntervalMin: number
  /** Successful polls over attempted polls in the window, 0–1. */
  uptimeRatio: number
  /** Share of accepted records whose values agreed with their peers, 0–1. */
  agreementRatio: number
  /** Whether the source is a live system or a generator. */
  mode: ConnectorMode
}

/**
 * Score a source across six factors.
 *
 * The `trust` factor is where the demo/live distinction lands: a synthetic source is
 * internally consistent by construction, so scoring it identically to a live feed would
 * make the demo look *better* instrumented than reality. It is capped instead, and the
 * cap is what stops a wall of green from being mistaken for a wall of measurement.
 */
export function assessSourceQuality(input: QualityInput): SourceQuality {
  const expected = Math.max(1, input.expectedRecords)

  const completeness = clamp((input.receivedRecords / expected) * 100, 0, 100)
  const validity = input.receivedRecords
    ? clamp((input.acceptedRecords / input.receivedRecords) * 100, 0, 100)
    : 0

  // Freshness is measured against the source's own cadence: a feed that reports hourly is
  // not stale at fifty minutes, and a feed that reports every five seconds is.
  const staleAfter = Math.max(1, input.expectedIntervalMin) * 3
  const freshness = clamp((1 - input.ageMin / staleAfter) * 100, 0, 100)

  const availability = clamp(input.uptimeRatio * 100, 0, 100)
  const consistency = clamp(input.agreementRatio * 100, 0, 100)

  // A generator agrees with itself perfectly. Reporting that as full trust would be
  // measuring the generator rather than the grid.
  const trust = input.mode === 'demo' ? 70 : clamp(input.agreementRatio * 100, 0, 100)

  const factors: QualityFactors = {
    completeness: round(completeness, 1),
    freshness: round(freshness, 1),
    validity: round(validity, 1),
    consistency: round(consistency, 1),
    availability: round(availability, 1),
    trust: round(trust, 1),
  }

  const weighted = (Object.keys(QUALITY_WEIGHTS) as Array<keyof QualityFactors>).reduce(
    (sum, key) => sum + factors[key] * QUALITY_WEIGHTS[key],
    0,
  )

  // Capped by the weakest factor, the same rule the decision-confidence engine uses. A
  // source delivering two records in five is not a "good" source however clean, fresh and
  // consistent those two are — and a plain weighted mean would let five healthy factors
  // carry the one that has failed.
  const weakestValue = Math.min(...Object.values(factors))
  const score = round(clamp(Math.min(weighted, weakestValue + 25), 0, 100), 1)

  const weakestKey = (Object.keys(factors) as Array<keyof QualityFactors>).reduce((worst, key) =>
    factors[key] * QUALITY_WEIGHTS[key] < factors[worst] * QUALITY_WEIGHTS[worst] ? key : worst,
  )

  return {
    ...factors,
    score,
    band: score >= 90 ? 'excellent' : score >= 75 ? 'good' : score >= 55 ? 'limited' : 'low',
    weakest: { key: weakestKey, value: factors[weakestKey] },
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Connector health
// ─────────────────────────────────────────────────────────────────────────────

export interface HealthInput {
  status: ConnectorStatus
  quality: number
  latencyMs: number
  /** The connector's own declared timeout — latency is judged against it, not a constant. */
  timeoutMs: number
  errorCount: number
  recordsReceived: number
}

/**
 * A connector's overall health, 0–100.
 *
 * Latency is scored relative to the connector's configured timeout rather than an
 * absolute figure: 400 ms is excellent for a nightly SAP pull and alarming for a SCADA
 * poll, and one constant cannot be right for both.
 */
export function connectorHealth(input: HealthInput): number {
  if (input.status === 'disabled') return 0
  if (input.status === 'disconnected' || input.status === 'error') return 0

  const latencyShare = clamp(input.latencyMs / Math.max(1, input.timeoutMs), 0, 1)
  const latencyScore = (1 - latencyShare) * 100

  const errorRate = input.recordsReceived
    ? clamp(input.errorCount / input.recordsReceived, 0, 1)
    : input.errorCount > 0
      ? 1
      : 0
  const reliability = (1 - errorRate) * 100

  const base = input.quality * 0.5 + latencyScore * 0.25 + reliability * 0.25

  // A degraded connector is degraded whatever its numbers say; capping here keeps the
  // headline honest when a feed is limping but still technically answering.
  const ceiling = input.status === 'degraded' ? 65 : input.status === 'connecting' ? 50 : 100
  return round(clamp(Math.min(base, ceiling), 0, 100), 1)
}

// ─────────────────────────────────────────────────────────────────────────────
// Failure injection (demo only)
// ─────────────────────────────────────────────────────────────────────────────

export const FAULT_KINDS = [
  'connection_loss',
  'high_latency',
  'stale_data',
  'sensor_failure',
  'bad_data',
  'partial_data',
] as const
export type FaultKind = (typeof FAULT_KINDS)[number]

export interface FaultEffect {
  status: ConnectorStatus
  /** Multiplier applied to the connector's latency. */
  latencyFactor: number
  /** Share of records that fail validation, 0–1. */
  corruptionRate: number
  /** Share of expected records that never arrive, 0–1. */
  dropRate: number
  /** Extra minutes added to the age of the newest record. */
  extraAgeMin: number
  label: string
  labelAr: string
}

/**
 * What each injected fault does.
 *
 * These drive the *same* quality and health functions as a real problem would, which is
 * what makes the exercise worth anything: an operator watching data quality fall and twin
 * fidelity follow is watching the real dependency, not an animation of one.
 */
export function faultEffect(kind: FaultKind): FaultEffect {
  switch (kind) {
    case 'connection_loss':
      return {
        status: 'disconnected',
        latencyFactor: 1,
        corruptionRate: 0,
        dropRate: 1,
        extraAgeMin: 30,
        label: 'Connection lost',
        labelAr: 'انقطاع الاتصال',
      }
    case 'high_latency':
      return {
        status: 'degraded',
        latencyFactor: 12,
        corruptionRate: 0,
        dropRate: 0.1,
        extraAgeMin: 2,
        label: 'High latency',
        labelAr: 'زمن استجابة مرتفع',
      }
    case 'stale_data':
      return {
        status: 'degraded',
        latencyFactor: 1,
        corruptionRate: 0,
        dropRate: 0.6,
        extraAgeMin: 45,
        label: 'Stale data',
        labelAr: 'بيانات قديمة',
      }
    case 'sensor_failure':
      return {
        status: 'degraded',
        latencyFactor: 1,
        corruptionRate: 0.25,
        dropRate: 0.25,
        extraAgeMin: 5,
        label: 'Sensor failure',
        labelAr: 'عطل حساس',
      }
    case 'bad_data':
      return {
        status: 'degraded',
        latencyFactor: 1.5,
        corruptionRate: 0.55,
        dropRate: 0,
        extraAgeMin: 0,
        label: 'Bad data',
        labelAr: 'بيانات معطوبة',
      }
    case 'partial_data':
    default:
      return {
        status: 'degraded',
        latencyFactor: 1.2,
        corruptionRate: 0.05,
        dropRate: 0.4,
        extraAgeMin: 1,
        label: 'Partial data',
        labelAr: 'بيانات جزئية',
      }
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// The pipeline, as a description
// ─────────────────────────────────────────────────────────────────────────────

export const PIPELINE_STAGES = [
  'source',
  'connector',
  'gateway',
  'validation',
  'normalization',
  'mapping',
  'quality',
  'event_bus',
  'twin',
  'ai',
] as const
export type PipelineStage = (typeof PIPELINE_STAGES)[number]

export interface PipelineStep {
  stage: PipelineStage
  label: string
  labelAr: string
  /** Records entering this stage in the last batch. */
  recordsIn: number
  recordsOut: number
  detail: string
  detailAr: string
}

/**
 * The flow viewer's data.
 *
 * Built from a batch's actual counts so the drop between validation and normalisation is
 * the number of records that really failed, not a decorative taper.
 */
export function pipelineSteps(batch: {
  received: number
  accepted: number
  rejected: number
  mapped: number
  quality: number
}): PipelineStep[] {
  const step = (
    stage: PipelineStage,
    label: string,
    labelAr: string,
    recordsIn: number,
    recordsOut: number,
    detail: string,
    detailAr: string,
  ): PipelineStep => ({ stage, label, labelAr, recordsIn, recordsOut, detail, detailAr })

  return [
    step('source', 'Source', 'المصدر', batch.received, batch.received, 'Records offered', 'سجلات معروضة'),
    step('connector', 'Connector', 'الموصّل', batch.received, batch.received, 'Received', 'مستلمة'),
    step('gateway', 'Gateway', 'البوابة', batch.received, batch.received, 'Admitted', 'مقبولة للدخول'),
    step(
      'validation',
      'Validation',
      'التحقق',
      batch.received,
      batch.accepted,
      `${batch.rejected} rejected`,
      `${batch.rejected} مرفوضة`,
    ),
    step('normalization', 'Normalisation', 'التوحيد', batch.accepted, batch.accepted, 'Units converted', 'تحويل الوحدات'),
    step('mapping', 'Mapping', 'الربط', batch.accepted, batch.mapped, 'Fields mapped', 'ربط الحقول'),
    step(
      'quality',
      'Data quality',
      'جودة البيانات',
      batch.mapped,
      batch.mapped,
      `${round(batch.quality, 1)} / 100`,
      `${round(batch.quality, 1)} / ١٠٠`,
    ),
    step('event_bus', 'Event bus', 'ناقل الأحداث', batch.mapped, batch.mapped, 'Published', 'منشورة'),
    step('twin', 'Digital twin', 'التوأم الرقمي', batch.mapped, batch.mapped, 'Applied', 'مطبّقة'),
    step('ai', 'NABDH AI', 'ذكاء نَبْض', batch.mapped, batch.mapped, 'Available to the engines', 'متاحة للمحركات'),
  ]
}
