// Live health probes for the services we expect to find in the Dezky stack.
// Targets are env-driven (HEALTH_*): the defaults are the docker-compose
// service names so dev needs no env, and production sets in-cluster DNS /
// host addresses in platform-api-config.yaml. Setting a probe's env to
// "disabled" omits that service from the report entirely — used for tiers
// that aren't deployed yet (OCIS/Collabora in prod).

import { Injectable } from '@nestjs/common'
import { InjectConnection } from '@nestjs/mongoose'
import type { Connection } from 'mongoose'
import * as net from 'node:net'

export type ProbeStatus = 'ok' | 'warn' | 'bad'

export interface ProbeResult {
  id: string
  name: string
  role: string
  status: ProbeStatus
  latencyMs: number | null
  error?: string
  checkedAt: string
}

interface ProbeSpec {
  id: string
  name: string
  role: string
  // The probe returns either nothing (success) or throws / returns a reason
  // (failure). Latency is measured around the call by the runner.
  run(timeoutMs: number): Promise<void>
}

// Generous-ish per-probe budget. Probes run in parallel, so the total
// /health/platform response should be ~timeout regardless of count.
const PROBE_TIMEOUT_MS = 1500
const WARN_THRESHOLD_MS = 500

@Injectable()
export class HealthService {
  constructor(@InjectConnection() private readonly mongo: Connection) {}

  async probeAll(): Promise<ProbeResult[]> {
    const probes: ProbeSpec[] = [
      tcpSpec('mail',  'Stalwart',   'Mail · IMAP/JMAP/SMTP',     'HEALTH_STALWART_HOSTPORT',  'stalwart:8080'),
      httpSpec('files', 'OCIS',      'Files · OwnCloud Infinite', 'HEALTH_OCIS_URL',           'http://ocis:9200/health'),
      httpSpec('office','Collabora', 'Office editing · WOPI',     'HEALTH_COLLABORA_URL',      'http://collabora:9980/hosting/discovery'),
      httpSpec('auth',  'Authentik', 'Identity · SSO · MFA',      'HEALTH_AUTHENTIK_URL',      'http://authentik-server:9000/-/health/ready/'),
      tcpSpec('pg',    'PostgreSQL', 'Authentik + OCIS database', 'HEALTH_POSTGRES_HOSTPORT',  'postgres:5432'),
      { id: 'mongo',  name: 'MongoDB',      role: 'Platform application data',run: () => this.mongoPing(PROBE_TIMEOUT_MS) },
      tcpSpec('redis', 'Redis',      'Cache + session store',     'HEALTH_REDIS_HOSTPORT',     'redis:6379'),
      tcpSpec('proxy', 'Traefik',    'Reverse proxy · TLS',       'HEALTH_TRAEFIK_HOSTPORT',   'traefik:80'),
      // platform-api itself: this code is running, so it's trivially ok.
      { id: 'api',    name: 'Platform API', role: 'Control plane',            run: async () => { /* always ok */ } },
    ].filter((p): p is ProbeSpec => p !== null)

    const checkedAt = new Date().toISOString()
    const results = await Promise.all(probes.map((p) => run(p, checkedAt)))
    return results
  }

  private async mongoPing(timeoutMs: number): Promise<void> {
    if (this.mongo.readyState !== 1) {
      throw new Error(`mongoose readyState=${this.mongo.readyState}`)
    }
    await withTimeout(
      // db is defined once the connection is open; the readyState guard above
      // ensures that.
      this.mongo.db!.admin().ping(),
      timeoutMs,
      'mongo ping timed out',
    )
  }
}

// ── Runner ─────────────────────────────────────────────────────────────────

async function run(spec: ProbeSpec, checkedAt: string): Promise<ProbeResult> {
  const start = Date.now()
  try {
    await spec.run(PROBE_TIMEOUT_MS)
    const latencyMs = Date.now() - start
    return {
      id: spec.id,
      name: spec.name,
      role: spec.role,
      status: latencyMs > WARN_THRESHOLD_MS ? 'warn' : 'ok',
      latencyMs,
      checkedAt,
    }
  } catch (err) {
    const latencyMs = Date.now() - start
    return {
      id: spec.id,
      name: spec.name,
      role: spec.role,
      status: 'bad',
      latencyMs: latencyMs < PROBE_TIMEOUT_MS ? latencyMs : null,
      error: err instanceof Error ? err.message : String(err),
      checkedAt,
    }
  }
}

// ── Probe spec builders ────────────────────────────────────────────────────

function tcpSpec(id: string, name: string, role: string, envKey: string, dflt: string): ProbeSpec | null {
  const target = process.env[envKey] || dflt
  if (target === 'disabled') return null
  const [host, port] = target.split(':')
  return { id, name, role, run: () => tcpProbe(host!, Number(port), PROBE_TIMEOUT_MS) }
}

function httpSpec(id: string, name: string, role: string, envKey: string, dflt: string): ProbeSpec | null {
  const url = process.env[envKey] || dflt
  if (url === 'disabled') return null
  return { id, name, role, run: () => httpProbe(url, PROBE_TIMEOUT_MS) }
}

// ── Primitives ─────────────────────────────────────────────────────────────

function tcpProbe(host: string, port: number, timeoutMs: number): Promise<void> {
  return new Promise((resolve, reject) => {
    const socket = net.createConnection({ host, port })
    const timer = setTimeout(() => {
      socket.destroy()
      reject(new Error(`tcp ${host}:${port} timed out after ${timeoutMs}ms`))
    }, timeoutMs)
    socket.once('connect', () => {
      clearTimeout(timer)
      socket.end()
      resolve()
    })
    socket.once('error', (err) => {
      clearTimeout(timer)
      reject(err)
    })
  })
}

async function httpProbe(url: string, timeoutMs: number): Promise<void> {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeoutMs)
  try {
    const res = await fetch(url, { signal: controller.signal, method: 'GET' })
    if (!res.ok) {
      throw new Error(`HTTP ${res.status} from ${url}`)
    }
  } finally {
    clearTimeout(timer)
  }
}

function withTimeout<T>(p: Promise<T>, timeoutMs: number, msg: string): Promise<T> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(msg)), timeoutMs)
    p.then(
      (v) => {
        clearTimeout(timer)
        resolve(v)
      },
      (e) => {
        clearTimeout(timer)
        reject(e)
      },
    )
  })
}
