import { describe, it, expect } from 'vitest';
import { assembleAnatomyInput, parseAnatomy, parseEnvKeys, formatGitSource, imageName, primaryPublishedHostPort } from './anatomy';

const COMPOSE = `services:
  plex:
    image: plexinc/pms-docker
    restart: unless-stopped
    ports:
      - "32400:32400"
      - "1900:1900/udp"
    volumes:
      - ./config:/config
    env_file: .env
    networks:
      - media
networks:
  media:
`;

describe('parseAnatomy', () => {
  it('extracts services, ports, volumes, restart, env file, and networks', () => {
    const a = parseAnatomy(COMPOSE)!;
    expect(a.services).toEqual(['plex']);
    expect(a.ports.plex).toEqual([
      { host: '32400', container: '32400', proto: 'tcp', published: true },
      { host: '1900', container: '1900', proto: 'udp', published: true },
    ]);
    expect(a.volumes.plex).toEqual([{ host: './config', container: '/config' }]);
    expect(a.restart).toBe('unless-stopped');
    expect(a.envFiles).toEqual(['.env']);
    expect(a.networks).toContain('media');
  });

  it('returns null for empty or unparseable compose', () => {
    expect(parseAnatomy('')).toBeNull();
    expect(parseAnatomy('   ')).toBeNull();
    expect(parseAnatomy('::: not yaml :::\n  - [')).toBeNull();
  });

  it('parses the 3-part bind-IP port form, picking host and container', () => {
    const a = parseAnatomy('services:\n  web:\n    image: x\n    ports:\n      - "127.0.0.1:8080:80"\n')!;
    expect(a.ports.web).toEqual([{ host: '8080', container: '80', proto: 'tcp', published: true }]);
  });

  it('parses long-syntax object ports and volumes', () => {
    const a = parseAnatomy(
      'services:\n  app:\n    image: x\n    ports:\n      - target: 80\n        published: 8080\n        protocol: udp\n    volumes:\n      - type: bind\n        source: ./data\n        target: /data\n',
    )!;
    expect(a.ports.app).toEqual([{ host: '8080', container: '80', proto: 'udp', published: true }]);
    expect(a.volumes.app).toEqual([{ host: './data', container: '/data' }]);
  });

  it('treats ${VAR:-default} as satisfied but ${VAR} as referenced', () => {
    const a = parseAnatomy('services:\n  app:\n    image: x\n    environment:\n      - A=${NEEDED}\n      - B=${HAS:-fallback}\n')!;
    expect(a.referencedVars).toContain('NEEDED');
    expect(a.referencedVars).not.toContain('HAS');
  });

  it('skips the $${ESCAPED} literal (Compose escape)', () => {
    const a = parseAnatomy('services:\n  app:\n    image: x\n    labels:\n      - template=$${ESCAPED}\n')!;
    expect(a.referencedVars).not.toContain('ESCAPED');
  });

  it('skips $$ escaped vars but still catches real interpolations', () => {
    const a = parseAnatomy('services:\n  app:\n    image: x\n    environment:\n      - A=${REAL}\n      - B=$${ESCAPED}\n')!;
    expect(a.referencedVars).toContain('REAL');
    expect(a.referencedVars).not.toContain('ESCAPED');
  });

  it('marks container-only short-form ports as not published', () => {
    const a = parseAnatomy('services:\n  web:\n    image: x\n    ports:\n      - "80"\n')!;
    expect(a.ports.web).toEqual([{ host: '80', container: '80', proto: 'tcp', published: false }]);
  });
});

describe('imageName', () => {
  it('strips a plain image tag', () => {
    expect(imageName('nginx:1.25')).toBe('nginx');
  });

  it('keeps registry host and repository path', () => {
    expect(imageName('ghcr.io/karakeep-app/karakeep:release')).toBe('ghcr.io/karakeep-app/karakeep');
  });

  it('treats only the colon after the last slash as a tag separator', () => {
    expect(imageName('registry:5000/app:1.2')).toBe('registry:5000/app');
  });

  it('strips a digest suffix', () => {
    expect(imageName('nginx:1.25@sha256:abc123')).toBe('nginx');
  });
});

describe('primaryPublishedHostPort', () => {
  const portsFrom = (ports: string) =>
    parseAnatomy(`services:\n  web:\n    image: x\n    ports:\n${ports}`)!.ports;

  it('returns the first published TCP host port as a number', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "8443:443"\n'))).toBe(8443);
  });

  it('picks the host port from the 3-part bind-IP form', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "127.0.0.1:8080:80"\n'))).toBe(8080);
  });

  it('returns null for a container-only short-form port', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "80"\n'))).toBeNull();
  });

  it('returns null for an unresolved variable port', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "${PORT}:80"\n'))).toBeNull();
  });

  it('returns null for a port range', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "8000-8002:8000-8002"\n'))).toBeNull();
  });

  it('returns null for an out-of-range host port', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "70000:80"\n'))).toBeNull();
  });

  it('returns null for a UDP published port (short and long form)', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "5353:53/udp"\n'))).toBeNull();
    const longUdp = parseAnatomy(
      'services:\n  web:\n    image: x\n    ports:\n      - target: 53\n        published: 5353\n        protocol: udp\n',
    )!.ports;
    expect(primaryPublishedHostPort(longUdp)).toBeNull();
  });

  it('skips a non-published row and returns the next published TCP port', () => {
    expect(primaryPublishedHostPort(portsFrom('      - "80"\n      - "8443:443"\n'))).toBe(8443);
  });

  it('returns null when there are no ports', () => {
    expect(primaryPublishedHostPort({})).toBeNull();
  });
});

describe('parseEnvKeys', () => {
  it('collects keys and ignores comments and blank lines', () => {
    const keys = parseEnvKeys('# comment\nFOO=1\n\nBAR=2\n=novalue\n');
    expect(keys.has('FOO')).toBe(true);
    expect(keys.has('BAR')).toBe(true);
    expect(keys.size).toBe(2);
  });
});

describe('formatGitSource', () => {
  it('formats a URL into host/repo#branch', () => {
    expect(formatGitSource({ repo_url: 'https://github.com/acme/stack.git', branch: 'main' }))
      .toBe('github.com/acme/stack#main');
  });

  it('falls back to repo#branch for a non-URL', () => {
    expect(formatGitSource({ repo_url: 'git@host:repo', branch: 'dev' })).toBe('git@host:repo#dev');
  });
});

describe('assembleAnatomyInput', () => {
  it('builds the markdown input, computing env count and missing vars', () => {
    const input = assembleAnatomyInput({
      stackName: 'plex',
      content: 'services:\n  plex:\n    image: x\n    env_file: .env\n    environment:\n      - TOKEN=${TOKEN}\n',
      envContent: 'OTHER=1\n',
      selectedEnvFile: '.env',
      gitSource: { repo_url: 'https://github.com/acme/plex.git', branch: 'main' },
    })!;
    expect(input.stackName).toBe('plex');
    expect(input.envFile).toBe('.env');
    expect(input.envVarCount).toBe(1);
    expect(input.missingVars).toEqual(['TOKEN']);
    expect(input.gitSource).toBe('github.com/acme/plex#main');
  });

  it('excludes $$ escaped vars from missingVars while real refs still appear', () => {
    const input = assembleAnatomyInput({
      stackName: 'test',
      content: 'services:\n  app:\n    image: x\n    labels:\n      - template=$${ESCAPED}\n    environment:\n      - TOKEN=${REAL}\n',
      envContent: '',
      selectedEnvFile: null,
      gitSource: null,
    })!;
    expect(input.missingVars).toContain('REAL');
    expect(input.missingVars).not.toContain('ESCAPED');
  });

  it('returns null when compose cannot be parsed', () => {
    expect(assembleAnatomyInput({
      stackName: 's', content: '', envContent: '', selectedEnvFile: null, gitSource: null,
    })).toBeNull();
  });

  it('falls back to <stack>_default when no network is declared', () => {
    const input = assembleAnatomyInput({
      stackName: 'web', content: 'services:\n  web:\n    image: x\n', envContent: '', selectedEnvFile: null, gitSource: null,
    })!;
    expect(input.networkName).toBe('web_default');
  });

  it('never carries .env values, only key names and counts', () => {
    const input = assembleAnatomyInput({
      stackName: 's',
      content: 'services:\n  s:\n    image: x\n    env_file: .env\n',
      envContent: 'SECRET=hunter2\nAPI_KEY=abc\n',
      selectedEnvFile: '.env',
      gitSource: null,
    })!;
    expect(input.envVarCount).toBe(2);
    expect(JSON.stringify(input)).not.toContain('hunter2');
    expect(JSON.stringify(input)).not.toContain('abc');
  });
});
