jest.mock('proper-lockfile');
jest.mock('fs', () => ({
  existsSync: jest.fn().mockReturnValue(true),
  mkdirSync: jest.fn(),
  writeFileSync: jest.fn(),
  promises: {
    readFile: jest.fn().mockResolvedValue('[]'),
    writeFile: jest.fn().mockResolvedValue(),
  },
}));

const lockfile = require('proper-lockfile');
const fs = require('fs');
const StateManager = require('../src/managers/state-manager');

describe('StateManager', () => {
  let sm;
  const TEST_PATH = '/tmp/test-state.json';

  beforeEach(() => {
    jest.clearAllMocks();
    fs.existsSync.mockReturnValue(true);
    fs.promises.readFile.mockResolvedValue('[]');
    fs.promises.writeFile.mockResolvedValue();
    lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
    lockfile.check.mockResolvedValue(false);
    lockfile.unlock.mockResolvedValue();

    sm = new StateManager(TEST_PATH);
  });

  describe('constructor', () => {
    it('creates file with [] if it does not exist', () => {
      fs.existsSync.mockReturnValue(false);
      new StateManager('/tmp/new-state.json');
      expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/new-state.json', '[]', 'utf8');
    });

    it('creates directory recursively if needed', () => {
      fs.existsSync.mockReturnValue(false);
      new StateManager('/tmp/deep/nested/state.json');
      expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true });
    });

    it('does not create file if it exists', () => {
      fs.existsSync.mockReturnValue(true);
      fs.writeFileSync.mockClear();
      new StateManager(TEST_PATH);
      expect(fs.writeFileSync).not.toHaveBeenCalled();
    });
  });

  describe('read', () => {
    it('returns parsed JSON from file', async () => {
      fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'svc1' }]));
      const data = await sm.read();
      expect(data).toEqual([{ id: 'svc1' }]);
    });

    it('returns [] and recreates file on ENOENT', async () => {
      const err = new Error('ENOENT');
      err.code = 'ENOENT';
      fs.promises.readFile.mockRejectedValue(err);
      fs.existsSync.mockReturnValue(false);

      const data = await sm.read();
      expect(data).toEqual([]);
    });

    it('throws on invalid JSON', async () => {
      fs.promises.readFile.mockResolvedValue('{bad json}');
      await expect(sm.read()).rejects.toThrow('Failed to read state file');
    });
  });

  describe('write', () => {
    it('acquires lock, writes JSON, releases lock', async () => {
      const releaseFn = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValue(releaseFn);

      await sm.write([{ id: 'new' }]);

      expect(lockfile.lock).toHaveBeenCalledWith(TEST_PATH, expect.any(Object));
      expect(fs.promises.writeFile).toHaveBeenCalledWith(
        TEST_PATH,
        JSON.stringify([{ id: 'new' }], null, 2),
        'utf8'
      );
      expect(releaseFn).toHaveBeenCalled();
    });

    it('throws on ELOCKED', async () => {
      const err = new Error('locked');
      err.code = 'ELOCKED';
      lockfile.lock.mockRejectedValue(err);

      await expect(sm.write([])).rejects.toThrow('locked by another process');
    });

    it('releases lock even on write error', async () => {
      const releaseFn = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValue(releaseFn);
      fs.promises.writeFile.mockRejectedValue(new Error('disk full'));

      await expect(sm.write([])).rejects.toThrow();
      expect(releaseFn).toHaveBeenCalled();
    });
  });

  describe('update', () => {
    it('atomic read-modify-write cycle', async () => {
      const releaseFn = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValue(releaseFn);
      fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));

      const result = await sm.update(items => {
        items.push({ id: '2' });
        return items;
      });

      expect(result).toEqual([{ id: '1' }, { id: '2' }]);
      expect(fs.promises.writeFile).toHaveBeenCalled();
      expect(releaseFn).toHaveBeenCalled();
    });

    it('passes current data to updateFn', async () => {
      const releaseFn = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValue(releaseFn);
      fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'existing' }]));

      const updateFn = jest.fn(data => data);
      await sm.update(updateFn);

      expect(updateFn).toHaveBeenCalledWith([{ id: 'existing' }]);
    });

    it('throws on ELOCKED', async () => {
      const err = new Error('locked');
      err.code = 'ELOCKED';
      lockfile.lock.mockRejectedValue(err);

      await expect(sm.update(d => d)).rejects.toThrow('locked by another process');
    });
  });

  describe('convenience methods', () => {
    beforeEach(() => {
      const releaseFn = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValue(releaseFn);
    });

    it('addItem appends to array', async () => {
      fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));
      const result = await sm.addItem({ id: '2', name: 'New' });
      expect(result).toEqual([{ id: '1' }, { id: '2', name: 'New' }]);
    });

    it('removeItem filters by id', async () => {
      fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }, { id: '2' }]));
      const result = await sm.removeItem('1');
      expect(result).toEqual([{ id: '2' }]);
    });

    it('updateItem merges updates for matching id', async () => {
      fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Old' }]));
      const result = await sm.updateItem('1', { name: 'New', port: 8080 });
      expect(result).toEqual([{ id: '1', name: 'New', port: 8080 }]);
    });

    it('findItem returns matching item or null', async () => {
      fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Found' }]));
      const found = await sm.findItem('1');
      expect(found).toEqual({ id: '1', name: 'Found' });

      const missing = await sm.findItem('999');
      expect(missing).toBeNull();
    });
  });

  describe('isLocked', () => {
    it('returns lockfile.check result', async () => {
      lockfile.check.mockResolvedValue(true);
      expect(await sm.isLocked()).toBe(true);

      lockfile.check.mockResolvedValue(false);
      expect(await sm.isLocked()).toBe(false);
    });

    it('returns false on error', async () => {
      lockfile.check.mockRejectedValue(new Error('fail'));
      expect(await sm.isLocked()).toBe(false);
    });
  });

  describe('forceUnlock', () => {
    it('calls lockfile.unlock', async () => {
      await sm.forceUnlock();
      expect(lockfile.unlock).toHaveBeenCalledWith(TEST_PATH);
    });

    it('ignores ENOTACQUIRED error', async () => {
      const err = new Error('not locked');
      err.code = 'ENOTACQUIRED';
      lockfile.unlock.mockRejectedValue(err);
      await expect(sm.forceUnlock()).resolves.toBeUndefined();
    });

    it('throws other errors', async () => {
      lockfile.unlock.mockRejectedValue(new Error('other'));
      await expect(sm.forceUnlock()).rejects.toThrow('other');
    });
  });
});
