'use strict';

// B10: push-notification DELIVERY — verifies the message-building + HTTP send path
// for each channel against a local sink (no external network), plus error handling,
// dispatch, and that the auth token never leaks into the message body.
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');

const { _push } = require('../services/notificationService');
const { sendNtfy, sendGotify, sendDiscord, sendPushToUser } = _push;

// Local HTTP sink: records requests, status configurable.
function startSink() {
  const requests = [];
  let status = 200;
  const server = http.createServer((req, res) => {
    let body = '';
    req.on('data', (c) => (body += c));
    req.on('end', () => {
      requests.push({ method: req.method, url: req.url, headers: req.headers, body });
      res.statusCode = status;
      res.end(status === 200 ? 'ok' : 'err');
    });
  });
  return new Promise((resolve) => {
    server.listen(0, '127.0.0.1', () => {
      resolve({
        url: `http://127.0.0.1:${server.address().port}`,
        requests,
        setStatus: (c) => { status = c; },
        close: () => new Promise((r) => server.close(r)),
      });
    });
  });
}

test('ntfy delivery: posts body + Title header; token stays in Authorization, not the body', async () => {
  const sink = await startSink();
  try {
    await sendNtfy(sink.url, 'secret-token-xyz', 'Bill due', 'Electric $85 due tomorrow', 'overdue');
    assert.equal(sink.requests.length, 1);
    const r = sink.requests[0];
    assert.equal(r.method, 'POST');
    assert.equal(r.body, 'Electric $85 due tomorrow');
    assert.equal(r.headers['title'], 'Bill due');
    assert.ok(r.headers['priority'], 'priority header present');
    assert.equal(r.headers['authorization'], 'Bearer secret-token-xyz');
    assert.ok(!r.body.includes('secret-token-xyz'), 'token must never appear in the message body');
  } finally { await sink.close(); }
});

test('gotify delivery: JSON {title, message, priority} to /message?token=', async () => {
  const sink = await startSink();
  try {
    await sendGotify(sink.url, 'gtoken', 'Reminder', 'Rent due in 3 days', 'today');
    const r = sink.requests[0];
    assert.match(r.url, /\/message\?token=gtoken/);
    const json = JSON.parse(r.body);
    assert.equal(json.title, 'Reminder');
    assert.equal(json.message, 'Rent due in 3 days');
    assert.equal(json.priority, 7); // 'today'
  } finally { await sink.close(); }
});

test('discord delivery: webhook embed with title/description', async () => {
  const sink = await startSink();
  try {
    await sendDiscord(sink.url, 'Overdue bill', 'Water bill is 2 days overdue', 'overdue');
    const json = JSON.parse(sink.requests[0].body);
    assert.equal(json.embeds[0].title, 'Overdue bill');
    assert.equal(json.embeds[0].description, 'Water bill is 2 days overdue');
    assert.ok(typeof json.embeds[0].color === 'number');
  } finally { await sink.close(); }
});

test('dispatch: sendPushToUser routes to the configured channel', async () => {
  const sink = await startSink();
  try {
    const user = { notify_push_enabled: 1, push_channel: 'ntfy', push_url: sink.url, push_token: '' };
    await sendPushToUser(user, 'Title', 'Body', 'soon');
    assert.equal(sink.requests.length, 1);
    assert.equal(sink.requests[0].body, 'Body');
  } finally { await sink.close(); }
});

test('dispatch: disabled/unconfigured user sends nothing (no throw)', async () => {
  await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 0 }, 'T', 'B', 'soon'));
  await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 1, push_channel: 'ntfy' }, 'T', 'B', 'soon'));
});

test('error handling: an unreachable/erroring channel throws a clear, channel-named error', async () => {
  const sink = await startSink();
  sink.setStatus(500);
  try {
    await assert.rejects(
      sendNtfy(sink.url, '', 'T', 'B', 'soon'),
      /ntfy returned 500/,
    );
  } finally { await sink.close(); }
});

test('dispatch: an unknown channel throws instead of silently doing nothing', async () => {
  await assert.rejects(
    sendPushToUser({ notify_push_enabled: 1, push_channel: 'carrier-pigeon', push_url: 'http://x' }, 'T', 'B', 'soon'),
    /Unknown push channel: carrier-pigeon/,
  );
});

test('email HTML escapes the bill name everywhere — no XSS via the name (QA-B14-04)', () => {
  const { buildEmailHtml } = require('../services/notificationService')._email;
  const evil = '<img src=x onerror=alert(1)>';
  for (const type of ['due_3d', 'due_1d', 'due_today', 'overdue']) {
    const html = buildEmailHtml({ name: evil, expected_amount: 8500 }, type, '2026-07-15');
    assert.ok(!html.includes(evil), `raw payload must not appear in the ${type} email HTML`);
    assert.ok(html.includes('&lt;img src=x onerror='), `bill name must be HTML-escaped in the ${type} email (message + detail row)`);
  }
});
