// @vitest-environment node
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import yaml from 'js-yaml';

// Isolate writes to a temp dir so the real user-data/conf.yml is never touched
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-api-'));
process.env.USER_DATA_DIR = tmpDir;
process.env.ENABLE_API = 'true';
afterAll(() => {
  fs.rmSync(tmpDir, { recursive: true, force: true });
  delete process.env.ENABLE_API;
});

const app = require('../../services/app');

const seedConf = `pageInfo:
  title: Test Dashboard
sections:
  - name: Section One
    items:
      - title: Item A
        url: https://example.com/a
      - title: Item B
  - name: Section Two
`;

const readDisk = (file) => yaml.load(fs.readFileSync(path.join(tmpDir, file), 'utf8'));

beforeEach(() => {
  fs.writeFileSync(path.join(tmpDir, 'conf.yml'), seedConf);
  fs.writeFileSync(path.join(tmpDir, 'page2.yml'), 'pageInfo:\n  title: Page Two\n');
});

describe('API enablement gate', () => {
  it('returns 404 when ENABLE_API is not true', async () => {
    process.env.ENABLE_API = 'false';
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(404);
    expect(res.body.message).toContain('API not enabled');
    process.env.ENABLE_API = 'true';
  });

  it('returns 404 when ENABLE_API is unset', async () => {
    delete process.env.ENABLE_API;
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(404);
    process.env.ENABLE_API = 'true';
  });

  it('returns 404 for unknown API routes', async () => {
    const res = await request(app).get('/api/nonsense');
    expect(res.status).toBe(404);
    expect(res.body.message).toBe('Not found');
  });
});

describe('List and read config', () => {
  it('lists config files, excluding backups', async () => {
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(200);
    expect(res.body.files).toContain('conf.yml');
    expect(res.body.files).toContain('page2.yml');
    expect(res.body.files).not.toContain('config-backups');
  });

  it('returns a config file as JSON', async () => {
    const res = await request(app).get('/api/config/conf.yml');
    expect(res.status).toBe(200);
    expect(res.body.pageInfo.title).toBe('Test Dashboard');
    expect(res.body.sections).toHaveLength(2);
  });

  it('404s for a missing file', async () => {
    const res = await request(app).get('/api/config/nope.yml');
    expect(res.status).toBe(404);
  });

  it('rejects path traversal', async () => {
    const res = await request(app).get('/api/config/..%2F..%2Fetc%2Fpasswd');
    expect(res.status).toBe(400);
  });

  it('rejects non-yaml filenames', async () => {
    const res = await request(app).get('/api/config/evil.txt');
    expect(res.status).toBe(400);
  });

  it('returns a top-level key', async () => {
    const res = await request(app).get('/api/config/conf.yml/pageInfo');
    expect(res.status).toBe(200);
    expect(res.body.title).toBe('Test Dashboard');
  });

  it('rejects a non-whitelisted key', async () => {
    const res = await request(app).get('/api/config/conf.yml/banana');
    expect(res.status).toBe(400);
  });

  it('404s for an absent key', async () => {
    const res = await request(app).get('/api/config/conf.yml/appConfig');
    expect(res.status).toBe(404);
  });

  it('500s for unparseable YAML', async () => {
    fs.writeFileSync(path.join(tmpDir, 'broken.yml'), 'foo: [unclosed');
    const res = await request(app).get('/api/config/broken.yml');
    expect(res.status).toBe(500);
  });
});

describe('Replace config and keys', () => {
  it('replaces a whole config file and makes a backup', async () => {
    const res = await request(app).put('/api/config/conf.yml')
      .send({ pageInfo: { title: 'Replaced' }, sections: [] });
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(readDisk('conf.yml').pageInfo.title).toBe('Replaced');
    const backups = fs.readdirSync(path.join(tmpDir, 'config-backups'));
    expect(backups.some((f) => f.startsWith('conf-'))).toBe(true);
  });

  it('rejects schema-invalid conf.yml writes', async () => {
    const res = await request(app).put('/api/config/conf.yml')
      .send({ sections: 'not-a-list' });
    expect(res.status).toBe(400);
    expect(res.body.message).toContain('schema');
    expect(readDisk('conf.yml').sections).toHaveLength(2);
  });

  it('accepts a pageInfo-only sub-page write', async () => {
    const res = await request(app).put('/api/config/page2.yml')
      .send({ pageInfo: { title: 'Updated Page' } });
    expect(res.status).toBe(200);
    expect(readDisk('page2.yml').pageInfo.title).toBe('Updated Page');
  });

  it('rejects oversized configs', async () => {
    const res = await request(app).put('/api/config/conf.yml')
      .send({ sections: [{ name: 'x'.repeat(300 * 1024) }] });
    expect(res.status).toBe(400);
    expect(res.body.message).toContain('256');
  });

  it('replaces a top-level key', async () => {
    const res = await request(app).put('/api/config/conf.yml/pageInfo')
      .send({ title: 'New Title' });
    expect(res.status).toBe(200);
    expect(readDisk('conf.yml').pageInfo.title).toBe('New Title');
  });
});

describe('Sections CRUD', () => {
  it('adds a section', async () => {
    const res = await request(app).post('/api/config/conf.yml/sections')
      .send({ name: 'Section Three' });
    expect(res.status).toBe(201);
    expect(res.body.index).toBe(2);
    expect(readDisk('conf.yml').sections).toHaveLength(3);
  });

  it('rejects a section without a name', async () => {
    const res = await request(app).post('/api/config/conf.yml/sections').send({});
    expect(res.status).toBe(400);
  });

  it('gets a section by index', async () => {
    const res = await request(app).get('/api/config/conf.yml/sections/0');
    expect(res.body.name).toBe('Section One');
  });

  it('gets a section by name', async () => {
    const res = await request(app).get('/api/config/conf.yml/sections/Section%20Two');
    expect(res.body.name).toBe('Section Two');
  });

  it('patches a section, leaving other fields untouched', async () => {
    const res = await request(app).patch('/api/config/conf.yml/sections/0')
      .send({ icon: 'fas fa-rocket' });
    expect(res.status).toBe(200);
    const section = readDisk('conf.yml').sections[0];
    expect(section.icon).toBe('fas fa-rocket');
    expect(section.items).toHaveLength(2);
  });

  it('deletes a section', async () => {
    const res = await request(app).delete('/api/config/conf.yml/sections/1');
    expect(res.status).toBe(200);
    expect(readDisk('conf.yml').sections).toHaveLength(1);
  });

  it('404s for an unknown section', async () => {
    expect((await request(app).get('/api/config/conf.yml/sections/99')).status).toBe(404);
    expect((await request(app).get('/api/config/conf.yml/sections/Nope')).status).toBe(404);
  });

  it('400s when sections is not a list', async () => {
    fs.writeFileSync(path.join(tmpDir, 'scalar.yml'), 'sections: just-a-string\n');
    const res = await request(app).get('/api/config/scalar.yml/sections/0');
    expect(res.status).toBe(400);
  });
});

describe('Items CRUD', () => {
  it('lists items, defaulting to empty for a section without any', async () => {
    expect((await request(app).get('/api/config/conf.yml/sections/0/items')).body).toHaveLength(2);
    expect((await request(app).get('/api/config/conf.yml/sections/1/items')).body).toEqual([]);
  });

  it('adds an item, creating the items array if missing', async () => {
    const res = await request(app).post('/api/config/conf.yml/sections/1/items')
      .send({ title: 'New Item' });
    expect(res.status).toBe(201);
    expect(readDisk('conf.yml').sections[1].items[0].title).toBe('New Item');
  });

  it('rejects an item without a title', async () => {
    const res = await request(app).post('/api/config/conf.yml/sections/0/items').send({});
    expect(res.status).toBe(400);
  });

  it('gets an item by index and by title', async () => {
    expect((await request(app).get('/api/config/conf.yml/sections/0/items/1')).body.title).toBe('Item B');
    expect((await request(app).get('/api/config/conf.yml/sections/0/items/Item%20A')).body.title).toBe('Item A');
  });

  it('patches an item', async () => {
    const res = await request(app).patch('/api/config/conf.yml/sections/0/items/0')
      .send({ url: 'https://example.com/new' });
    expect(res.status).toBe(200);
    const item = readDisk('conf.yml').sections[0].items[0];
    expect(item.url).toBe('https://example.com/new');
    expect(item.title).toBe('Item A');
  });

  it('deletes an item', async () => {
    const res = await request(app).delete('/api/config/conf.yml/sections/0/items/0');
    expect(res.status).toBe(200);
    expect(readDisk('conf.yml').sections[0].items).toHaveLength(1);
  });

  it('404s for an unknown item', async () => {
    const res = await request(app).get('/api/config/conf.yml/sections/0/items/99');
    expect(res.status).toBe(404);
  });
});

describe('Robustness', () => {
  it('returns JSON for malformed request bodies', async () => {
    const res = await request(app).post('/api/config/conf.yml/sections')
      .set('Content-Type', 'application/json').send('{not json');
    expect(res.status).toBe(400);
    expect(res.body.success).toBe(false);
  });

  it('returns JSON for bodies over the 1mb parser limit', async () => {
    const res = await request(app).put('/api/config/conf.yml')
      .set('Content-Type', 'application/json')
      .send(`{"sections": [{"name": "${'x'.repeat(1100 * 1024)}"}]}`);
    expect(res.status).toBe(413);
    expect(res.body.success).toBe(false);
  });


  it('does not write when the existing file is unparseable', async () => {
    fs.writeFileSync(path.join(tmpDir, 'broken.yml'), 'foo: [unclosed');
    const res = await request(app).patch('/api/config/broken.yml/sections/0').send({ name: 'x' });
    expect(res.status).toBe(500);
    expect(fs.readFileSync(path.join(tmpDir, 'broken.yml'), 'utf8')).toBe('foo: [unclosed');
  });

  it('handles concurrent writes without corrupting the file', async () => {
    const patch = (icon) => request(app)
      .patch('/api/config/conf.yml/sections/0').send({ icon });
    const results = await Promise.all(['a', 'b', 'c', 'd', 'e'].map(patch));
    results.forEach((res) => expect(res.status).toBe(200));
    expect(() => readDisk('conf.yml')).not.toThrow();
  });
});
