import assert from 'node:assert/strict';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';

const runtime = await fsp.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-services-'));
process.env.DATA_DIR = path.join(runtime, 'data');
process.env.STORAGE_DIR = path.join(runtime, 'storage');

const { config, ensureRuntimeDirectories } = await import('../src/config.js');
const { openDatabase, transaction } = await import('../src/database.js');
const { HttpError, notFoundHandler, errorHandler } = await import('../src/middleware/errors.js');
const express = (await import('express')).default;
const { createApiRouter } = await import('../src/routes/api.js');
const { inspectUpload } = await import('../src/middleware/upload.js');
const routes = await import('../src/services/routes-service.js');
const media = await import('../src/services/media-service.js');
const storage = await import('../src/services/storage.js');

ensureRuntimeDirectories();

const PNG_FILE = Buffer.from(
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
  'base64'
);
const MP3_FILE = Buffer.concat([
  Buffer.from('49443304000000000000', 'hex'),
  Buffer.from('fffb9064', 'hex'),
  Buffer.alloc(512)
]);
const GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test"><metadata><name>Teststrecke</name></metadata><trk><trkseg>
<trkpt lat="50.0000" lon="10.0000"><ele>100</ele></trkpt>
<trkpt lat="50.0010" lon="10.0010"><ele>110</ele></trkpt>
</trkseg></trk></gpx>`;
const GPX_APPEND = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test"><trk><trkseg>
<trkpt lat="50.0020" lon="10.0020"><ele>105</ele></trkpt>
</trkseg></trk></gpx>`;

let uploadCounter = 0;
function uploadedFile(name, contents, extra = {}) {
  const filePath = path.join(config.storageDir, 'uploads', `${Date.now()}-${uploadCounter += 1}-${name}`);
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, contents);
  return {
    fieldname: extra.fieldname || 'file',
    originalname: name,
    encoding: '7bit',
    mimetype: extra.mimetype || 'application/octet-stream',
    destination: path.dirname(filePath),
    filename: path.basename(filePath),
    path: filePath,
    size: Buffer.byteLength(contents),
    ...extra
  };
}

function expectHttpError(callback, status) {
  assert.throws(callback, error => error instanceof HttpError && error.status === status);
}

async function invokeUpload(middleware, req) {
  return new Promise(resolve => {
    middleware(req, {}, error => resolve(error));
  });
}

test.after(async () => {
  await fsp.rm(runtime, { recursive: true, force: true });
});

test('route, POI, media and storage services cover successful and rejected lifecycle paths', async () => {
  const db = openDatabase(path.join(config.dataDir, 'services.sqlite'));
  try {
    assert.equal(transaction(db, () => 42), 42);
    assert.throws(() => transaction(db, () => {
      throw new Error('rollback');
    }), /rollback/);

    expectHttpError(() => routes.createRoute(db, {}, null), 400);

    const invalidGpx = uploadedFile('invalid.gpx', '<xml/>');
    expectHttpError(() => routes.createRoute(db, { name: 'Ungültig' }, invalidGpx), 400);
    assert.equal(fs.existsSync(invalidGpx.path), false);

    const route = routes.createRoute(db, {
      name: 'Äußerst schöne Strecke',
      slug: ' gleiche route ',
      description: 'Beschreibung',
      schoolName: 'Schule'
    }, uploadedFile('route.gpx', GPX));
    assert.equal(route.name, 'Äußerst schöne Strecke');
    assert.equal(route.pointCount, 2);
    assert.ok(fs.existsSync(routes.getRouteGpxFile(db, route.id).absolutePath));

    const duplicate = routes.createRoute(db, {
      name: 'Zweite Strecke',
      slug: ' gleiche route '
    }, uploadedFile('route-2.gpx', GPX));
    assert.notEqual(duplicate.slug, route.slug);

    assert.deepEqual(routes.listRoutes(db, {}).map(item => item.id), [route.id, duplicate.id]);
    assert.equal(routes.listRoutes(db, { lat: 90, lon: 90, radiusKm: 0.001 }).length, 0);
    assert.ok(routes.listRoutes(db, {
      lat: route.start.lat,
      lon: route.start.lon,
      radiusKm: 1
    })[0].proximityM <= 1);
    expectHttpError(() => routes.getRoute(db, 999999), 404);
    expectHttpError(() => routes.getRouteGpxFile(db, 999999), 404);
    expectHttpError(() => routes.updateRoute(db, 999999, {}, null), 404);

    const renamed = routes.updateRoute(db, route.id, {
      name: 'Umbenannt',
      description: 'Neu',
      schoolName: 'Neue Schule'
    }, null);
    assert.equal(renamed.name, 'Umbenannt');

    const replaced = routes.updateRoute(db, route.id, {}, uploadedFile('replacement.gpx', GPX_APPEND));
    assert.equal(replaced.pointCount, 1);

    expectHttpError(() => routes.appendRoute(db, route.id, null), 400);
    expectHttpError(() => routes.appendRoute(db, 999999, uploadedFile('missing.gpx', GPX)), 404);
    const appended = routes.appendRoute(db, route.id, uploadedFile('append.gpx', GPX));
    assert.equal(appended.pointCount, 3);

    expectHttpError(() => routes.createPoi(db, 999999, { lat: 1, lon: 2 }), 404);
    expectHttpError(() => routes.createPoi(db, route.id, { lat: 'x', lon: 2 }), 400);
    const poi = routes.createPoi(db, route.id, {
      title: '',
      description: 'POI',
      lat: route.start.lat,
      lon: route.start.lon,
      triggerRadiusM: '55',
      sequence: '1'
    });
    assert.equal(poi.title, 'Unbenannter POI');
    const secondPoi = routes.createPoi(db, route.id, {
      title: 'Zweiter POI',
      lat: route.start.lat,
      lon: route.start.lon,
      sequence: '2'
    });

    expectHttpError(() => routes.getPoi(db, route.id, 999999), 404);
    expectHttpError(() => routes.updatePoi(db, route.id, 999999, {}), 404);
    expectHttpError(() => routes.updatePoi(db, route.id, poi.id, { sequence: -1 }), 400);
    const updatedPoi = routes.updatePoi(db, route.id, poi.id, {
      title: 'Aktualisiert',
      lat: route.start.lat + 0.001,
      lon: route.start.lon + 0.001,
      triggerRadiusM: 60,
      sequence: 0
    });
    assert.equal(updatedPoi.title, 'Aktualisiert');
    assert.equal(routes.listPois(db, route.id)[0].id, poi.id);

    expectHttpError(() => media.listPictures(db, route.id, 999999), 404);
    expectHttpError(() => media.createPicture(db, route.id, poi.id, {}, null), 400);
    const invalidPicture = uploadedFile('invalid-picture.bin', 'not image', {
      detectedMime: 'text/plain',
      detectedExtension: 'txt'
    });
    expectHttpError(() => media.createPicture(db, route.id, poi.id, {}, invalidPicture), 415);
    assert.equal(fs.existsSync(invalidPicture.path), false);

    const picture = media.createPicture(db, route.id, poi.id, { caption: 'Bild' }, uploadedFile('picture.png', PNG_FILE, {
      detectedMime: 'image/png',
      detectedExtension: 'png'
    }));
    assert.equal(picture.sequence, 0);
    const secondPicture = media.createPicture(db, route.id, poi.id, { caption: 'Bild 2', sequence: 2 }, uploadedFile('picture-2.png', PNG_FILE, {
      detectedMime: 'image/png',
      detectedExtension: 'png'
    }));
    assert.equal(media.listPictures(db, route.id, poi.id).length, 2);
    assert.ok(fs.existsSync(media.getPictureFile(db, route.id, poi.id, picture.id).absolutePath));
    expectHttpError(() => media.getPicture(db, route.id, poi.id, 999999), 404);
    expectHttpError(() => media.updatePicture(db, route.id, poi.id, picture.id, { sequence: -1 }, null), 400);

    const metadataUpdate = media.updatePicture(db, route.id, poi.id, picture.id, {
      caption: 'Neue Bildbeschreibung',
      sequence: 1
    }, null);
    assert.equal(metadataUpdate.caption, 'Neue Bildbeschreibung');

    const replacement = uploadedFile('replacement.webp', PNG_FILE, {
      detectedMime: 'image/png',
      detectedExtension: 'png'
    });
    const replacedPicture = media.updatePicture(db, route.id, poi.id, picture.id, {}, replacement);
    assert.equal(replacedPicture.id, picture.id);

    expectHttpError(() => media.getAudio(db, route.id, poi.id), 404);
    expectHttpError(() => media.createAudio(db, route.id, poi.id, null), 400);
    const invalidAudio = uploadedFile('invalid-audio.bin', 'bad', {
      detectedMime: 'text/plain',
      detectedExtension: 'txt'
    });
    expectHttpError(() => media.createAudio(db, route.id, poi.id, invalidAudio), 415);
    assert.equal(fs.existsSync(invalidAudio.path), false);

    const audio = media.createAudio(db, route.id, poi.id, uploadedFile('audio.mp3', MP3_FILE, {
      detectedMime: 'audio/mpeg',
      detectedExtension: 'mp3'
    }));
    assert.equal(audio.poiId, poi.id);
    assert.ok(fs.existsSync(media.getAudioFile(db, route.id, poi.id).absolutePath));
    expectHttpError(() => media.createAudio(db, route.id, poi.id, uploadedFile('duplicate.mp3', MP3_FILE, {
      detectedMime: 'audio/mpeg',
      detectedExtension: 'mp3'
    })), 409);
    expectHttpError(() => media.updateAudio(db, route.id, secondPoi.id, uploadedFile('missing.mp3', MP3_FILE, {
      detectedMime: 'audio/mpeg',
      detectedExtension: 'mp3'
    })), 404);

    const replacedAudio = media.updateAudio(db, route.id, poi.id, uploadedFile('replacement.mp3', MP3_FILE, {
      detectedMime: 'audio/mpeg',
      detectedExtension: 'mp3'
    }));
    assert.equal(replacedAudio.poiId, poi.id);
    assert.equal(media.deleteAudio(db, route.id, poi.id).deleted, true);
    expectHttpError(() => media.deleteAudio(db, route.id, poi.id), 404);

    assert.equal(media.deletePicture(db, route.id, poi.id, secondPicture.id).deleted, true);
    expectHttpError(() => media.deletePicture(db, route.id, poi.id, secondPicture.id), 404);

    const poiPicture = media.createPicture(db, route.id, secondPoi.id, {}, uploadedFile('poi-picture.png', PNG_FILE, {
      detectedMime: 'image/png',
      detectedExtension: 'png'
    }));
    media.createAudio(db, route.id, secondPoi.id, uploadedFile('poi-audio.mp3', MP3_FILE, {
      detectedMime: 'audio/mpeg',
      detectedExtension: 'mp3'
    }));
    assert.equal(routes.deletePoi(db, route.id, secondPoi.id).deleted, true);
    expectHttpError(() => routes.deletePoi(db, route.id, secondPoi.id), 404);
    expectHttpError(() => media.getPicture(db, route.id, secondPoi.id, poiPicture.id), 404);

    const deleted = routes.softDeleteRoute(db, route.id);
    assert.equal(deleted.status, 'deleted');
    assert.equal(routes.listRoutes(db, {}).some(item => item.id === route.id), false);
    assert.equal(routes.listRoutes(db, { includeDeleted: true }).some(item => item.id === route.id), true);
    expectHttpError(() => routes.getRoute(db, route.id), 404);
    expectHttpError(() => routes.softDeleteRoute(db, route.id), 404);
    const restored = routes.restoreRoute(db, route.id);
    assert.equal(restored.status, 'active');
    expectHttpError(() => routes.restoreRoute(db, route.id), 404);

    const missingRouteDir = routes.createRoute(db, { name: 'Ohne Verzeichnis' }, uploadedFile('missing-dir.gpx', GPX));
    fs.rmSync(storage.routeDirectory(missingRouteDir.id), { recursive: true, force: true });
    expectHttpError(() => routes.softDeleteRoute(db, missingRouteDir.id), 409);

    assert.equal(storage.removeStoredFile(null), false);
    assert.equal(storage.removeStoredFile('active/routes/not-there.bin'), false);
    expectHttpError(() => storage.removeStoredFile('../outside'), 500);
    expectHttpError(() => storage.resolveStoredFile('trash/routes/file'), 404);
    expectHttpError(() => storage.resolveStoredFile('active/../../outside'), 500);
    expectHttpError(() => storage.resolveStoredFile('active/routes/no-file'), 404);

    const orphanUpload = uploadedFile('orphan.bin', 'x');
    storage.removeUpload(orphanUpload);
    assert.equal(fs.existsSync(orphanUpload.path), false);
  } finally {
    db.close();
  }
});

test('upload inspection handles JSON, GPX, binary validation and cleanup paths', async () => {
  const noFileReq = { body: {} };
  assert.equal(await invokeUpload(inspectUpload('picture', 'picture'), noFileReq), undefined);

  const jsonPictureReq = {
    body: {
      picture: {
        filename: ' strange name.png ',
        dataUrl: `data:image/png;base64,${PNG_FILE.toString('base64')}`
      }
    }
  };
  assert.equal(await invokeUpload(inspectUpload('picture', 'picture', { allowJson: true }), jsonPictureReq), undefined);
  assert.equal(jsonPictureReq.file.detectedMime, 'image/png');
  assert.equal(jsonPictureReq.file.detectedExtension, 'png');
  assert.equal(jsonPictureReq.body.picture, undefined);
  storage.removeUpload(jsonPictureReq.file);

  const jsonAudioReq = {
    body: {
      audio: {
        filename: 'audio.bin',
        base64: MP3_FILE.toString('base64').replace(/(.{40})/g, '$1\n')
      }
    }
  };
  assert.equal(await invokeUpload(inspectUpload('audio', 'audio', { allowJson: true }), jsonAudioReq), undefined);
  assert.equal(jsonAudioReq.file.detectedMime, 'audio/mpeg');
  storage.removeUpload(jsonAudioReq.file);

  for (const descriptor of [
    'not-object',
    { base64: '' },
    { base64: '!!!' },
    { dataUrl: 12 },
    { dataUrl: 'not-a-data-url' }
  ]) {
    const req = { body: { picture: descriptor } };
    const error = await invokeUpload(inspectUpload('picture', 'picture', { allowJson: true }), req);
    assert.equal(error.status, 400);
    assert.equal(req.file, undefined);
  }

  const unsupportedReq = {
    body: { picture: { filename: 'text.txt', base64: Buffer.from('plain text').toString('base64') } }
  };
  const unsupported = await invokeUpload(inspectUpload('picture', 'picture', { allowJson: true }), unsupportedReq);
  assert.equal(unsupported.status, 415);
  assert.equal(unsupportedReq.file, undefined);

  const validGpxReq = { body: {}, file: uploadedFile('route.xml', `\uFEFF${GPX}`) };
  assert.equal(await invokeUpload(inspectUpload('gpx', 'gpx'), validGpxReq), undefined);
  assert.equal(validGpxReq.file.detectedExtension, 'gpx');
  storage.removeUpload(validGpxReq.file);

  const namespacedGpx = '<?xml version="1.0"?><!-- route --><!DOCTYPE gpx><ns_1-foo.gpx:gpx></ns_1-foo.gpx:gpx>';
  const namespacedGpxReq = { body: {}, file: uploadedFile('namespaced.xml', namespacedGpx) };
  assert.equal(await invokeUpload(inspectUpload('gpx', 'gpx'), namespacedGpxReq), undefined);
  storage.removeUpload(namespacedGpxReq.file);

  for (const malformed of [
    'plain text',
    '<!-- missing end',
    '<?xml version="1.0"',
    '<!DOCTYPE gpx',
    '</gpx>',
    '< gpx>'
  ]) {
    const request = { body: {}, file: uploadedFile('malformed.xml', malformed) };
    const error = await invokeUpload(inspectUpload('gpx', 'gpx'), request);
    assert.equal(error.status, 415);
    assert.equal(request.file, undefined);
  }

  const invalidGpxReq = { body: {}, file: uploadedFile('route.xml', '<xml/>') };
  const invalidGpx = await invokeUpload(inspectUpload('gpx', 'gpx'), invalidGpxReq);
  assert.equal(invalidGpx.status, 415);
  assert.equal(invalidGpxReq.file, undefined);

  const invalidUtf8Req = { body: {}, file: uploadedFile('route.gpx', Buffer.from([0xff, 0xfe, 0xfd])) };
  const invalidUtf8 = await invokeUpload(inspectUpload('gpx', 'gpx'), invalidUtf8Req);
  assert.equal(invalidUtf8.status, 415);
  assert.equal(invalidUtf8Req.file, undefined);
});

test('HTTP error middleware preserves details, maps upload errors and delegates sent headers', () => {
  const notFound = [];
  notFoundHandler({}, {}, error => notFound.push(error));
  assert.equal(notFound[0].status, 404);

  const sent = [];
  const response = {
    headersSent: false,
    statusCode: null,
    status(value) {
      this.statusCode = value;
      return this;
    },
    json(value) {
      sent.push(value);
      return this;
    }
  };
  errorHandler(new HttpError(422, 'Ungültig', { field: 'name' }), {}, response, () => {});
  assert.equal(response.statusCode, 422);
  assert.deepEqual(sent[0].details, { field: 'name' });

  errorHandler(Object.assign(new Error('large'), { code: 'LIMIT_FILE_SIZE' }), {}, response, () => {});
  assert.equal(response.statusCode, 413);
  errorHandler(Object.assign(new Error('unexpected'), { code: 'LIMIT_UNEXPECTED_FILE' }), {}, response, () => {});
  assert.equal(response.statusCode, 400);

  const delegated = [];
  errorHandler(new Error('already sent'), {}, { headersSent: true }, error => delegated.push(error));
  assert.equal(delegated[0].message, 'already sent');
});


test('Express API router executes route, POI, file and restore handlers in-process', async t => {
  const db = openDatabase(path.join(config.dataDir, 'router.sqlite'));
  const app = express();
  app.use(express.json({
    limit: config.maxJsonBodyBytes,
    type: ['application/json', 'application/*+json', 'text/json']
  }));
  app.use(express.urlencoded({ extended: true }));
  app.use('/api', createApiRouter(db));
  app.use(notFoundHandler);
  app.use(errorHandler);

  const server = await new Promise((resolve, reject) => {
    const listener = app.listen(0, '127.0.0.1', () => resolve(listener));
    listener.once('error', reject);
  });
  const address = server.address();
  const baseUrl = `http://127.0.0.1:${address.port}`;

  t.after(async () => {
    await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
    db.close();
  });

  async function json(url, options = {}, status = 200) {
    const response = await fetch(`${baseUrl}${url}`, options);
    const body = await response.json();
    assert.equal(response.status, status, JSON.stringify(body));
    return { response, body };
  }

  const health = await json('/api/health');
  assert.equal(health.body.ok, true);
  assert.match(health.body.socket, /:/);

  const createForm = new FormData();
  createForm.append('name', 'Router-Test');
  createForm.append('description', 'API-Abdeckung');
  createForm.append('gpx', new Blob([GPX], { type: 'text/plain' }), 'route.txt');
  const created = await json('/api/routes', { method: 'POST', body: createForm }, 201);
  const routeId = created.body.id;
  assert.equal(created.response.headers.get('location'), `/api/routes/${routeId}`);

  assert.equal((await json('/api/routes?lat=invalid&lon=&radiusKm=invalid')).body.routes.length, 1);
  assert.equal((await json(`/api/routes/${routeId}`)).body.id, routeId);
  assert.equal((await fetch(`${baseUrl}/api/routes/${routeId}/gpx`)).status, 200);
  assert.equal((await json(`/api/routes/${routeId}/pois`)).body.pois.length, 0);

  const updateForm = new FormData();
  updateForm.append('name', 'Router-Test aktualisiert');
  const updated = await json(`/api/routes/${routeId}`, { method: 'PUT', body: updateForm });
  assert.equal(updated.body.name, 'Router-Test aktualisiert');

  const appendForm = new FormData();
  appendForm.append('gpx', new Blob([GPX_APPEND], { type: 'application/octet-stream' }), 'append.bin');
  assert.equal((await json(`/api/routes/${routeId}/append`, { method: 'POST', body: appendForm })).body.pointCount, 3);

  const poiCreated = await json(`/api/routes/${routeId}/pois`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: 'Router-POI', lat: 50, lon: 10, triggerRadiusM: 40, sequence: 0 })
  }, 201);
  const poiId = poiCreated.body.id;
  assert.equal(poiCreated.response.headers.get('location'), `/api/routes/${routeId}/pois/${poiId}`);
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}`)).body.title, 'Router-POI');
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: 'Router-POI neu' })
  })).body.title, 'Router-POI neu');

  const pictureForm = new FormData();
  pictureForm.append('caption', 'Router-Bild');
  pictureForm.append('picture', new Blob([PNG_FILE], { type: 'text/plain' }), 'picture.txt');
  const pictureCreated = await json(`/api/routes/${routeId}/pois/${poiId}/pictures`, {
    method: 'POST', body: pictureForm
  }, 201);
  const pictureId = pictureCreated.body.id;
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures`)).body.pictures.length, 1);
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}?metadata=true`)).body.id, pictureId);
  assert.equal((await fetch(`${baseUrl}/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`)).status, 200);
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ caption: 'Router-Bild neu' })
  })).body.caption, 'Router-Bild neu');

  const audioForm = new FormData();
  audioForm.append('audio', new Blob([MP3_FILE], { type: 'text/plain' }), 'audio.txt');
  const audioCreated = await json(`/api/routes/${routeId}/pois/${poiId}/audio`, {
    method: 'POST', body: audioForm
  }, 201);
  assert.equal(audioCreated.response.headers.get('location'), `/api/routes/${routeId}/pois/${poiId}/audio`);
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/audio?metadata=true`)).body.poiId, poiId);
  assert.equal((await fetch(`${baseUrl}/api/routes/${routeId}/pois/${poiId}/audio`)).status, 200);

  const replaceAudio = {
    audio: {
      filename: 'replacement.mp3',
      base64: MP3_FILE.toString('base64')
    }
  };
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/audio`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(replaceAudio)
  })).body.poiId, poiId);

  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/audio`, { method: 'DELETE' })).body.deleted, true);
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`, { method: 'DELETE' })).body.deleted, true);
  assert.equal((await json(`/api/routes/${routeId}/pois/${poiId}`, { method: 'DELETE' })).body.deleted, true);

  const deleted = await json(`/api/routes/${routeId}`, { method: 'DELETE' });
  assert.equal(deleted.body.softDeleted, true);
  const restored = await json(`/api/routes/${routeId}/restore`, { method: 'POST' });
  assert.equal(restored.body.restored, true);

  await json('/api/not-found', {}, 404);
});
