import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import test from 'node:test';
import { startLocalWriteAuthProxy } from '../test-support/local-write-auth-proxy.js';

const root = path.resolve(import.meta.dirname, '..');
const LOCAL_API_URL = 'http://127.0.0.1:47145';
const BACKEND_API_URL = 'http://127.0.0.1:47146';
const TEST_API_USER = 'local-write-user';
const TEST_API_PASSWORD = 'local-write-password';
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
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 WAV_FILE = Buffer.from(
  '524946462400000057415645666d74201000000001000100401f0000803e0000020010006461746100000000',
  'hex'
);

async function runProcess(command, args, options = {}) {
  const child = spawn(command, args, {
    ...options,
    stdio: ['ignore', 'pipe', 'pipe']
  });
  const stdout = [];
  const stderr = [];
  child.stdout.on('data', chunk => stdout.push(chunk.toString()));
  child.stderr.on('data', chunk => stderr.push(chunk.toString()));
  const status = await new Promise((resolve, reject) => {
    child.once('error', reject);
    child.once('exit', resolve);
  });
  return { status, stdout: stdout.join(''), stderr: stderr.join('') };
}

function authorizationHeader() {
  const token = Buffer.from(`${TEST_API_USER}:${TEST_API_PASSWORD}`, 'utf8').toString('base64');
  return `Basic ${token}`;
}

async function fetchWithWriteAuthRetry(url, options = {}) {
  const request = new Request(url, options);
  const response = await fetch(request.clone());
  if (!WRITE_METHODS.has(request.method) || response.status !== 401) return response;

  assert.match(response.headers.get('www-authenticate') || '', /^Basic\s/i);
  await response.arrayBuffer();

  const headers = new Headers(request.headers);
  headers.set('Authorization', authorizationHeader());
  return fetch(new Request(request, { headers }));
}

async function requestJson(url, options = {}, expectedStatus = 200) {
  const response = await fetchWithWriteAuthRetry(url, options);
  const body = await response.json();
  assert.equal(response.status, expectedStatus, JSON.stringify(body));
  return { response, body };
}

async function requestBuffer(url, expectedStatus = 200) {
  const response = await fetch(url);
  const body = Buffer.from(await response.arrayBuffer());
  assert.equal(response.status, expectedStatus);
  return { response, body };
}

async function waitForServer(baseUrl, child, output) {
  for (let attempt = 0; attempt < 80; attempt += 1) {
    if (child.exitCode != null) throw new Error(`Server wurde vorzeitig beendet.\n${output.join('')}`);
    try {
      const response = await fetch(`${baseUrl}/api/health`);
      if (response.ok) return;
    } catch {
      // Server startet noch.
    }
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  throw new Error(`Serverstart hat das Zeitlimit überschritten.\n${output.join('')}`);
}

test('the npm-started local API is exercised through a write-auth proxy on loopback port 47145', { timeout: 30000 }, async t => {
  const runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-media-api-'));
  const baseUrl = LOCAL_API_URL;
  let authProxy;
  const output = [];
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
  const detached = process.platform !== 'win32';
  const child = spawn(npmCommand, ['start'], {
    cwd: root,
    env: {
      ...process.env,
      HOST: '127.0.0.1',
      PORT: '47146',
      DATA_DIR: path.join(runtime, 'data'),
      STORAGE_DIR: path.join(runtime, 'storage')
    },
    stdio: ['ignore', 'pipe', 'pipe'],
    detached
  });
  child.stdout.on('data', chunk => output.push(chunk.toString()));
  child.stderr.on('data', chunk => output.push(chunk.toString()));

  t.after(async () => {
    if (authProxy) await authProxy.close();
    if (child.exitCode == null) {
      if (detached) {
        try {
          process.kill(-child.pid, 'SIGTERM');
        } catch (error) {
          if (error.code !== 'ESRCH') throw error;
        }
      } else {
        child.kill('SIGTERM');
      }
      await new Promise(resolve => child.once('exit', resolve));
    }
    await fs.rm(runtime, { recursive: true, force: true });
  });

  await waitForServer(BACKEND_API_URL, child, output);
  authProxy = await startLocalWriteAuthProxy({
    listenPort: 47145,
    targetPort: 47146,
    username: TEST_API_USER,
    password: TEST_API_PASSWORD
  });

  const healthResponse = await fetch(`${baseUrl}/api/health`);
  assert.equal(healthResponse.status, 200);
  assert.equal(healthResponse.headers.get('www-authenticate'), null);
  assert.match(healthResponse.headers.get('cache-control') || '', /no-store/);

  const indexResponse = await fetch(`${baseUrl}/`);
  const indexHtml = await indexResponse.text();
  assert.equal(indexResponse.status, 200);
  assert.match(indexResponse.headers.get('cache-control') || '', /no-store/);
  assert.match(indexHtml, /css\/app\.css\?v=0\.12\.14/);

  const appStyleResponse = await fetch(`${baseUrl}/css/app.css?v=0.12.14`);
  assert.equal(appStyleResponse.status, 200);
  assert.match(appStyleResponse.headers.get('cache-control') || '', /no-store/);
  await appStyleResponse.arrayBuffer();

  const pythonHealthCheck = await runProcess('python3', ['-c', `
from common import ApiClient
result = ApiClient('${LOCAL_API_URL}').request('GET', '/api/health')
assert result['ok'] is True
`], {
    cwd: root,
    env: {
      ...process.env,
      API_USER: '',
      API_PASSWORD: '',
      PYTHONPATH: path.join(root, 'tools/python')
    }
  });
  assert.equal(
    pythonHealthCheck.status,
    0,
    [pythonHealthCheck.stdout, pythonHealthCheck.stderr].filter(Boolean).join('\n')
  );

  const gpxPath = path.join(root, 'examples', 'sample-route.gpx');
  const gpx = await fs.readFile(gpxPath);
  const createRoute = await runProcess('python3', [
    path.join(root, 'tools/python/create_route.py'),
    '--base-url', LOCAL_API_URL,
    '--name', 'Medien-API-Test',
    '--slug', '',
    '--description', '',
    '--school-name', '',
    '--gpx', gpxPath
  ], {
    cwd: root,
    env: {
      ...process.env,
      API_USER: `  ${TEST_API_USER}  `,
      API_PASSWORD: `  ${TEST_API_PASSWORD}  `,
      PYTHONPATH: path.join(root, 'tools/python')
    }
  });
  assert.equal(
    createRoute.status,
    0,
    [createRoute.stdout, createRoute.stderr].filter(Boolean).join('\n')
  );
  const route = JSON.parse(createRoute.stdout);
  assert.equal(route.gpxUrl, `/api/routes/${route.id}/gpx`);
  const { response: gpxResponse, body: downloadedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
  assert.match(gpxResponse.headers.get('content-type') || '', /application\/gpx\+xml/);
  assert.deepEqual(downloadedGpx, gpx);

  const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      title: 'Teststation',
      description: 'Station für Medientests',
      lat: route.start.lat,
      lon: route.start.lon,
      triggerRadiusM: 40,
      sequence: 0
    })
  }, 201);

  const { body: otherPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      title: 'Andere Teststation',
      lat: route.start.lat + 0.0001,
      lon: route.start.lon + 0.0001,
      triggerRadiusM: 40,
      sequence: 1
    })
  }, 201);

  const { body: fetchedPoi } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}`
  );
  assert.equal(fetchedPoi.id, poi.id);
  assert.equal(fetchedPoi.routeId, route.id);
  assert.equal(fetchedPoi.title, 'Teststation');

  const { body: updatedPoi } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}`,
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'Aktualisierte Teststation' })
    }
  );
  assert.equal(updatedPoi.title, 'Aktualisierte Teststation');

  await requestJson(`${baseUrl}/api/routes/999999/pois/${poi.id}`, {}, 404);
  await requestJson(`${baseUrl}/api/routes/${route.id}/pois/999999`, {}, 404);
  await requestJson(`${baseUrl}/api/pois/${poi.id}`, {}, 404);

  const pictureForm = new FormData();
  pictureForm.append('poiId', String(otherPoi.id));
  pictureForm.append('caption', 'Erste Bildbeschreibung');
  pictureForm.append('sequence', '0');
  pictureForm.append('picture', new Blob([PNG_FILE], { type: 'application/octet-stream' }), 'bild.txt');
  const { response: pictureResponse, body: picture } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`,
    { method: 'POST', body: pictureForm },
    201
  );
  assert.equal(pictureResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
  assert.equal(picture.poiId, poi.id);
  assert.equal(picture.caption, 'Erste Bildbeschreibung');
  assert.equal(picture.url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
  const { response: pictureFileResponse, body: pictureFile } = await requestBuffer(`${baseUrl}${picture.url}`);
  assert.match(pictureFileResponse.headers.get('content-type') || '', /image\/png/);
  assert.deepEqual(pictureFile, PNG_FILE);
  const { body: pictureMetadata } = await requestJson(`${baseUrl}${picture.url}?metadata=true`);
  assert.equal(pictureMetadata.caption, 'Erste Bildbeschreibung');
  assert.equal(pictureMetadata.poiId, poi.id);
  await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${picture.id}?metadata=true`,
    {},
    404
  );

  const { body: pictureList } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures`);
  assert.equal(pictureList.pictures.length, 1);
  assert.equal(pictureList.pictures[0].id, picture.id);

  const jsonPicturePayload = {
    caption: 'Bild aus JSON',
    sequence: 1,
    picture: {
      filename: 'bild-json.bin',
      base64: PNG_FILE.toString('base64')
    }
  };
  const { body: jsonPicture } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'text/json' },
      body: JSON.stringify(jsonPicturePayload)
    },
    201
  );
  assert.equal(jsonPicture.poiId, otherPoi.id);
  assert.equal(jsonPicture.caption, 'Bild aus JSON');
  const { response: jsonPictureResponse, body: jsonPictureFile } = await requestBuffer(`${baseUrl}${jsonPicture.url}`);
  assert.match(jsonPictureResponse.headers.get('content-type') || '', /image\/png/);
  assert.deepEqual(jsonPictureFile, PNG_FILE);

  await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'text/json' },
      body: '{}'
    },
    400
  );

  await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        picture: {
          filename: 'behauptetes-bild.png',
          contentType: 'image/png',
          base64: Buffer.from('kein Bild').toString('base64')
        }
      })
    },
    415
  );

  const { body: updatedPicture } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ caption: 'Aktualisierte Bildbeschreibung', sequence: 2 })
    }
  );
  assert.equal(updatedPicture.caption, 'Aktualisierte Bildbeschreibung');
  assert.equal(updatedPicture.sequence, 2);

  const audioForm = new FormData();
  audioForm.append('audio', new Blob([MP3_FILE], { type: 'application/octet-stream' }), 'ansage.txt');
  const { response: audioResponse, body: audio } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
    { method: 'POST', body: audioForm },
    201
  );
  assert.equal(audioResponse.headers.get('location'), `/api/routes/${route.id}/pois/${poi.id}/audio`);
  assert.equal(audio.poiId, poi.id);
  assert.equal(audio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
  const { response: audioFileResponse, body: audioFile } = await requestBuffer(`${baseUrl}${audio.url}`);
  assert.match(audioFileResponse.headers.get('content-type') || '', /audio\/mpeg/);
  assert.deepEqual(audioFile, MP3_FILE);
  const { body: audioMetadata } = await requestJson(`${baseUrl}${audio.url}?metadata=true`);
  assert.equal(audioMetadata.poiId, poi.id);

  const duplicateAudio = new FormData();
  duplicateAudio.append('audio', new Blob([MP3_FILE], { type: 'image/png' }), 'doppelt.png');
  await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {
    method: 'POST',
    body: duplicateAudio
  }, 409);

  const replacementAudio = {
    audio: {
      filename: 'ansage-neu.bin',
      base64: WAV_FILE.toString('base64')
    }
  };
  const { body: replacedAudio } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(replacementAudio)
    }
  );
  assert.equal(replacedAudio.url, `/api/routes/${route.id}/pois/${poi.id}/audio`);
  const { response: replacedAudioResponse, body: replacedAudioFile } = await requestBuffer(`${baseUrl}${replacedAudio.url}`);
  assert.match(replacedAudioResponse.headers.get('content-type') || '', /audio\/wav/);
  assert.deepEqual(replacedAudioFile, WAV_FILE);

  const { body: routeWithMedia } = await requestJson(`${baseUrl}/api/routes/${route.id}`);
  assert.equal(routeWithMedia.pois[0].images[0].caption, 'Aktualisierte Bildbeschreibung');
  assert.equal(routeWithMedia.gpxUrl, `/api/routes/${route.id}/gpx`);
  assert.equal(routeWithMedia.pois[0].audioUrl, `/api/routes/${route.id}/pois/${poi.id}/audio`);
  assert.equal(routeWithMedia.pois[0].images[0].url, `/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`);
  assert.doesNotMatch(JSON.stringify(routeWithMedia), /\/media\//);
  const legacyMediaResponse = await fetch(`${baseUrl}/media/routes/${route.id}/route.gpx`);
  assert.equal(legacyMediaResponse.status, 404);
  const legacyPictureEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/pictures`);
  assert.equal(legacyPictureEndpoint.status, 404);
  const legacyAudioEndpoint = await fetch(`${baseUrl}/api/routes/${route.id}/audio`);
  assert.equal(legacyAudioEndpoint.status, 404);

  const { body: deletedAudio } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`,
    { method: 'DELETE' }
  );
  assert.equal(deletedAudio.deleted, true);

  const { body: deletedPicture } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`,
    { method: 'DELETE' }
  );
  assert.equal(deletedPicture.deleted, true);

  const otherAudioForm = new FormData();
  otherAudioForm.append('audio', new Blob([MP3_FILE], { type: 'text/plain' }), 'falsch.txt');
  await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/audio`,
    { method: 'POST', body: otherAudioForm },
    201
  );

  const { body: deletedPoi } = await requestJson(
    `${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}`,
    { method: 'DELETE' }
  );
  assert.equal(deletedPoi.deleted, true);
  await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}`, {}, 404);
  await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/pictures/${jsonPicture.id}`, {}, 404);
  await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${otherPoi.id}/audio`, {}, 404);

  await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/pictures/${picture.id}`, {}, 404);
  await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${poi.id}/audio`, {}, 404);

  for (const method of ['POST', 'PUT', 'DELETE']) {
    assert.ok(authProxy.stats.unauthorizedWrites[method] > 0, `${method} muss zunächst HTTP 401 auslösen.`);
    assert.ok(authProxy.stats.authorizedWrites[method] > 0, `${method} muss mit Basic Auth weitergeleitet werden.`);
  }
  assert.ok(authProxy.stats.forwardedReads > 0);
  assert.equal(authProxy.stats.authenticatedReads, 0, 'Lesende Requests dürfen nicht präventiv authentifiziert werden.');
});
