/**
 * Targeted unit tests for helper parsers in the safety net.
 *
 * These focus on option-scanning branches that are hard to hit via end-to-end
 * command strings, improving confidence (and coverage) of the parsing logic.
 */

import { describe, expect, test } from 'bun:test';
import { dangerousInText } from '../src/core/analyze/dangerous-text.ts';
import { extractDashCArg } from '../src/core/analyze/shell-wrappers.ts';
import {
  _extractParallelChildCommand,
  _extractXargsChildCommand,
  _findHasDelete,
  _hasRecursiveForceFlags,
} from '../src/core/analyze.ts';
import { _extractGitSubcommandAndRest, _getCheckoutPositionalArgs } from '../src/core/rules-git.ts';
import { extractShortOpts, splitShellCommands, stripWrappersWithInfo } from '../src/core/shell.ts';
import { MAX_STRIP_ITERATIONS } from '../src/types.ts';

describe('shell parsing helpers', () => {
  describe('extractDashCArg', () => {
    test('returns null for empty tokens', () => {
      expect(extractDashCArg([])).toBeNull();
    });

    test('returns null for single token', () => {
      expect(extractDashCArg(['bash'])).toBeNull();
    });

    test('extracts arg after standalone -c', () => {
      expect(extractDashCArg(['bash', '-c', 'echo ok'])).toBe('echo ok');
    });

    test('extracts arg after bundled -lc', () => {
      expect(extractDashCArg(['bash', '-lc', 'echo ok'])).toBe('echo ok');
    });

    test('extracts arg after bundled -xc', () => {
      expect(extractDashCArg(['sh', '-xc', 'rm -rf /'])).toBe('rm -rf /');
    });

    test('returns null when -c has no following arg', () => {
      expect(extractDashCArg(['bash', '-c'])).toBeNull();
    });

    test('returns null when bundled option has no following arg', () => {
      expect(extractDashCArg(['bash', '-lc'])).toBeNull();
    });

    test('handles -- separator before -c (implementation scans past it)', () => {
      expect(extractDashCArg(['bash', '--', '-c', 'echo'])).toBe('echo');
    });

    test('ignores long options starting with --', () => {
      expect(extractDashCArg(['bash', '--rcfile', 'script'])).toBeNull();
    });

    test('returns null when next token starts with dash', () => {
      expect(extractDashCArg(['bash', '-lc', '-x'])).toBeNull();
    });

    test('handles -c appearing later in tokens', () => {
      expect(extractDashCArg(['bash', '-l', '-c', 'echo ok'])).toBe('echo ok');
    });
  });

  describe('extractShortOpts', () => {
    test('stops at double dash', () => {
      // given: tokens with -Ap after -- (a filename, not options)
      // when: extracting short options
      // then: A and p should NOT be in the result
      expect(extractShortOpts(['git', 'add', '--', '-Ap'])).toEqual(new Set());
      expect(extractShortOpts(['rm', '-r', '--', '-f'])).toEqual(new Set(['-r']));
    });

    test('extracts before double dash', () => {
      // given: tokens with options before --
      // when: extracting short options
      // then: only options before -- are extracted
      expect(extractShortOpts(['git', '-v', 'add', '-n', '--', '-x'])).toEqual(
        new Set(['-v', '-n']),
      );
    });
  });

  describe('splitShellCommands', () => {
    test('returns whole command when quotes are unclosed', () => {
      expect(splitShellCommands('echo "unterminated')).toEqual([['echo "unterminated']]);
    });

    test('extracts arithmetic substitution segments (nested parens)', () => {
      expect(splitShellCommands('echo $((1+2))')).toEqual([['echo'], ['1+2']]);
    });

    test('extracts backtick substitution segments', () => {
      expect(splitShellCommands('echo `date`')).toEqual([['date'], ['echo', '`date`']]);
    });

    test('extracts $() substitution segments split on operators', () => {
      expect(splitShellCommands('echo $(rm -rf /tmp/x && echo ok)')).toEqual([
        ['echo'],
        ['rm', '-rf', '/tmp/x'],
        ['echo', 'ok'],
      ]);
    });

    test('extracts multiple backtick substitutions from one token', () => {
      expect(splitShellCommands('echo `a`:`b`')).toEqual([['a'], ['b'], ['echo', '`a`:`b`']]);
    });

    test('handles nested $(...) with operators', () => {
      const result = splitShellCommands('echo $(echo $(rm -rf /tmp/x))');
      expect(result.length).toBeGreaterThan(1);
      const flat = result.flat();
      expect(flat).toContain('rm');
      expect(flat).toContain('-rf');
    });

    test('handles deeply nested $(...) substitutions', () => {
      const result = splitShellCommands('echo $(a $(b $(c)))');
      expect(result.length).toBeGreaterThan(1);
    });

    test('handles $(...) with semicolon operators', () => {
      expect(splitShellCommands('echo $(cd /tmp; rm -rf .)')).toEqual([
        ['echo'],
        ['cd', '/tmp'],
        ['rm', '-rf', '.'],
      ]);
    });

    test('handles $(...) with pipe operators', () => {
      expect(splitShellCommands('echo $(cat file | rm -rf /)')).toEqual([
        ['echo'],
        ['cat', 'file'],
        ['rm', '-rf', '/'],
      ]);
    });

    test('handles unterminated $() substitution (no hang, still extracts tokens)', () => {
      expect(splitShellCommands('echo $(rm -rf /tmp/x')).toEqual([
        ['echo'],
        ['rm', '-rf', '/tmp/x'],
      ]);
    });
  });

  describe('stripWrappersWithInfo', () => {
    test('strips sudo options that consume a value', () => {
      const result = stripWrappersWithInfo(['sudo', '-u', 'root', 'rm', '-rf', '/tmp/a']);
      expect(result.tokens).toEqual(['rm', '-rf', '/tmp/a']);
    });

    test('strips env -C=...', () => {
      const result = stripWrappersWithInfo(['env', '-C=/tmp', 'rm', '-rf']);
      expect(result.tokens).toEqual(['rm', '-rf']);
    });

    test('strips command -pv and -- separator', () => {
      const result = stripWrappersWithInfo(['command', '-pv', '--', 'git', 'status']);
      expect(result.tokens).toEqual(['git', 'status']);
    });

    test('captures env assignments after hitting max strip iterations', () => {
      const tokens = Array.from({ length: MAX_STRIP_ITERATIONS }, () => 'sudo');
      tokens.push('FOO=bar', 'rm', '-rf');

      const result = stripWrappersWithInfo(tokens);
      expect(result.tokens).toEqual(['rm', '-rf']);
      expect(result.envAssignments.get('FOO')).toBe('bar');
    });

    test('strips nested wrappers across iterations and preserves env assignments', () => {
      const result = stripWrappersWithInfo([
        'sudo',
        'env',
        'FOO=1',
        'sudo',
        'command',
        '--',
        'rm',
        '-rf',
        '/tmp/a',
      ]);
      expect(result.tokens).toEqual(['rm', '-rf', '/tmp/a']);
      expect(result.envAssignments.get('FOO')).toBe('1');
    });

    test("drops leading tokens containing '=' that are not NAME=value assignments", () => {
      // Intentionally conservative: only strict NAME=value is treated as an env assignment.
      // Shell-legal forms like NAME+=value are dropped to reach the real command head.
      const result = stripWrappersWithInfo(['FOO+=bar', 'rm', '-rf']);
      expect(result.tokens).toEqual(['rm', '-rf']);
      expect(result.envAssignments.get('FOO')).toBeUndefined();
    });
  });
});

describe('rm parsing helpers', () => {
  describe('hasRecursiveForceFlags', () => {
    test('empty tokens returns false', () => {
      expect(_hasRecursiveForceFlags([])).toBe(false);
    });

    test('stops at double dash', () => {
      // -f after `--` is a positional arg, not an option.
      expect(_hasRecursiveForceFlags(['rm', '-r', '--', '-f'])).toBe(false);
    });

    test('detects -rf combined', () => {
      expect(_hasRecursiveForceFlags(['rm', '-rf', 'foo'])).toBe(true);
    });

    test('detects -r -f separate', () => {
      expect(_hasRecursiveForceFlags(['rm', '-r', '-f', 'foo'])).toBe(true);
    });

    test('detects --recursive --force', () => {
      expect(_hasRecursiveForceFlags(['rm', '--recursive', '--force', 'foo'])).toBe(true);
    });
  });
});

describe('find parsing helpers', () => {
  describe('findHasDelete', () => {
    test('exec without terminator ignored', () => {
      // Un-terminated -exec should not cause a false positive on -delete.
      expect(_findHasDelete(['-exec', 'echo', '-delete'])).toBe(false);
    });

    test('skips undefined tokens', () => {
      // biome-ignore lint/suspicious/noExplicitAny: intentionally testing malformed input
      expect(_findHasDelete([undefined as any, '-delete'] as any)).toBe(true);
    });

    test('delete outside exec detected', () => {
      expect(_findHasDelete(['-name', '*.txt', '-delete'])).toBe(true);
    });

    test('delete inside exec not detected', () => {
      expect(_findHasDelete(['-exec', 'rm', '-delete', ';', '-print'])).toBe(false);
    });

    test('options that consume a value treat -delete as an argument', () => {
      const consumingValue = [
        '-name',
        '-iname',
        '-path',
        '-ipath',
        '-regex',
        '-iregex',
        '-type',
        '-user',
        '-group',
        '-perm',
        '-size',
        '-mtime',
        '-ctime',
        '-atime',
        '-newer',
        '-printf',
        '-fprint',
        '-fprintf',
      ] as const;

      for (const opt of consumingValue) {
        expect(_findHasDelete([opt, '-delete'])).toBe(false);
        expect(_findHasDelete([opt, '-delete', '-delete'])).toBe(true);
      }
    });
  });
});

describe('dangerousInText', () => {
  test('detects rm -rf variants', () => {
    expect(dangerousInText('rm -rf /tmp/x')).toBe('rm -rf');
    expect(dangerousInText('rm -R -f /tmp/x')).toBe('rm -rf');
    expect(dangerousInText('rm -fr /tmp/x')).toBe('rm -rf');
    expect(dangerousInText('rm -f -r /tmp/x')).toBe('rm -rf');
  });

  test('detects with leading whitespace (trimStart)', () => {
    expect(dangerousInText('   rm -rf /tmp/x')).toBe('rm -rf');
  });

  test('detects key git patterns', () => {
    expect(dangerousInText('git reset --hard')).toBe('git reset --hard');
    expect(dangerousInText('git clean -f')).toBe('git clean -f');
  });

  test('skips find -delete when text starts with echo/rg', () => {
    expect(dangerousInText('echo "find . -delete')).toBeNull();
    expect(dangerousInText('rg "find . -delete')).toBeNull();
  });
});

describe('xargs parsing helpers', () => {
  describe('extractXargsChildCommand', () => {
    test('none when child unspecified', () => {
      expect(_extractXargsChildCommand(['xargs'])).toEqual([]);
    });

    test('double dash starts child', () => {
      expect(_extractXargsChildCommand(['xargs', '--', 'rm', '-rf'])).toEqual(['rm', '-rf']);
    });

    test('long option consumes value', () => {
      expect(_extractXargsChildCommand(['xargs', '--max-args', '5', 'rm', '-rf'])).toEqual([
        'rm',
        '-rf',
      ]);
    });

    test('long option equals form', () => {
      expect(_extractXargsChildCommand(['xargs', '--max-args=5', 'rm'])).toEqual(['rm']);
    });

    test('short option attached form', () => {
      expect(_extractXargsChildCommand(['xargs', '-n1', 'rm'])).toEqual(['rm']);
    });

    test('dash i does not consume child', () => {
      expect(_extractXargsChildCommand(['xargs', '-i', 'rm', '-rf'])).toEqual(['rm', '-rf']);
    });

    test('more attached forms', () => {
      const cases: Array<[string[], string[]]> = [
        [['xargs', '-P4', 'rm'], ['rm']],
        [['xargs', '-L2', 'rm'], ['rm']],
        [['xargs', '-n1', 'rm'], ['rm']],
      ];
      for (const [tokens, expected] of cases) {
        expect(_extractXargsChildCommand(tokens)).toEqual(expected);
      }
    });
  });
});

describe('parallel parsing helpers', () => {
  describe('extractParallelChildCommand', () => {
    test('returns empty when ::: is first token after parallel', () => {
      // When ::: is the first token after parallel (and options),
      // it returns empty because args follow :::
      expect(_extractParallelChildCommand(['parallel', ':::'])).toEqual([]);
    });

    test('extracts command with -- separator', () => {
      expect(_extractParallelChildCommand(['parallel', '--', 'rm', '-rf'])).toEqual(['rm', '-rf']);
    });

    test('returns command and all following tokens', () => {
      // The function returns all tokens starting from the first non-option
      expect(_extractParallelChildCommand(['parallel', 'rm', '-rf'])).toEqual(['rm', '-rf']);
    });

    test('returns command including ::: marker when command comes first', () => {
      // If command tokens appear before :::, all of them are returned
      expect(_extractParallelChildCommand(['parallel', 'rm', '-rf', ':::', '/'])).toEqual([
        'rm',
        '-rf',
        ':::',
        '/',
      ]);
    });

    test('consumes options', () => {
      expect(_extractParallelChildCommand(['parallel', '-j4', '--', 'rm', '-rf'])).toEqual([
        'rm',
        '-rf',
      ]);
    });

    test('consumes --option=value', () => {
      expect(_extractParallelChildCommand(['parallel', '--foo=bar', 'rm', '-rf'])).toEqual([
        'rm',
        '-rf',
      ]);
    });

    test('consumes options that take a value', () => {
      expect(_extractParallelChildCommand(['parallel', '-S', 'sshlogin', 'rm', '-rf'])).toEqual([
        'rm',
        '-rf',
      ]);
    });

    test('consumes -j value form', () => {
      expect(_extractParallelChildCommand(['parallel', '-j', '4', 'rm', '-rf'])).toEqual([
        'rm',
        '-rf',
      ]);
    });

    test('skips unknown short option', () => {
      expect(_extractParallelChildCommand(['parallel', '-X', 'rm', '-rf'])).toEqual(['rm', '-rf']);
    });

    test('empty for just parallel', () => {
      expect(_extractParallelChildCommand(['parallel'])).toEqual([]);
    });
  });
});

describe('git rules helpers', () => {
  describe('extractGitSubcommandAndRest', () => {
    test('git only returns null subcommand', () => {
      const result = _extractGitSubcommandAndRest(['git']);
      expect(result.subcommand).toBeNull();
      expect(result.rest).toEqual([]);
    });

    test('non git returns null subcommand', () => {
      const result = _extractGitSubcommandAndRest(['echo', 'ok']);
      expect(result.subcommand).toBeNull();
      expect(result.rest).toEqual([]);
    });

    test('unknown short option skipped', () => {
      const result = _extractGitSubcommandAndRest(['git', '-x', 'reset', '--hard']);
      expect(result.subcommand).toBe('reset');
      expect(result.rest).toEqual(['--hard']);
    });

    test('unknown long option equals skipped', () => {
      const result = _extractGitSubcommandAndRest(['git', '--unknown=1', 'reset', '--hard']);
      expect(result.subcommand).toBe('reset');
      expect(result.rest).toEqual(['--hard']);
    });

    test('opts with value separate consumed', () => {
      const result = _extractGitSubcommandAndRest(['git', '-c', 'foo=bar', 'reset']);
      expect(result.subcommand).toBe('reset');
      expect(result.rest).toEqual([]);
    });

    test('double dash can introduce subcommand', () => {
      const result = _extractGitSubcommandAndRest(['git', '--', 'reset', '--hard']);
      expect(result.subcommand).toBe('reset');
      expect(result.rest).toEqual(['--hard']);
    });

    test('double dash without a subcommand yields null', () => {
      const result = _extractGitSubcommandAndRest(['git', '--', '--help']);
      expect(result.subcommand).toBeNull();
      expect(result.rest).toEqual(['--help']);
    });

    test('attached -C consumes itself', () => {
      const result = _extractGitSubcommandAndRest(['git', '-C/tmp', 'reset', '--hard']);
      expect(result.subcommand).toBe('reset');
      expect(result.rest).toEqual(['--hard']);
    });
  });

  describe('getCheckoutPositionalArgs', () => {
    test('attached short opts ignored', () => {
      expect(_getCheckoutPositionalArgs(['-bnew', 'main', 'file.txt'])).toEqual([
        'main',
        'file.txt',
      ]);
      expect(_getCheckoutPositionalArgs(['-U3', 'main'])).toEqual(['main']);
    });

    test('long equals ignored', () => {
      expect(_getCheckoutPositionalArgs(['--pathspec-from-file=paths.txt', 'main'])).toEqual([
        'main',
      ]);
    });

    test('double dash breaks', () => {
      expect(_getCheckoutPositionalArgs(['--', 'file.txt'])).toEqual([]);
    });

    test('options with value consumed', () => {
      expect(_getCheckoutPositionalArgs(['-b', 'new', 'main'])).toEqual(['main']);
    });

    test('unknown long option consumes value', () => {
      expect(_getCheckoutPositionalArgs(['--unknown', 'main', 'file.txt'])).toEqual(['file.txt']);
    });

    test('unknown short option skipped', () => {
      expect(_getCheckoutPositionalArgs(['-x', 'main'])).toEqual(['main']);
    });

    test('optional value options recurse-submodules', () => {
      expect(_getCheckoutPositionalArgs(['--recurse-submodules', 'main'])).toEqual(['main']);
      expect(_getCheckoutPositionalArgs(['--recurse-submodules=on-demand', 'main'])).toEqual([
        'main',
      ]);
    });

    test('optional value options track', () => {
      expect(_getCheckoutPositionalArgs(['--track', 'main'])).toEqual(['main']);
      expect(_getCheckoutPositionalArgs(['--track=direct', 'main'])).toEqual(['main']);
    });
  });
});

describe('cwd tracking helpers', () => {
  const { _segmentChangesCwd } = require('../src/core/analyze.ts');

  test('cd returns true', () => {
    expect(_segmentChangesCwd(['cd', '..'])).toBe(true);
  });

  test('pushd returns true', () => {
    expect(_segmentChangesCwd(['pushd', '/tmp'])).toBe(true);
  });

  test('popd returns true', () => {
    expect(_segmentChangesCwd(['popd'])).toBe(true);
  });

  test('builtin cd returns true', () => {
    expect(_segmentChangesCwd(['builtin', 'cd', '..'])).toBe(true);
  });

  test('builtin only returns false', () => {
    expect(_segmentChangesCwd(['builtin'])).toBe(false);
  });

  test('grouped cd returns true', () => {
    expect(_segmentChangesCwd(['{', 'cd', '..', ';', '}'])).toBe(true);
  });

  test('subshell cd returns true', () => {
    expect(_segmentChangesCwd(['(', 'cd', '..', ')'])).toBe(true);
  });

  test('command substitution cd returns true', () => {
    expect(_segmentChangesCwd(['$(', 'cd', '..', ')'])).toBe(true);
  });

  test('regex fallback on unparseable', () => {
    expect(_segmentChangesCwd(['cd', "'unterminated"])).toBe(true);
  });

  test('non-cd command returns false', () => {
    expect(_segmentChangesCwd(['ls', '-la'])).toBe(false);
  });
});

describe('xargs parsing helpers', () => {
  const { _extractXargsChildCommandWithInfo } = require('../src/core/analyze.ts');

  test('replacement token from -I option', () => {
    const result = _extractXargsChildCommandWithInfo(['xargs', '-I', '{}', 'rm', '-rf', '{}']);
    expect(result.replacementToken).toBe('{}');
  });

  test('replacement token from -I attached', () => {
    const result = _extractXargsChildCommandWithInfo(['xargs', '-I%', 'rm', '-rf', '%']);
    expect(result.replacementToken).toBe('%');
  });

  test('replacement token from --replace defaults to braces', () => {
    const result = _extractXargsChildCommandWithInfo(['xargs', '--replace', 'rm', '-rf', '{}']);
    expect(result.replacementToken).toBe('{}');
  });

  test('replacement token from --replace= empty defaults to braces', () => {
    const result = _extractXargsChildCommandWithInfo(['xargs', '--replace=', 'rm', '-rf', '{}']);
    expect(result.replacementToken).toBe('{}');
  });

  test('replacement token from --replace=CUSTOM', () => {
    const result = _extractXargsChildCommandWithInfo([
      'xargs',
      '--replace=FOO',
      'rm',
      '-rf',
      'FOO',
    ]);
    expect(result.replacementToken).toBe('FOO');
  });

  test('no replacement token when not specified', () => {
    const result = _extractXargsChildCommandWithInfo(['xargs', 'rm', '-rf']);
    expect(result.replacementToken).toBeNull();
  });
});
