import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
  applyCfmlFileFormat,
  detectEol,
  getFileFormatDiagnostics,
  hasUtf8BomBytes,
  inspectFileFormat,
  normalizeEolBytes,
  type EolStyle,
} from '../src/linters/file-format.js';

const BOM = Buffer.from([0xEF, 0xBB, 0xBF]);

function utf8(value: string): Buffer {
  return Buffer.from(value, 'utf8');
}

function withFile(content: Buffer, run: (filePath: string) => void): void {
  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'linter-file-format-'));
  const filePath = path.join(directory, 'sample.cfc');
  fs.writeFileSync(filePath, content);
  try {
    run(filePath);
  } finally {
    fs.rmSync(directory, { recursive: true, force: true });
  }
}

function hasRule(messages: Array<{ ruleId: string }>, ruleId: string): boolean {
  return messages.some((message) => message.ruleId === ruleId);
}

test('detects UTF-8 BOM and reports a missing CFML BOM separately', () => {
  const withBom = inspectFileFormat(Buffer.concat([BOM, utf8('component {}\n')]));
  assert.equal(withBom.hasUtf8Bom, true);
  assert.equal(withBom.validUtf8, true);
  assert.equal(hasRule(getFileFormatDiagnostics(withBom), 'FILE_ENCODING_ERROR'), false);

  const withoutBom = inspectFileFormat(utf8('component {}\n'));
  assert.equal(withoutBom.hasUtf8Bom, false);
  assert.equal(hasRule(getFileFormatDiagnostics(withoutBom), 'FILE_ENCODING_ERROR'), true);
});

test('strictly validates payload UTF-8 without replacing invalid bytes', () => {
  const valid = inspectFileFormat(Buffer.concat([BOM, utf8('café\n')]));
  assert.equal(valid.validUtf8, true);
  assert.equal(hasRule(getFileFormatDiagnostics(valid), 'FILE_UTF8_INVALID'), false);

  const invalid = inspectFileFormat(Buffer.concat([BOM, Buffer.from([0xC3, 0x28])]));
  assert.equal(invalid.validUtf8, false);
  const invalidDiagnostic = getFileFormatDiagnostics(invalid).find((message) => message.ruleId === 'FILE_UTF8_INVALID');
  assert.equal(invalidDiagnostic?.severity, 'error');
});

test('detects each EOL style and combinations without double-counting CRLF', () => {
  const cases: Array<{ text: string; style: EolStyle; crlf: number; lf: number; cr: number }> = [
    { text: 'a\r\nb\r\n', style: 'crlf', crlf: 2, lf: 0, cr: 0 },
    { text: 'a\nb\n', style: 'lf', crlf: 0, lf: 2, cr: 0 },
    { text: 'a\rb\r', style: 'cr', crlf: 0, lf: 0, cr: 2 },
    { text: 'no newline', style: 'none', crlf: 0, lf: 0, cr: 0 },
    { text: 'a\r\nb\n', style: 'mixed', crlf: 1, lf: 1, cr: 0 },
    { text: 'a\r\nb\r', style: 'mixed', crlf: 1, lf: 0, cr: 1 },
    { text: 'a\nb\r', style: 'mixed', crlf: 0, lf: 1, cr: 1 },
    { text: 'a\r\nb\nc\r', style: 'mixed', crlf: 1, lf: 1, cr: 1 },
  ];

  for (const item of cases) {
    const result = detectEol(utf8(item.text));
    assert.equal(result.style, item.style, JSON.stringify(item));
    assert.deepEqual(result.counts, { crlf: item.crlf, lf: item.lf, cr: item.cr }, JSON.stringify(item));
  }
});

test('reports FILE_EOL_MIXED as a warning only when multiple newline styles occur', () => {
  const mixed = getFileFormatDiagnostics(inspectFileFormat(Buffer.concat([BOM, utf8('a\r\nb\n')])));
  assert.equal(mixed.find((message) => message.ruleId === 'FILE_EOL_MIXED')?.severity, 'warning');

  for (const text of ['a\r\nb\r\n', 'a\nb\n', 'a\rb\r', 'no newline']) {
    const messages = getFileFormatDiagnostics(inspectFileFormat(Buffer.concat([BOM, utf8(text)])));
    assert.equal(hasRule(messages, 'FILE_EOL_MIXED'), false, JSON.stringify(text));
  }
});

test('normalizes LF, CRLF, and mixed EOL bytes to either supported target', () => {
  const conversions: Array<{ input: string; target: 'lf' | 'crlf'; expected: string }> = [
    { input: 'a\nb\n', target: 'crlf', expected: 'a\r\nb\r\n' },
    { input: 'a\r\nb\r\n', target: 'lf', expected: 'a\nb\n' },
    { input: 'a\r\nb\nc\rd', target: 'crlf', expected: 'a\r\nb\r\nc\r\nd' },
    { input: 'a\r\nb\nc\rd', target: 'lf', expected: 'a\nb\nc\nd' },
  ];

  for (const conversion of conversions) {
    assert.deepEqual(
      normalizeEolBytes(utf8(conversion.input), conversion.target),
      utf8(conversion.expected),
    );

    const withBom = Buffer.concat([BOM, utf8(conversion.input)]);
    withFile(withBom, (filePath) => {
      const result = applyCfmlFileFormat(filePath, { fix: true, fixEol: conversion.target });
      assert.deepEqual(fs.readFileSync(filePath), Buffer.concat([BOM, utf8(conversion.expected)]));
      assert.deepEqual(result.fixesApplied, ['eol']);
      assert.equal(result.inspection.eolStyle, conversion.target);
      assert.equal(hasRule(result.messages, 'FILE_EOL_MIXED'), false);
    });
  }
});

test('explicit EOL fixes preserve text, BOM, and final-newline state', () => {
  const inputText = 'component {\r\n  // café\n  return true;\r}';
  const input = Buffer.concat([BOM, utf8(inputText)]);
  const expected = Buffer.concat([BOM, utf8('component {\r\n  // café\r\n  return true;\r\n}')]);

  withFile(input, (filePath) => {
    const result = applyCfmlFileFormat(filePath, { fix: true, fixEol: 'crlf' });
    const actual = fs.readFileSync(filePath);
    assert.deepEqual(actual, expected);
    assert.equal(hasUtf8BomBytes(actual), true);
    assert.equal(result.inspection.eolStyle, 'crlf');
    assert.deepEqual(result.fixesApplied, ['eol']);
    assert.equal(hasRule(result.messages, 'FILE_EOL_MIXED'), false);
    assert.equal(actual.toString('utf8').startsWith('\uFEFFcomponent {'), true);
    assert.equal(actual[actual.length - 1], 0x7D, 'the missing final newline remains absent');
  });

  const withFinalNewline = Buffer.concat([BOM, utf8('a\nb\n')]);
  withFile(withFinalNewline, (filePath) => {
    applyCfmlFileFormat(filePath, { fix: true, fixEol: 'crlf' });
    assert.deepEqual(fs.readFileSync(filePath), Buffer.concat([BOM, utf8('a\r\nb\r\n')]));
  });
});

test('fix:true adds only the BOM unless an explicit EOL target is also supplied', () => {
  const original = utf8('alpha\nbeta\n');
  withFile(original, (filePath) => {
    const result = applyCfmlFileFormat(filePath, { fix: true });
    const actual = fs.readFileSync(filePath);
    assert.deepEqual(actual, Buffer.concat([BOM, original]));
    assert.deepEqual(result.fixesApplied, ['bom']);
    assert.equal(result.inspection.eolStyle, 'lf');
    assert.equal(hasRule(result.messages, 'FILE_ENCODING_ERROR'), false);
  });

  withFile(original, (filePath) => {
    const result = applyCfmlFileFormat(filePath, { fix: true, fixEol: 'crlf' });
    assert.deepEqual(fs.readFileSync(filePath), Buffer.concat([BOM, utf8('alpha\r\nbeta\r\n')]));
    assert.deepEqual(result.fixesApplied, ['bom', 'eol']);
    assert.equal(hasRule(result.messages, 'FILE_ENCODING_ERROR'), false);
    assert.equal(hasRule(result.messages, 'FILE_EOL_MIXED'), false);
  });
});

test('does not rewrite an already-target EOL file or report a fix without a mutation', () => {
  const input = Buffer.concat([BOM, utf8('a\r\nb\r\n')]);
  withFile(input, (filePath) => {
    const result = applyCfmlFileFormat(filePath, { fix: true, fixEol: 'crlf' });
    assert.deepEqual(fs.readFileSync(filePath), input);
    assert.equal(result.fixesApplied, undefined);
    assert.equal(result.messages.length, 0);
  });
});

test('does not normalize EOL in invalid UTF-8 and keeps the invalid-byte diagnostic', () => {
  const input = Buffer.concat([BOM, Buffer.from([0x61, 0x0D, 0x0A, 0x62, 0x0A, 0xC3, 0x28])]);
  withFile(input, (filePath) => {
    const result = applyCfmlFileFormat(filePath, { fix: true, fixEol: 'crlf' });
    assert.deepEqual(fs.readFileSync(filePath), input);
    assert.equal(result.fixesApplied, undefined);
    assert.equal(hasRule(result.messages, 'FILE_UTF8_INVALID'), true);
    assert.equal(hasRule(result.messages, 'FILE_EOL_MIXED'), true);
  });
});

test('an EOL target without fix:true fails before mutating the file', () => {
  const input = Buffer.concat([BOM, utf8('a\n')]);
  withFile(input, (filePath) => {
    assert.throws(
      () => applyCfmlFileFormat(filePath, { fix: false, fixEol: 'crlf' }),
      /fix_eol requires fix:true.*No file changes were made/i,
    );
    assert.deepEqual(fs.readFileSync(filePath), input);
  });
});
