import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
import express from 'express';
import fs from 'fs';
import os from 'os';
import path from 'path';
import request from 'supertest';

import { registerPluginRoutes } from './plugin-routes.js';

let projectDir;
let userConfigPath;
let rootDir;
let plugins;
let refreshOpenCodeAfterConfigChange;
let app;
let cleanupPaths;

const testUnlessRoot = typeof process.getuid === 'function' && process.getuid() === 0 ? test.skip : test;

function readJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}

function createApp(overrides = {}) {
  const testApp = express();
  testApp.use(express.json());
  registerPluginRoutes(testApp, {
    resolveOptionalProjectDirectory: async () => ({ directory: projectDir, error: null }),
    refreshOpenCodeAfterConfigChange,
    clientReloadDelayMs: 25,
    listPluginEntries: plugins.listPluginEntries,
    getPluginEntry: plugins.getPluginEntry,
    createPluginEntry: plugins.createPluginEntry,
    updatePluginEntry: plugins.updatePluginEntry,
    deletePluginEntry: plugins.deletePluginEntry,
    listPluginDirFiles: plugins.listPluginDirFiles,
    readPluginDirFile: plugins.readPluginDirFile,
    writePluginDirFile: plugins.writePluginDirFile,
    deletePluginDirFile: plugins.deletePluginDirFile,
    encodePluginId: plugins.encodePluginId,
    decodePluginId: plugins.decodePluginId,
    ...overrides,
  });
  return testApp;
}

function createRegistryApp(getNpmInfo) {
  app = createApp({ getNpmInfo });
  return app;
}

async function createEntry(spec = 'a') {
  return request(app)
    .post('/api/config/plugins/entry')
    .send({ spec, scope: 'user' })
    .expect(200);
}

async function createFile(fileName = 'test.js', content = '//x') {
  return request(app)
    .post('/api/config/plugins/file')
    .send({ fileName, content, scope: 'user' })
    .expect(200);
}

describe('opencode plugin routes', () => {
  beforeAll(async () => {
    rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugin-routes-'));
    userConfigPath = path.join(rootDir, 'user-opencode.json');
    process.env.OPENCODE_CONFIG = userConfigPath;
    plugins = await import('./plugins.js');
  });

  beforeEach(() => {
    projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
    fs.rmSync(userConfigPath, { force: true });
    fs.rmSync(path.join(rootDir, 'plugins'), { recursive: true, force: true });
    refreshOpenCodeAfterConfigChange = mock(async () => undefined);
    cleanupPaths = [];
    app = createApp();
  });

  afterEach(() => {
    for (const target of cleanupPaths) {
      try {
        fs.chmodSync(target, 0o600);
      } catch {
      }
    }
  });

  afterAll(() => {
    fs.rmSync(rootDir, { recursive: true, force: true });
    delete process.env.OPENCODE_CONFIG;
  });

  test('GET /api/config/plugins empty returns entries and files arrays', async () => {
    const response = await request(app).get('/api/config/plugins').expect(200);

    expect(response.body).toEqual({ entries: [], files: [] });
  });

  test('GET /registry with empty specs returns empty results', async () => {
    const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
    createRegistryApp(getNpmInfo);

    const response = await request(app).get('/api/config/plugins/registry?specs=').expect(200);

    expect(response.body).toEqual({ results: [] });
    expect(getNpmInfo).not.toHaveBeenCalled();
  });

  test('GET /registry reports update for exact npm version behind latest', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));

    const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);

    expect(response.body.results[0]).toMatchObject({
      kind: 'npm-ok',
      spec: 'foo@1.0.0',
      name: 'foo',
      currentVersion: '1.0.0',
      latestVersion: '2.0.0',
      hasUpdate: true,
    });
  });

  test('GET /registry reports no update when exact npm version matches latest', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));

    const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);

    expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', hasUpdate: false, latestVersion: '1.0.0', currentVersion: '1.0.0' });
  });

  test('GET /registry reports missing exact npm version', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));

    const response = await request(app).get('/api/config/plugins/registry?specs=foo@99.99.99').expect(200);

    expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-version', name: 'foo', currentVersion: '99.99.99', latestVersion: '2.0.0' });
  });

  test('GET /registry reports missing npm package', async () => {
    createRegistryApp(mock(async () => ({ ok: false, status: 404, error: 'Package not found' })));

    const response = await request(app).get('/api/config/plugins/registry?specs=nonexistent@1.0.0').expect(200);

    expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-package', spec: 'nonexistent@1.0.0', name: 'nonexistent', error: 'Package not found' });
  });

  test('GET /registry reports malformed npm spec', async () => {
    const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
    createRegistryApp(getNpmInfo);

    const response = await request(app).get('/api/config/plugins/registry?specs=%40%40malformed').expect(200);

    expect(response.body.results[0]).toEqual({ kind: 'npm-malformed', spec: '@@malformed', error: 'Spec syntax is malformed' });
    expect(getNpmInfo).not.toHaveBeenCalled();
  });

  test('GET /registry reports existing path plugin ok', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
    const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-path-')), 'plugin.js');
    fs.writeFileSync(tmpFile, '// plugin', 'utf8');

    const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);

    expect(response.body.results[0]).toEqual({ kind: 'path-ok', spec: tmpFile, absolutePath: tmpFile });
  });

  test('GET /registry reports missing path plugin', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));

    const response = await request(app).get('/api/config/plugins/registry?specs=%2Fnonexistent%2F__path%2Fxyz.js').expect(200);

    expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: '/nonexistent/__path/xyz.js', absolutePath: '/nonexistent/__path/xyz.js' });
  });

  test('GET /registry treats Windows absolute paths as local paths', async () => {
    const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
    createRegistryApp(getNpmInfo);

    const windowsPath = 'C:\\Users\\me\\plugin.js';
    const response = await request(app)
      .get(`/api/config/plugins/registry?specs=${encodeURIComponent(windowsPath)}`)
      .expect(200);

    expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: windowsPath, absolutePath: windowsPath });
    expect(getNpmInfo).not.toHaveBeenCalled();
  });

  testUnlessRoot('GET /registry reports unreadable path plugin', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
    const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-unreadable-')), 'plugin.js');
    fs.writeFileSync(tmpFile, '// plugin', 'utf8');
    cleanupPaths.push(tmpFile);
    fs.chmodSync(tmpFile, 0);

    const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);

    expect(response.body.results[0]).toEqual({ kind: 'path-unreadable', spec: tmpFile, absolutePath: tmpFile });
  });

  test('GET /registry reports npm network failure without failing route', async () => {
    createRegistryApp(mock(async () => ({ ok: false, status: 'network', error: 'socket closed' })));

    const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);

    expect(response.body.results[0]).toMatchObject({ kind: 'npm-network', spec: 'foo@1.0.0', error: 'socket closed' });
  });

  test('GET /registry deduplicates npm package lookups by name', async () => {
    const getNpmInfo = mock(async () => ({ ok: true, latest: '3.0.0', versions: ['1', '2', '3'], distTags: { latest: '3.0.0' } }));
    createRegistryApp(getNpmInfo);

    await request(app).get('/api/config/plugins/registry?specs=foo@1,foo@2,foo@3').expect(200);

    expect(getNpmInfo).toHaveBeenCalledTimes(1);
    expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: false });
  });

  test('GET /registry forwards refresh true to npm lookup', async () => {
    const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
    createRegistryApp(getNpmInfo);

    await request(app).get('/api/config/plugins/registry?specs=foo&refresh=true').expect(200);

    expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: true });
  });

  test('GET /registry rejects more than 100 unique specs', async () => {
    const specs = Array.from({ length: 101 }, (_, index) => `pkg-${index}`).join(',');

    const response = await request(app).get(`/api/config/plugins/registry?specs=${specs}`).expect(400);

    expect(response.body).toEqual({ error: 'too many specs' });
  });

  test('GET /registry reports bare npm name with null current version', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));

    const response = await request(app).get('/api/config/plugins/registry?specs=foo').expect(200);

    expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo', name: 'foo', currentVersion: null, hasUpdate: false });
  });

  test('GET /registry accepts non-exact npm range without missing-version noise', async () => {
    createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));

    const response = await request(app).get('/api/config/plugins/registry?specs=foo@%5E1.0').expect(200);

    expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo@^1.0', name: 'foo', currentVersion: '^1.0', hasUpdate: false });
  });

  test('GET /registry supports scoped npm package specs', async () => {
    const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
    createRegistryApp(getNpmInfo);

    const response = await request(app).get('/api/config/plugins/registry?specs=%40scope%2Ffoo%401.0.0').expect(200);

    expect(getNpmInfo).toHaveBeenCalledWith('@scope/foo', { forceRefresh: false });
    expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' });
  });

  test('POST /entry creates entry and requires reload', async () => {
    const response = await createEntry('a');

    expect(response.body).toMatchObject({ success: true, requiresReload: true, reloadDelayMs: 25 });
    expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry creation');
  });

  test('GET after POST returns created entry', async () => {
    await createEntry('a');

    const response = await request(app).get('/api/config/plugins').expect(200);

    expect(response.body.entries).toEqual([expect.objectContaining({ spec: 'a', scope: 'user' })]);
  });

  test('POST duplicate entry returns 409', async () => {
    await createEntry('a');

    const response = await request(app)
      .post('/api/config/plugins/entry')
      .send({ spec: 'a', scope: 'user' })
      .expect(409);

    expect(response.body.error).toContain('already exists');
  });

  test('PATCH /entry/:id updates entry in same array index', async () => {
    await createEntry('a');
    const before = await request(app).get('/api/config/plugins').expect(200);
    const id = before.body.entries[0].id;

    const response = await request(app)
      .patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
      .send({ spec: 'b' })
      .expect(200);

    expect(response.body.success).toBe(true);
    const after = await request(app).get('/api/config/plugins').expect(200);
    expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' }));
    expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry update');
  });

  test('DELETE /entry/:id removes entry and prunes plugin key', async () => {
    await createEntry('a');
    const listed = await request(app).get('/api/config/plugins').expect(200);
    const id = listed.body.entries[0].id;

    await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200);

    const after = await request(app).get('/api/config/plugins').expect(200);
    expect(after.body.entries).toEqual([]);
    expect(readJson(userConfigPath).plugin).toBeUndefined();
    expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry deletion');
  });

  test('POST /file writes plugin dir file', async () => {
    const response = await createFile('test.js', '//x');

    expect(response.body).toMatchObject({ success: true, requiresReload: true });
    expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
    expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file creation');
  });

  test('POST duplicate file returns 409', async () => {
    await createFile('test.js', '//x');

    const response = await request(app)
      .post('/api/config/plugins/file')
      .send({ fileName: 'test.js', content: '//again', scope: 'user' })
      .expect(409);

    expect(response.body.error).toContain('already exists');
  });

  test('PUT /file/:id updates file content', async () => {
    await createFile('test.js', '//x');
    const listed = await request(app).get('/api/config/plugins').expect(200);
    const id = listed.body.files[0].id;

    await request(app)
      .put(`/api/config/plugins/file/${encodeURIComponent(id)}`)
      .send({ content: '//y' })
      .expect(200);

    expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y');
    expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file update');
  });

  test('DELETE /file/:id unlinks file', async () => {
    await createFile('test.js', '//x');
    const listed = await request(app).get('/api/config/plugins').expect(200);
    const id = listed.body.files[0].id;

    await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);

    expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
    expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file deletion');
  });

  test('PATCH unknown entry id returns 404', async () => {
    const id = plugins.encodePluginId('config', 'user:missing');

    const response = await request(app)
      .patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
      .send({ spec: 'b' })
      .expect(404);

    expect(response.body.error).toContain('not found');
  });

  test('POST invalid fileName returns 400', async () => {
    const response = await request(app)
      .post('/api/config/plugins/file')
      .send({ fileName: '../escape.js', content: '//x', scope: 'user' })
      .expect(400);

    expect(response.body.error).toContain('Plugin file name');
  });
});
