/**
 * Regression tests for WorkflowEngine.healthCheckService (DC-042 followup).
 *
 * Bug: bundled-workflows.js:310 called `servicesStateManager.getState()` —
 * a method that doesn't exist on StateManager. Combined with a missing
 * `await`, this returned a Promise instead of an array, which then short-
 * circuited via `|| []` to an empty array. The result: every health-check-
 * on-interval workflow ran successfully with 0 services checked, while
 * the workflow engine still reported "Action health-check failed:
 * servicesStateManager.getState is not a function" on the dashboard.
 *
 * Fix: call `await servicesStateManager.read()` with a .catch fallback to
 * an empty array so a corrupt/missing state file doesn't break the
 * workflow.
 */

const { WorkflowEngine } = require('../src/recipes/bundled-workflows');

function makeEngine(opts = {}) {
  const ctx = {
    servicesStateManager: opts.servicesStateManager || {
      read: jest.fn().mockResolvedValue([]),
    },
    docker: opts.docker !== undefined ? opts.docker : {
      client: {
        getContainer: jest.fn(),
      },
    },
  };
  const engine = new WorkflowEngine(ctx);
  // The constructor calls startScheduledWorkflows() which sets setInterval jobs.
  // Those prevent Jest from exiting cleanly. Clear them after construction.
  // We only care about healthCheckService behavior here, not scheduling.
  if (engine.scheduledJobs) {
    for (const job of engine.scheduledJobs.values()) {
      clearInterval(job);
    }
    engine.scheduledJobs.clear();
  }
  return engine;
}

describe('WorkflowEngine.healthCheckService — DC-042 followup (getState bug)', () => {
  test('uses .read() not the non-existent .getState() — does not throw', async () => {
    const readMock = jest.fn().mockResolvedValue([]);
    const engine = makeEngine({
      servicesStateManager: { read: readMock },
      docker: undefined,  // no docker — exercises the falsy branch
    });

    // The original bug: this throws `servicesStateManager.getState is not a function`
    const result = await engine.healthCheckService('{{serviceId}}');

    expect(readMock).toHaveBeenCalledTimes(1);
    expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
  });

  test('returns checked/healthy counts from read() output (all healthy)', async () => {
    const docker = {
      client: {
        getContainer: jest.fn((id) => ({
          inspect: jest.fn().mockResolvedValue({
            State: { Running: true, Health: { Status: 'healthy' } },
          }),
        })),
      },
    };
    const engine = makeEngine({
      servicesStateManager: {
        read: jest.fn().mockResolvedValue([
          { id: 'svc-1', containerId: 'c1' },
          { id: 'svc-2', containerId: 'c2' },
          { id: 'svc-3' },  // no containerId, should be skipped
        ]),
      },
      docker,
    });

    const result = await engine.healthCheckService('{{serviceId}}');

    expect(result.checked).toBe(2);             // svc-3 skipped (no containerId)
    expect(result.healthy).toBe(2);             // both containers healthy
    expect(result.results).toHaveLength(2);
    expect(result.results[0]).toMatchObject({ service: 'svc-1', healthy: true });
    expect(result.results[1]).toMatchObject({ service: 'svc-2', healthy: true });
    expect(result.failing).toEqual([]);
  });

  test('throws when any service is unhealthy — surfaces failing service IDs', async () => {
    const docker = {
      client: {
        getContainer: jest.fn((id) => ({
          inspect: jest.fn().mockResolvedValue({
            State: { Running: id !== 'c2', Health: { Status: id === 'c2' ? 'unhealthy' : 'healthy' } },
          }),
        })),
      },
    };
    const engine = makeEngine({
      servicesStateManager: {
        read: jest.fn().mockResolvedValue([
          { id: 'svc-1', containerId: 'c1' },
          { id: 'svc-2', containerId: 'c2' },
        ]),
      },
      docker,
    });

    await expect(engine.healthCheckService('{{serviceId}}')).rejects.toThrow(/svc-2/);
    await expect(engine.healthCheckService('{{serviceId}}')).rejects.toMatchObject({
      failingServices: ['svc-2'],
      workflowResult: expect.objectContaining({ checked: 2, healthy: 1, failing: ['svc-2'] }),
    });
  });

  test('gracefully degrades if read() throws — empty services list, no crash', async () => {
    const engine = makeEngine({
      servicesStateManager: {
        read: jest.fn().mockRejectedValue(new Error('disk on fire')),
      },
      docker: undefined,
    });

    // Before the fix, this rejected because .read() wasn't called and the
    // .catch(() => []) fallback didn't exist. Now it should resolve to empty.
    const result = await engine.healthCheckService('{{serviceId}}');
    expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
  });

  test('servicesStateManager absent on ctx → no crash, empty result', async () => {
    const engine = new WorkflowEngine({
      servicesStateManager: null,
      docker: undefined,
    });
    // Same constructor cleanup
    if (engine.scheduledJobs) {
      for (const job of engine.scheduledJobs.values()) clearInterval(job);
      engine.scheduledJobs.clear();
    }

    const result = await engine.healthCheckService('{{serviceId}}');
    expect(result).toEqual({ checked: 0, healthy: 0, results: [], failing: [] });
  });

  test('single service (non-template serviceId) path still works', async () => {
    const engine = makeEngine({
      docker: {
        client: {
          getContainer: jest.fn(() => ({
            inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
          })),
        },
      },
    });

    const result = await engine.healthCheckService('single-svc-id');
    expect(result).toEqual({ serviceId: 'single-svc-id', healthy: true });
  });

  test('single-service check throws when container is unhealthy', async () => {
    const engine = makeEngine({
      docker: {
        client: {
          getContainer: jest.fn(() => ({
            inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
          })),
        },
      },
    });

    await expect(engine.healthCheckService('down-svc')).rejects.toMatchObject({
      failingServices: ['down-svc'],
    });
  });
});

/**
 * DC-044 root-cause fix tests: notify-on-failure gating + template interpolation.
 *
 * The original code in executeAction had TWO latent bugs:
 *   1. notify-on-failure sent unconditionally (its comment said "Only send if
 *      previous action failed" but the code never checked).
 *   2. healthCheckService returned no serviceId field, so templates like
 *      `Health check failed for {{serviceId}}` never interpolated and stayed
 *      literal in every alert.
 *
 * These tests exercise the full executeWorkflow path with a stub workflow
 * that pairs `health-check` with `notify-on-failure`.
 */
describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + template)', () => {
  // Build an engine and call _runActions directly with arbitrary action
  // sequences. Bypasses BUNDLED_WORKFLOWS lookup so tests are isolated and
  // don't mutate module state.
  function makeEngine(opts = {}) {
    const ctx = {
      servicesStateManager: opts.servicesStateManager || { read: jest.fn().mockResolvedValue([]) },
      docker: opts.docker || { client: { getContainer: jest.fn(() => ({ inspect: jest.fn().mockResolvedValue({ State: { Running: true } }) })) } },
      notification: opts.notification || { send: jest.fn() },
    };
    const engine = new WorkflowEngine(ctx);
    if (engine.scheduledJobs) {
      for (const job of engine.scheduledJobs.values()) clearInterval(job);
      engine.scheduledJobs.clear();
    }
    return engine;
  }

  test('notify-on-failure is a no-op when the previous action succeeded', async () => {
    const notify = jest.fn();
    const engine = makeEngine({
      servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-1', containerId: 'c1' }]) },
      docker: { client: { getContainer: jest.fn(() => ({
        inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
      })) } },
      notification: { send: notify },
    });

    const results = await engine._runActions(
      [
        { type: 'health-check', target: '{{serviceId}}' },
        { type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
      ],
      { trigger: 'manual' }
    );

    const notifyResult = results.find(r => r.action === 'notify-on-failure');
    expect(notifyResult.success).toBe(true);
    expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
    expect(notify).not.toHaveBeenCalled();
  });

  test('notify-on-failure fires and interpolates {{failingServices}} when previous action failed', async () => {
    const notify = jest.fn();
    const engine = makeEngine({
      servicesStateManager: { read: jest.fn().mockResolvedValue([{ id: 'svc-broken', containerId: 'c1' }]) },
      docker: { client: { getContainer: jest.fn(() => ({
        inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
      })) } },
      notification: { send: notify },
    });

    const results = await engine._runActions(
      [
        { type: 'health-check', target: '{{serviceId}}' },
        { type: 'notify-on-failure', message: 'Health check failed for {{failingServices}}' },
      ],
      { trigger: 'manual' }
    );

    const healthResult = results.find(r => r.action === 'health-check');
    const notifyResult = results.find(r => r.action === 'notify-on-failure');
    expect(healthResult.success).toBe(false);
    expect(healthResult.failingServices).toEqual(['svc-broken']);
    expect(notifyResult.success).toBe(true);
    expect(notify).toHaveBeenCalledTimes(1);
    // notification.send signature: (category, title, message, level)
    const sentMessage = notify.mock.calls[0][2];
    expect(sentMessage).toBe('Health check failed for svc-broken');
    expect(sentMessage).not.toContain('{{');
  });

  test('notify (not notify-on-failure) fires unconditionally — regression guard', async () => {
    const notify = jest.fn();
    const engine = makeEngine({ notification: { send: notify } });

    const results = await engine._runActions(
      [{ type: 'notify', message: 'always sent' }],
      { trigger: 'manual' }
    );

    expect(notify).toHaveBeenCalledTimes(1);
    expect(notify.mock.calls[0][2]).toBe('always sent');
    expect(results[0].success).toBe(true);
  });

  test('notify-on-failure as first action is a no-op (no previous result)', async () => {
    const notify = jest.fn();
    const engine = makeEngine({ notification: { send: notify } });

    const results = await engine._runActions(
      [{ type: 'notify-on-failure', message: 'should not fire' }],
      { trigger: 'manual' }
    );

    const notifyResult = results[0];
    expect(notifyResult.success).toBe(true);
    expect(notifyResult.result).toEqual({ skipped: true, reason: 'no previous failure' });
    expect(notify).not.toHaveBeenCalled();
  });

  test('multi-service batch failure: {{failingServices}} interpolates comma-joined list', async () => {
    const notify = jest.fn();
    const engine = makeEngine({
      servicesStateManager: { read: jest.fn().mockResolvedValue([
        { id: 'svc-ok', containerId: 'c1' },
        { id: 'svc-broken-1', containerId: 'c2' },
        { id: 'svc-broken-2', containerId: 'c3' },
      ]) },
      docker: { client: { getContainer: jest.fn((id) => ({
        inspect: jest.fn().mockResolvedValue({
          State: { Running: id === 'c1', Health: { Status: id === 'c1' ? 'healthy' : 'unhealthy' } },
        }),
      })) } },
      notification: { send: notify },
    });

    const results = await engine._runActions(
      [
        { type: 'health-check', target: '{{serviceId}}' },
        { type: 'notify-on-failure', message: 'Failing: {{failingServices}}' },
      ],
      { trigger: 'manual' }
    );

    expect(notify).toHaveBeenCalledTimes(1);
    const sentMessage = notify.mock.calls[0][2];
    expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
    expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
  });

  // B2 regression: hit the actual bundled health-check-on-interval workflow
  // end-to-end via executeWorkflow. The bundled template uses
  // {{failingServices}} (DC-044 fix). Earlier it used {{serviceId}} which
  // never resolved because no per-service ID is in workflow scope. This test
  // would have failed with the old template.
  test('executeWorkflow on bundled health-check-on-interval: no literal {{...}} in notification', async () => {
    const { BUNDLED_WORKFLOWS } = require('../src/recipes/bundled-workflows');
    expect(BUNDLED_WORKFLOWS['health-check-on-interval']).toBeDefined();

    const notify = jest.fn();
    const engine = makeEngine({
      servicesStateManager: { read: jest.fn().mockResolvedValue([
        { id: 'svc-broken', containerId: 'c1' },
      ]) },
      docker: { client: { getContainer: jest.fn(() => ({
        inspect: jest.fn().mockResolvedValue({
          State: { Running: false, Health: { Status: 'unhealthy' } },
        }),
      })) } },
      notification: { send: notify },
    });

    const result = await engine.executeWorkflow('health-check-on-interval', { trigger: 'manual' });

    // Either the bundled workflow fired notification (with interpolated
    // message) OR every action resolved — but in NO case may a literal
    // {{...}} template token leak into notification.send.
    if (notify.mock.calls.length > 0) {
      const sentMessage = notify.mock.calls[0][2];
      expect(sentMessage).not.toMatch(/\{\{/);
      expect(sentMessage).not.toMatch(/\}\}/);
      // The new bundled template substitutes failingServices — make sure
      // the actual service ID made it through.
      expect(sentMessage).toContain('svc-broken');
    }
    // Workflow must always complete (success or failure), never throw.
    expect(result).toBeDefined();
    expect(result.workflowId).toBe('health-check-on-interval');
  });

  // B3 regression: a running container with Health.Status === 'unhealthy'
  // must be reported as unhealthy. Previously checkContainerHealth compared
  // info.State.Health itself (an object) to the string 'unhealthy', which
  // was always false — so any container with an explicit healthcheck was
  // always considered healthy. The fix reads info.State.Health.Status.
  test('checkContainerHealth treats running-but-unhealthy container as unhealthy', async () => {
    const engine = makeEngine({
      docker: { client: { getContainer: jest.fn(() => ({
        inspect: jest.fn().mockResolvedValue({
          State: { Running: true, Health: { Status: 'unhealthy' } },
        }),
      })) } },
    });

    const healthy = await engine.checkContainerHealth('running-but-unhealthy');
    expect(healthy).toBe(false);
  });

  test('checkContainerHealth treats running-with-no-healthcheck as healthy', async () => {
    const engine = makeEngine({
      docker: { client: { getContainer: jest.fn(() => ({
        inspect: jest.fn().mockResolvedValue({ State: { Running: true } }),
      })) } },
    });

    const healthy = await engine.checkContainerHealth('no-healthcheck');
    expect(healthy).toBe(true);
  });

  test('checkContainerHealth treats stopped container as unhealthy', async () => {
    const engine = makeEngine({
      docker: { client: { getContainer: jest.fn(() => ({
        inspect: jest.fn().mockResolvedValue({ State: { Running: false } }),
      })) } },
    });

    const healthy = await engine.checkContainerHealth('stopped');
    expect(healthy).toBe(false);
  });
});
