/**
 * Tests for DC-086: asymmetric hysteresis on the dashboard service badge.
 *
 *   - First probe always emits (no prior state).
 *   - Same-status probe does NOT re-emit (dedup against repeated green).
 *   - One "down" then back to "up" keeps the badge green (no flicker).
 *   - Two consecutive "down" probes flip the badge to red.
 *   - One "up" after a down streak flips back to green (fast recovery).
 *   - History retains every raw probe even when no emit happens.
 *   - getCurrentStatus returns displayed status, not raw.
 */

'use strict';

const path = require('path');
const fs = require('fs');
const os = require('os');

// Use an isolated data dir so test history doesn't pollute the real one.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-hyst-'));
process.env.HEALTH_DATA_DIR = tmpDir;
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');

// Module exports a singleton instance, not a class — see module.exports in
// src/monitoring/health-checker.js. The test creates fresh state by replacing
// the relevant maps on the singleton in beforeEach.
const healthCheckerSingleton = require('../src/monitoring/health-checker');
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;

function restoreEnv(name, value) {
  if (value === undefined) delete process.env[name];
  else process.env[name] = value;
}

function makeUp(serviceId = 'svc1') {
  return {
    serviceId,
    timestamp: new Date().toISOString(),
    status: 'up',
    responseTime: 50,
    statusCode: 200,
    message: 'Service is healthy',
    details: { headers: {}, bodyLength: 12 }
  };
}

function makeDown(serviceId = 'svc1') {
  return {
    serviceId,
    timestamp: new Date().toISOString(),
    status: 'down',
    responseTime: 50,
    statusCode: 500,
    message: 'fail',
    details: { headers: {}, bodyLength: 0 }
  };
}

describe('DC-086: hysteresis on the dashboard badge', () => {
  let hc;
  let emitSpy;

  beforeEach(() => {
    // Reset the singleton's per-test state so each case starts clean.
    healthCheckerSingleton.displayedStatus = new Map();
    healthCheckerSingleton.consecutiveSinceChange = new Map();
    healthCheckerSingleton.currentStatus = new Map();
    healthCheckerSingleton.history = {};
    healthCheckerSingleton.removeAllListeners('status-check');
    emitSpy = jest.fn();
    healthCheckerSingleton.on('status-check', emitSpy);
    hc = healthCheckerSingleton;
  });

  afterEach(() => {
    restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
    restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
  });

  afterAll(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  test('first probe (no prior state) emits', () => {
    hc.recordStatus('svc1', makeUp());
    expect(emitSpy).toHaveBeenCalledTimes(1);
    expect(emitSpy.mock.calls[0][0].status).toBe('up');
  });

  test('second probe with same status does NOT re-emit', () => {
    hc.recordStatus('svc1', makeUp());
    hc.recordStatus('svc1', makeUp());
    expect(emitSpy).toHaveBeenCalledTimes(1);
  });

  test('one "down" then "up" keeps the badge green (the flicker bug)', () => {
    hc.recordStatus('svc1', makeUp());        // baseline: green, emit 1
    hc.recordStatus('svc1', makeDown());      // one blip — keep green, no emit
    hc.recordStatus('svc1', makeUp());        // recovered — still green, no emit
    expect(emitSpy).toHaveBeenCalledTimes(1);
    expect(hc.displayedStatus.get('svc1').status).toBe('up');
  });

  test('up, down, up, down, down resets the first streak before flipping', () => {
    hc.recordStatus('svc1', makeUp());
    hc.recordStatus('svc1', makeDown());
    hc.recordStatus('svc1', makeUp());
    hc.recordStatus('svc1', makeDown());
    expect(hc.displayedStatus.get('svc1').status).toBe('up');
    expect(emitSpy).toHaveBeenCalledTimes(1);

    hc.recordStatus('svc1', makeDown());
    expect(hc.displayedStatus.get('svc1').status).toBe('down');
    expect(emitSpy).toHaveBeenCalledTimes(2);
  });

  test('two consecutive "down" probes flip the badge to red', () => {
    hc.recordStatus('svc1', makeUp());        // baseline: green
    hc.recordStatus('svc1', makeDown());      // blip #1 — keep green (counter=1)
    hc.recordStatus('svc1', makeDown());      // blip #2 — flip red (counter=2 >= DOWN_THRESHOLD)
    expect(emitSpy).toHaveBeenCalledTimes(2);
    expect(emitSpy.mock.calls[1][0].status).toBe('down');
    expect(hc.displayedStatus.get('svc1').status).toBe('down');
  });

  test('one "up" after a down streak flips back to green (fast recovery)', () => {
    hc.recordStatus('svc1', makeUp());
    hc.recordStatus('svc1', makeDown());
    hc.recordStatus('svc1', makeDown());      // now red
    expect(hc.displayedStatus.get('svc1').status).toBe('down');

    hc.recordStatus('svc1', makeUp());        // first green — flip back
    expect(emitSpy).toHaveBeenCalledTimes(3);
    expect(emitSpy.mock.calls[2][0].status).toBe('up');
    expect(hc.displayedStatus.get('svc1').status).toBe('up');
  });

  test('history retains every raw probe even when no emit happens', () => {
    hc.recordStatus('svc1', makeUp());
    hc.recordStatus('svc1', makeDown());      // blip, no emit
    hc.recordStatus('svc1', makeUp());        // recovery, no emit
    expect(hc.history['svc1'].length).toBe(3);
    expect(hc.history['svc1'][0].status).toBe('up');
    expect(hc.history['svc1'][1].status).toBe('down');
    expect(hc.history['svc1'][2].status).toBe('up');
  });

  test('getCurrentStatus returns the displayed status, not the raw probe', () => {
    const displayedUp = makeUp();
    displayedUp.timestamp = '2026-08-22T09:59:00.000Z';
    displayedUp.statusCode = 200;
    displayedUp.message = 'healthy';
    displayedUp.details = { source: 'accepted-up' };
    hc.recordStatus('svc1', displayedUp);
    const latestRaw = makeDown();
    latestRaw.timestamp = '2026-08-22T10:00:00.000Z';
    latestRaw.responseTime = 987;
    latestRaw.statusCode = 500;
    latestRaw.message = 'failed probe';
    latestRaw.error = 'upstream failure';
    latestRaw.details = { source: 'suppressed-down' };
    hc.recordStatus('svc1', latestRaw);       // raw=down, displayed=up
    const out = hc.getCurrentStatus();
    expect(out['svc1'].status).toBe('up');    // shown to API consumers
    expect(out['svc1'].timestamp).toBe(displayedUp.timestamp);
    expect(out['svc1'].statusCode).toBe(200);
    expect(out['svc1'].message).toBe('healthy');
    expect(out['svc1'].error).toBeUndefined();
    expect(out['svc1'].details).toEqual({ source: 'accepted-up' });
    expect(hc.currentStatus.get('svc1')).toBe(latestRaw);
  });

  test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
    for (let i = 0; i < 50; i++) hc.recordStatus('svc1', makeUp());
    expect(emitSpy).toHaveBeenCalledTimes(1);
  });

  test('a long steady-green-then-steady-red transition: 1 emit (up), 1 emit (red)', () => {
    for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeUp());
    expect(emitSpy).toHaveBeenCalledTimes(1);
    hc.recordStatus('svc1', makeDown());
    hc.recordStatus('svc1', makeDown());      // flips to red
    expect(emitSpy).toHaveBeenCalledTimes(2);
    for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeDown());
    expect(emitSpy).toHaveBeenCalledTimes(2); // no further broadcasts
  });

  test('DOWN_THRESHOLD env var is honored', () => {
    process.env.HEALTH_DOWN_THRESHOLD = '3';
    jest.resetModules();
    const HC2Module = require('../src/monitoring/health-checker');
    // Module is a singleton with DOWN_THRESHOLD captured at module load —
    // resetModules gives us a fresh module-level instance with the new env.
    const hc2 = HC2Module;
    hc2.displayedStatus = new Map();
    hc2.consecutiveSinceChange = new Map();
    hc2.currentStatus = new Map();
    hc2.history = {};
    hc2.removeAllListeners('status-check');
    const spy = jest.fn();
    hc2.on('status-check', spy);
    hc2.recordStatus('svc1', makeUp());
    hc2.recordStatus('svc1', makeDown());      // 1
    hc2.recordStatus('svc1', makeDown());      // 2 — still green (need 3)
    expect(spy).toHaveBeenCalledTimes(1);
    expect(hc2.displayedStatus.get('svc1').status).toBe('up');
    hc2.recordStatus('svc1', makeDown());      // 3 — flip
    expect(spy).toHaveBeenCalledTimes(2);
    expect(hc2.displayedStatus.get('svc1').status).toBe('down');
  });

  test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => {
    process.env.HEALTH_DOWN_THRESHOLD = value;
    jest.resetModules();
    const hc2 = require('../src/monitoring/health-checker');
    hc2.displayedStatus = new Map();
    hc2.consecutiveSinceChange = new Map();
    hc2.currentStatus = new Map();
    hc2.history = {};
    hc2.removeAllListeners('status-check');
    const spy = jest.fn();
    hc2.on('status-check', spy);
    hc2.recordStatus('svc1', makeUp());
    hc2.recordStatus('svc1', makeDown());
    expect(spy).toHaveBeenCalledTimes(1);
    hc2.recordStatus('svc1', makeDown());
    expect(spy).toHaveBeenCalledTimes(2);
  });

  test('UP_THRESHOLD env var greater than 1 is honored', () => {
    process.env.HEALTH_UP_THRESHOLD = '2';
    jest.resetModules();
    const hc2 = require('../src/monitoring/health-checker');
    hc2.displayedStatus = new Map();
    hc2.consecutiveSinceChange = new Map();
    hc2.currentStatus = new Map();
    hc2.history = {};
    hc2.removeAllListeners('status-check');
    const spy = jest.fn();
    hc2.on('status-check', spy);
    hc2.recordStatus('svc1', makeDown());
    hc2.recordStatus('svc1', makeUp());
    expect(spy).toHaveBeenCalledTimes(1);
    expect(hc2.displayedStatus.get('svc1').status).toBe('down');
    hc2.recordStatus('svc1', makeUp());
    expect(spy).toHaveBeenCalledTimes(2);
    expect(hc2.displayedStatus.get('svc1').status).toBe('up');
  });

  test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => {
    process.env.HEALTH_UP_THRESHOLD = value;
    jest.resetModules();
    const hc2 = require('../src/monitoring/health-checker');
    hc2.displayedStatus = new Map();
    hc2.consecutiveSinceChange = new Map();
    hc2.currentStatus = new Map();
    hc2.history = {};
    hc2.removeAllListeners('status-check');
    const spy = jest.fn();
    hc2.on('status-check', spy);
    hc2.recordStatus('svc1', makeDown());
    hc2.recordStatus('svc1', makeUp());
    expect(spy).toHaveBeenCalledTimes(2);
    expect(hc2.displayedStatus.get('svc1').status).toBe('up');
  });

  test('removeService clears hysteresis state before the same ID is re-added', () => {
    hc.config.services.svc1 = { name: 'Service 1' };
    hc.recordStatus('svc1', makeUp());
    hc.recordStatus('svc1', makeDown());
    expect(hc.displayedStatus.has('svc1')).toBe(true);
    expect(hc.consecutiveSinceChange.get('svc1')).toBe(1);
    hc.consecutiveFailures.set('svc1', 3);
    const timer = setTimeout(() => {}, 60_000);
    hc.serviceTimers.set('svc1', timer);

    hc.saveConfig = jest.fn();
    hc.removeService('svc1');

    expect(hc.displayedStatus.has('svc1')).toBe(false);
    expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
    expect(hc.currentStatus.has('svc1')).toBe(false);
    expect(hc.consecutiveFailures.has('svc1')).toBe(false);
    expect(hc.serviceTimers.has('svc1')).toBe(false);

    hc.config.services.svc1 = { name: 'Service 1 re-added' };
    const emitSpyAfterReAdd = jest.fn();
    hc.on('status-check', emitSpyAfterReAdd);
    hc.recordStatus('svc1', makeDown());

    expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1);
    expect(hc.displayedStatus.get('svc1').status).toBe('down');
    expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
  });
});
