// Live health probes for the services we expect to find in the Dezky stack.
// Hostnames + ports are the compose service names from
// infrastructure/docker-compose/docker-compose.yml. When we move to k3s,
// swap these for in-cluster service DNS (e.g. authentik.dezky.svc...).

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[] = [
      { id: 'mail',   name: 'Stalwart',     role: 'Mail · IMAP/JMAP/SMTP',    run: () => tcpProbe('stalwart', 8080, PROBE_TIMEOUT_MS) },
      { id: 'files',  name: 'OCIS',         role: 'Files · OwnCloud Infinite',run: () => httpProbe('http://ocis:9200/health', PROBE_TIMEOUT_MS) },
      { id: 'office', name: 'Collabora',    role: 'Office editing · WOPI',    run: () => httpProbe('http://collabora:9980/hosting/discovery', PROBE_TIMEOUT_MS) },
      { id: 'auth',   name: 'Authentik',    role: 'Identity · SSO · MFA',     run: () => httpProbe('http://authentik-server:9000/-/health/ready/', PROBE_TIMEOUT_MS) },
      { id: 'pg',     name: 'PostgreSQL',   role: 'Authentik + OCIS database',run: () => tcpProbe('postgres', 5432, PROBE_TIMEOUT_MS) },
      { id: 'mongo',  name: 'MongoDB',      role: 'Platform application data',run: () => this.mongoPing(PROBE_TIMEOUT_MS) },
      { id: 'redis',  name: 'Redis',        role: 'Cache + session store',    run: () => tcpProbe('redis', 6379, PROBE_TIMEOUT_MS) },
      { id: 'proxy',  name: 'Traefik',      role: 'Reverse proxy · TLS',      run: () => tcpProbe('traefik', 80, PROBE_TIMEOUT_MS) },
      // platform-api itself: this code is running, so it's trivially ok.
      { id: 'api',    name: 'Platform API', role: 'Control plane',            run: async () => { /* always ok */ } },
    ]

    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,
    }
  }
}

// ── 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)
      },
    )
  })
}
