import net from 'node:net';
import child_process from 'node:child_process';
import EventEmitter from 'node:events';
import { PassThrough } from 'node:stream';
import fs from 'node:fs';
import fs_promises from 'node:fs/promises';
import supportsColor from 'supports-color';
import { assert, refute, match, sinon } from '@sinonjs/referee-sinon';
import { forwardToDaemon, isAlive } from './forwarder.js';
import { createResolver } from './resolver.js';
import { LINT_COMMAND } from './commands.js';

describe('lib/forwarder', () => {
  const resolver = createResolver();
  if (resolver === 'fail' || resolver === 'ignore') {
    throw new Error('Failed to create resolver');
  }
  const config = { token: 'token', port: 123, pid: 456, hash: 'hash' };
  const color_level = supportsColor.stdout?.['level'] || 0;
  let socket;
  let argv;

  let child;
  let read_file_promise;

  function returnThis() {
    // @ts-ignore
    return this;
  }
  let watcher;

  beforeEach(() => {
    watcher = new EventEmitter();
    watcher['close'] = sinon.fake();
    sinon.replace(fs, 'watch', sinon.fake.returns(watcher));
  });

  /**
   * @param {string} text
   */
  function fakeStdin(text) {
    const stdin = new PassThrough();
    stdin.end(text);
    sinon.replaceGetter(process, 'stdin', () => stdin);
  }

  beforeEach(() => {
    socket = new PassThrough();
    sinon.replace(socket, 'write', sinon.fake());
    sinon.replace(socket, 'end', sinon.fake());
    sinon.replace(socket, 'on', sinon.fake(returnThis));
    sinon.replace(net, 'connect', sinon.fake.returns(socket));
    sinon.replace(console, 'error', sinon.fake());
    argv = [process.argv0, 'eslint_d'];
    sinon.replace(process, 'argv', argv);
  });

  beforeEach(() => {
    read_file_promise = sinon.promise();
    sinon.replace(
      fs_promises,
      'readFile',
      sinon.fake.returns(read_file_promise)
    );
    child = { unref: sinon.fake() };
    sinon.replace(child_process, 'spawn', sinon.fake.returns(child));
  });

  context('isAlive', () => {
    it('connects to 127.0.0.1 on port from config', () => {
      isAlive(config);

      assert.calledOnceWith(net.connect, config.port, '127.0.0.1');
    });

    it('handles response without errors', async () => {
      const chunks = [];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );

      const result = isAlive(config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(2).callback(); // readable

      assert.equals(await result, true);
    });

    it('handles ECONNREFUSED', async () => {
      const result = isAlive(config);
      const err = new Error('Connection refused');
      err['code'] = 'ECONNREFUSED';
      socket.on.getCall(1).callback(err); // error

      assert.equals(await result, false);
    });
  });

  context('forwardToDaemon', () => {
    it('connects to 127.0.0.1 on port from config', () => {
      forwardToDaemon(resolver, config);

      assert.calledOnceWith(net.connect, config.port, '127.0.0.1');
    });

    it('writes token, color level, cwd and argv to socket', async () => {
      sinon.replace(process, 'cwd', sinon.fake.returns('the/cwd'));

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);

      assert.calledOnceWith(
        socket.write,
        `["token","${LINT_COMMAND}",${color_level},"the/cwd",[${JSON.stringify(process.argv0)},"eslint_d"],""]`
      );
      assert.calledOnce(socket.end);
    });

    it('writes DEBUG env to socket', async () => {
      sinon.replace(process, 'cwd', sinon.fake.returns('the/cwd'));
      sinon.define(process.env, 'DEBUG', 'eslint_d:*');

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);

      assert.calledOnceWith(
        socket.write,
        `["token","${LINT_COMMAND}",${color_level},"the/cwd",[${JSON.stringify(process.argv0)},"eslint_d"],"eslint_d:*"]`
      );
      assert.calledOnce(socket.end);
    });

    it('does not read from stdin by default', () => {
      sinon.replace(process.stdin, 'on', sinon.fake(returnThis));

      forwardToDaemon(resolver, config);

      refute.called(process.stdin.on);
    });

    it('reads from stdin if --stdin is in argv', () => {
      sinon.replace(process.stdin, 'on', sinon.fake(returnThis));
      argv.push('--stdin');

      forwardToDaemon(resolver, config);

      assert.calledThrice(process.stdin.on);
      assert.calledWith(process.stdin.on, 'readable', match.func);
      assert.calledWith(process.stdin.on, 'end', match.func);
      assert.calledWith(process.stdin.on, 'error', match.func);
    });

    it('writes text from stdin to socket', async () => {
      fakeStdin('text from stdin');
      argv.push('--stdin');

      forwardToDaemon(resolver, config);
      await new Promise(setImmediate);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);

      assert.calledThrice(socket.write);
      assert.calledWith(socket.write, '\n');
      assert.calledWith(socket.write, 'text from stdin');
    });

    it('forwards socket response to stdout', async () => {
      const chunks = ['response ', 'from daemon'];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );
      sinon.replace(process.stdout, 'write', sinon.fake());

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(2).callback(); // readable
      socket.on.getCall(3).callback(); // end

      assert.calledThrice(process.stdout.write);
      assert.calledWith(process.stdout.write, 're');
      assert.calledWith(process.stdout.write, 'sponse from');
      assert.calledWith(process.stdout.write, ' daemon');
    });

    it('launch daemon on first connection refused', async () => {
      const chunks = ['response ', 'from daemon'];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );
      sinon.replace(process.stdout, 'write', sinon.fake());
      sinon.replace(fs_promises, 'unlink', sinon.fake.resolves());

      forwardToDaemon(resolver, config);
      const err = new Error('Connection refused');
      err['code'] = 'ECONNREFUSED';
      socket.on.getCall(1).callback(err); // connectionAttemptFailed
      await new Promise(setImmediate);

      // removeConfig
      assert.calledOnceWith(fs_promises.unlink, `${resolver.base}/.eslint_d`);
      await new Promise(setImmediate);
      // spawn
      assert.calledOnce(child_process.spawn);
      // watch config
      watcher.emit('change', 'rename', '.eslint_d');
      await new Promise(setImmediate);
      // read config
      read_file_promise.resolve(JSON.stringify(config));
      await new Promise(setImmediate);

      assert.calledTwice(net.connect);
      socket.on.getCall(2).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(4).callback(); // readable
      socket.on.getCall(5).callback(); // end

      assert.calledThrice(process.stdout.write);
      assert.calledWith(process.stdout.write, 're');
      assert.calledWith(process.stdout.write, 'sponse from');
      assert.calledWith(process.stdout.write, ' daemon');
    });

    it('handles "EXIT000" from response', async () => {
      const chunks = ['response from daemonEXIT000'];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );
      sinon.replace(process.stdout, 'write', sinon.fake());

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(2).callback(); // readable
      socket.on.getCall(3).callback(); // end

      assert.calledWith(process.stdout.write, 'response from daemon');
      assert.equals(process.exitCode, 0);
      refute.called(console.error);
    });

    it('handles "EXIT001" from response', async () => {
      const chunks = ['response from daemonEXIT001'];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );
      sinon.replace(process.stdout, 'write', sinon.fake());

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(2).callback(); // readable
      socket.on.getCall(3).callback(); // end

      assert.calledWith(process.stdout.write, 'response from daemon');
      assert.equals(process.exitCode, 1);
      refute.called(console.error);
    });

    it('handles "EXIT123" from response', async () => {
      const chunks = ['response from daemonEXIT123'];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );
      sinon.replace(process.stdout, 'write', sinon.fake());

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(2).callback(); // readable
      socket.on.getCall(3).callback(); // end

      assert.calledWith(process.stdout.write, 'response from daemon');
      assert.equals(process.exitCode, 123);
      refute.called(console.error);
    });

    it('handles "EXIT001" inside response', async () => {
      const chunks = ['response EXIT001', ' from daemonEXIT002'];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );
      sinon.replace(process.stdout, 'write', sinon.fake());

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(2).callback(); // readable
      socket.on.getCall(3).callback(); // end

      assert.calledWith(process.stdout.write, 'response ');
      assert.calledWith(process.stdout.write, 'EXIT001 from daemon');
      assert.equals(process.exitCode, 2);
      refute.called(console.error);
    });

    it('logs error and sets exitCode to 1 if response does not end with EXIT marker', async () => {
      const chunks = ['response from daemon'];
      sinon.replace(
        socket,
        'read',
        sinon.fake(() => (chunks.length ? chunks.shift() : null))
      );
      sinon.replace(process.stdout, 'write', sinon.fake());

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(2).callback(); // readable
      socket.on.getCall(3).callback(); // end

      assert.calledWith(process.stdout.write, 'response from');
      assert.calledWith(process.stdout.write, ' daemon');
      assert.equals(process.exitCode, 1);
      assert.calledOnceWith(console.error, 'eslint_d: unexpected response');
    });

    it('logs error from stream', async () => {
      sinon.replace(fs_promises, 'unlink', sinon.fake.resolves());

      forwardToDaemon(resolver, config);
      socket.on.getCall(0).callback(); // connect
      await new Promise(setImmediate);
      socket.on.getCall(4).callback(new Error('Ouch!')); // error

      assert.calledOnceWith(console.error, 'eslint_d: Error: Ouch!');
      assert.equals(process.exitCode, 1);
      refute.called(fs_promises.unlink);
    });

    it('logs error on second ECONNREFUSED', async () => {
      sinon.replace(fs_promises, 'unlink', sinon.fake.resolves());

      forwardToDaemon(resolver, config);
      const err = new Error('Connection refused');
      err['code'] = 'ECONNREFUSED';
      socket.on.getCall(1).callback(err); // connectionAttemptFailed
      await new Promise(setImmediate);

      // removeConfig
      assert.calledOnceWith(fs_promises.unlink, `${resolver.base}/.eslint_d`);
      await new Promise(setImmediate);
      // spawn
      assert.calledOnce(child_process.spawn);
      // watch config
      watcher.emit('change', 'rename', '.eslint_d');
      await new Promise(setImmediate);
      // read config
      read_file_promise.resolve(JSON.stringify(config));
      await new Promise(setImmediate);

      assert.calledTwice(net.connect);
      socket.on.getCall(3).callback(err); // connectionAttemptFailed
      await new Promise(setImmediate);

      assert.calledOnceWith(
        console.error,
        'eslint_d: Error: Connection refused - removing config'
      );
      assert.equals(process.exitCode, 1);
      assert.calledTwice(fs_promises.unlink);
    });

    context('--fix-to-stdout', () => {
      beforeEach(() => {
        sinon.replace(process, 'cwd', sinon.fake.returns('cwd'));
        fakeStdin('text from stdin');
      });

      it('throws if --stdin is absent', async () => {
        argv.push('--fix-to-stdout');

        await forwardToDaemon(resolver, config);

        assert.equals(process.exitCode, 1);
        assert.calledOnceWith(
          console.error,
          '--fix-to-stdout requires passing --stdin as well'
        );
      });

      it('replaces the option with --fix-dry-run --format json', async () => {
        argv.push('--stdin', '--fix-to-stdout', '--other', '--options');

        forwardToDaemon(resolver, config);
        await new Promise(setImmediate);
        socket.on.getCall(0).callback(); // connect
        await new Promise(setImmediate);

        assert.calledThrice(socket.write);
        assert.calledWith(
          socket.write,
          `["token","${LINT_COMMAND}",${color_level},"cwd",[${JSON.stringify(process.argv0)},"eslint_d","--stdin","--fix-dry-run","--format","json","--other","--options"],""]`
        );
        assert.calledWith(socket.write, '\n');
        assert.calledWith(socket.write, 'text from stdin');
        assert.calledOnce(socket.end);
      });

      it('prints fixed output to stdout', async () => {
        argv.push('--stdin', '--fix-to-stdout');
        const chunks = ['[{"output":"response from daemon"}]EXIT001'];
        sinon.replace(
          socket,
          'read',
          sinon.fake(() => (chunks.length ? chunks.shift() : null))
        );
        sinon.replace(process.stdout, 'write', sinon.fake());

        forwardToDaemon(resolver, config);
        await new Promise(setImmediate);
        socket.on.getCall(0).callback(); // connect
        await new Promise(setImmediate);
        socket.on.getCall(2).callback(); // readable
        socket.on.getCall(3).callback(); // end

        assert.calledWith(process.stdout.write, 'response from daemon');
        assert.equals(process.exitCode, 0);
        refute.called(console.error);
      });

      it('prints original input to stdout if no output', async () => {
        argv.push('--stdin', '--fix-to-stdout');
        const chunks = ['[{}]EXIT000'];
        sinon.replace(
          socket,
          'read',
          sinon.fake(() => (chunks.length ? chunks.shift() : null))
        );
        sinon.replace(process.stdout, 'write', sinon.fake());

        forwardToDaemon(resolver, config);
        await new Promise(setImmediate);
        socket.on.getCall(0).callback(); // connect
        await new Promise(setImmediate);
        socket.on.getCall(2).callback(); // readable
        socket.on.getCall(3).callback(); // end

        assert.calledWith(process.stdout.write, 'text from stdin');
        assert.equals(process.exitCode, 0);
        refute.called(console.error);
      });

      it('prints error to stderr and original input to stdout if output cannot be parsed', async () => {
        argv.push('--stdin', '--fix-to-stdout');
        const chunks = ['NotJSON!EXIT000'];
        sinon.replace(
          socket,
          'read',
          sinon.fake(() => (chunks.length ? chunks.shift() : null))
        );
        sinon.replace(process.stdout, 'write', sinon.fake());

        forwardToDaemon(resolver, config);
        await new Promise(setImmediate);
        socket.on.getCall(0).callback(); // connect
        await new Promise(setImmediate);
        socket.on.getCall(2).callback(); // readable
        socket.on.getCall(3).callback(); // end

        assert.calledWith(process.stdout.write, 'text from stdin');
        assert.equals(process.exitCode, 1);
        let error;
        try {
          JSON.parse('NotJSON!');
        } catch (err) {
          error = err;
        }
        assert.calledOnceWith(console.error, `eslint_d: ${error}`);
      });

      it('logs error and sets exitCode to 1 if response does not end with EXIT marker', async () => {
        argv.push('--stdin', '--fix-to-stdout');
        const chunks = ['response from daemon'];
        sinon.replace(
          socket,
          'read',
          sinon.fake(() => (chunks.length ? chunks.shift() : null))
        );
        sinon.replace(process.stdout, 'write', sinon.fake());

        forwardToDaemon(resolver, config);
        await new Promise(setImmediate);
        socket.on.getCall(0).callback(); // connect
        await new Promise(setImmediate);
        socket.on.getCall(2).callback(); // readable
        socket.on.getCall(3).callback(); // end

        assert.calledWith(process.stdout.write, 'response from daemon');
        assert.equals(process.exitCode, 1);
        assert.calledOnceWith(console.error, 'eslint_d: unexpected response');
      });
    });
  });
});
