import { describe, it, expect } from 'vitest';
import { classifyFailure } from '../services/updateGuard/failureClassifier';
import type { FailureReason } from '../services/updateGuard/types';

describe('classifyFailure', () => {
  const cases: Array<{ name: string; message: string; reason: FailureReason }> = [
    {
      name: 'container crash sentinel',
      message: 'CONTAINER_CRASHED\nExit Code: 137\nContainer exited after deployment. Check container logs for details.',
      reason: 'container_exited',
    },
    {
      name: 'idle stall sentinel',
      message: 'STACK_STALLED_OUTPUT: no output for 600s',
      reason: 'unknown',
    },
    {
      name: 'docker daemon down (message)',
      message: 'Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?',
      reason: 'node_unreachable',
    },
    {
      name: 'docker desktop connect error',
      message: 'error during connect: this error may indicate that the docker daemon is not running',
      reason: 'node_unreachable',
    },
    {
      name: 'required variable missing',
      message: 'error while interpolating services.web.environment.TOKEN: required variable REQ_TOKEN is missing a value: must be provided',
      reason: 'env_missing',
    },
    {
      name: 'variable not set (logfmt warning escalated)',
      message: 'level=warning msg="The \\"DB_HOST\\" variable is not set. Defaulting to a blank string."',
      reason: 'env_missing',
    },
    {
      name: 'env file not found',
      message: "env file /compose/app/.env not found: stat /compose/app/.env: no such file or directory",
      reason: 'env_missing',
    },
    {
      name: 'pull access denied',
      message: 'Error response from daemon: pull access denied for ghost/missing, repository does not exist or may require docker login',
      reason: 'image_pull_failed',
    },
    {
      name: 'manifest unknown',
      message: 'manifest unknown: manifest unknown',
      reason: 'image_pull_failed',
    },
    {
      name: 'registry rate limited',
      message: 'toomanyrequests: You have reached your pull rate limit.',
      reason: 'image_pull_failed',
    },
    {
      name: 'port already allocated',
      message: 'Error response from daemon: driver failed programming external connectivity: Bind for 0.0.0.0:8080 failed: port is already allocated',
      reason: 'port_conflict',
    },
    {
      name: 'address already in use',
      message: 'listen tcp4 0.0.0.0:443: bind: address already in use',
      reason: 'port_conflict',
    },
    {
      name: 'windows ports not available',
      message: 'ports are not available: exposing port TCP 0.0.0.0:5432 -> 0.0.0.0:0',
      reason: 'port_conflict',
    },
    {
      name: 'bind source missing',
      message: 'Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /srv/missing',
      reason: 'bind_path_missing',
    },
    {
      name: 'permission denied',
      message: 'open /compose/app/data: permission denied',
      reason: 'permission_denied',
    },
    {
      name: 'unhealthy dependency',
      message: 'dependency failed to start: container app-db-1 is unhealthy',
      reason: 'healthcheck_failed',
    },
    {
      name: 'dependency exited',
      message: 'dependency failed to start: container app-db-1 exited (1)',
      reason: 'dependency_unavailable',
    },
    {
      name: 'external network missing',
      message: 'network proxy_net declared as external, but could not be found',
      reason: 'dependency_unavailable',
    },
    {
      name: 'yaml syntax error',
      message: 'yaml: line 14: mapping values are not allowed in this context',
      reason: 'compose_render_failed',
    },
    {
      name: 'undefined volume',
      message: 'service "web" refers to undefined volume data: invalid compose project',
      reason: 'compose_render_failed',
    },
    {
      name: 'unmatched output falls back to unknown',
      message: 'something completely unexpected happened',
      reason: 'unknown',
    },
    {
      name: 'mixed replica images capture refusal',
      message: 'Service "web" has mixed replica images; refusing recovery capture that cannot restore exact prior identity',
      reason: 'mixed_replica_images',
    },
    {
      name: 'host-absolute include coverage refusal',
      message: 'Host-absolute include path cannot be captured for exact rollback',
      reason: 'rollback_coverage_unavailable',
    },
    {
      name: 'generic rollback coverage unavailable',
      message: 'Exact authored-project rollback coverage is unavailable for this stack',
      reason: 'rollback_coverage_unavailable',
    },
  ];

  it.each(cases)('classifies $name as $reason', ({ message, reason }) => {
    const result = classifyFailure(message);
    expect(result.reason).toBe(reason);
    expect(result.label.length).toBeGreaterThan(0);
    expect(result.suggestion.length).toBeGreaterThan(0);
  });

  it('short-circuits to node_unreachable when the route flags a dead daemon', () => {
    expect(classifyFailure('arbitrary text', { dockerUnavailable: true }).reason).toBe('node_unreachable');
  });

  it('prefers env_missing over compose_render_failed when a render fails on a missing variable', () => {
    const result = classifyFailure(
      'invalid compose project: required variable DB_PASS is missing a value',
    );
    expect(result.reason).toBe('env_missing');
  });

  it('prefers the crash sentinel over any other pattern in the same output', () => {
    const result = classifyFailure(
      'CONTAINER_CRASHED\nExit Code: 1\nport is already allocated',
    );
    expect(result.reason).toBe('container_exited');
  });

  it('classifies the underlying cause of a rolled-back update from its message', () => {
    // ComposeRollbackError copies the original error message, so the route can
    // pass getErrorMessage(error) unchanged for wrapped failures.
    const causeMessage = 'pull access denied for private/app';
    expect(classifyFailure(causeMessage).reason).toBe('image_pull_failed');
  });

  it('always returns a classification (total over arbitrary input)', () => {
    for (const message of ['', '   ', 'x'.repeat(10_000)]) {
      const result = classifyFailure(message);
      expect(result.reason).toBeDefined();
      expect(result.label).toBeDefined();
      expect(result.suggestion).toBeDefined();
    }
  });
});
