import { test } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import http from "node:http";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
  normalizeUrl,
  validateSite,
  validateSettings,
  DEFAULT_SETTINGS,
} from "../backend/validation.ts";
import {
  publicTarget,
  isPublicIp,
  safeGet,
  MonitorError,
} from "../backend/network.ts";
import {
  detectWpError,
  inspectSite,
  restEndpoint,
  validWordPressJson,
} from "../backend/inspect.ts";
import { Store } from "../backend/store.ts";
import { Monitor } from "../backend/monitor.ts";
import { Mailer, escapeHtml } from "../backend/mail.ts";
import { hashPassword, verifyPassword } from "../backend/auth.ts";
import { parseImport } from "../src/components/BulkImportModal.tsx";
import type { CheckResult, ClientSite } from "../src/types.ts";
const payload = (url = "https://8.8.8.8/") =>
  validateSite({
    name: "Client",
    url,
    clientName: "Test",
    packageType: "care_plan",
    category: "corporate",
  });
const result = (
  status: CheckResult["status"] = "online",
  url = "https://8.8.8.8/",
): CheckResult => ({
  url,
  hostname: new URL(url).hostname,
  status,
  statusCode: status === "down" ? 500 : 200,
  statusText: status,
  responseTimeMs: 100,
  isWordPress: true,
  wpErrorType: status === "down" ? "server_error" : null,
  wpErrorMessage: "Test response",
  restApiStatus: "healthy",
  ssl: { valid: true, checked: true, daysRemaining: 90 },
  checkedAt: new Date().toISOString(),
});
const pause = () => new Promise<void>((r) => setImmediate(r));
async function idle(m: Monitor) {
  for (let i = 0; i < 100 && (m.active.size || m.queue.size); i++)
    await pause();
  assert.equal(m.active.size + m.queue.size, 0);
}

test("URL normalization rejects credentials, non-HTTP schemes and unsafe ports", () => {
  assert.equal(normalizeUrl(" EXAMPLE.COM/#section "), "https://example.com/");
  for (const url of [
    "ftp://example.com",
    "https://user:pass@example.com",
    "http://example.com:22",
    "javascript:alert(1)",
  ])
    assert.throws(() => normalizeUrl(url));
});
test("public addresses exclude local, mapped IPv6, link-local and reserved ranges", async () => {
  for (const ip of [
    "127.0.0.1",
    "10.0.0.1",
    "172.16.0.1",
    "192.168.0.1",
    "169.254.169.254",
    "0.0.0.0",
    "100.64.0.1",
    "224.0.0.1",
    "::1",
    "fe80::1",
    "fc00::1",
    "::ffff:8.8.8.8",
    "2001:db8::1",
  ])
    assert.equal(isPublicIp(ip), false, ip);
  assert.equal(isPublicIp("8.8.8.8"), true);
  assert.equal(isPublicIp("2606:4700:4700::1111"), true);
  await assert.rejects(
    publicTarget("https://example.com", async () => [
      { address: "8.8.8.8", family: 4 },
      { address: "10.0.0.1", family: 4 },
    ]),
  );
  await assert.rejects(publicTarget("http://2130706433"));
  await assert.rejects(publicTarget("https://localhost"));
});
test("requests pin a validated address and revalidate redirects", async (t) => {
  let count = 0;
  const mock = t.mock.method(
    http,
    "request",
    (_url: any, options: any, callback: any) => {
      count++;
      options.lookup("unused", { all: true }, (e: any, addresses: any) =>
        assert.deepEqual(addresses, [{ address: "8.8.8.8", family: 4 }]),
      );
      const req = new EventEmitter() as any;
      req.destroy = (e: Error) => req.emit("error", e);
      req.end = () =>
        setImmediate(() => {
          const res = new EventEmitter() as any;
          res.statusCode = 302;
          res.headers = { location: "http://127.0.0.1/secret" };
          res.resume = () => {};
          callback(res);
        });
      return req;
    },
  );
  await assert.rejects(safeGet("http://8.8.8.8"), /tidak awam/);
  assert.equal(count, 1);
  mock.mock.restore();
});
test("response-body stalls remain subject to timeout", async (t) => {
  t.mock.method(http, "request", (_url: any, _options: any, callback: any) => {
    const req = new EventEmitter() as any;
    req.destroy = (e: Error) => req.emit("error", e);
    req.end = () =>
      setImmediate(() => {
        const res = new EventEmitter() as any;
        res.statusCode = 200;
        res.headers = { "content-type": "text/html" };
        callback(res);
        res.emit("data", Buffer.from("partial response"));
      });
    return req;
  });
  await assert.rejects(
    safeGet("http://8.8.8.8", { timeoutMs: 30 }),
    (e) => (e as MonitorError).code === "ETIMEDOUT",
  );
});
test("oversized responses are bounded", async (t) => {
  t.mock.method(http, "request", (_url: any, _options: any, callback: any) => {
    const req = new EventEmitter() as any;
    req.destroy = (e: Error) => req.emit("error", e);
    req.end = () =>
      setImmediate(() => {
        const res = new EventEmitter() as any;
        res.statusCode = 200;
        res.headers = {};
        callback(res);
        res.emit("data", Buffer.alloc(101));
      });
    return req;
  });
  await assert.rejects(
    safeGet("http://8.8.8.8", { maxBytes: 100 }),
    (e) => (e as MonitorError).code === "ETOOLARGE",
  );
});
test("instructional articles and code examples do not become outage reports", () => {
  assert.equal(
    detectWpError("<article>How to fix database error in WordPress</article>"),
    undefined,
  );
  assert.equal(
    detectWpError(
      "<h1>How to fix WordPress</h1><pre>Fatal error: example</pre>",
    ),
    undefined,
  );
  assert.equal(
    detectWpError("<h1>Error establishing a database connection</h1>")?.type,
    "database",
  );
  assert.equal(
    detectWpError("<p>There has been a critical error on this website.</p>")
      ?.type,
    "critical",
  );
});
test("REST validates WordPress JSON and preserves subdirectory installation paths", async () => {
  const calls: string[] = [];
  const get = async (url: string) => {
    calls.push(url);
    return {
      status: 200,
      body:
        calls.length === 1
          ? '<div class="wp-block-page">Home</div>'
          : "<html>SPA fallback</html>",
      contentType: "text/html",
      finalUrl: url,
      ssl: { valid: true, checked: true, daysRemaining: 30 },
      elapsedMs: 120,
    };
  };
  const r = await inspectSite({ url: "https://example.com/staging/" }, get);
  assert.equal(calls[1], "https://example.com/staging/wp-json/");
  assert.equal(r.restApiStatus, "error");
  assert.equal(r.status, "warning");
  assert.equal(validWordPressJson("{}"), false);
  assert.equal(
    validWordPressJson('{"namespaces":["wp/v2"],"routes":{}}'),
    true,
  );
  assert.equal(
    restEndpoint({
      url: "https://example.com/about/",
      restApiUrl: "https://example.com/wp-json/",
    }),
    "https://example.com/wp-json/",
  );
});
test("monitor constraints produce unknown; timeout and TLS have distinct categories", async () => {
  for (const [code, status, error] of [
    ["ETOOLARGE", "unknown", "monitor_error"],
    ["ETIMEDOUT", "down", "timeout"],
    ["CERT_HAS_EXPIRED", "down", "tls_error"],
    ["ECONNREFUSED", "down", "connection_error"],
  ]) {
    const r = await inspectSite({ url: "https://example.com" }, async () => {
      throw new MonitorError("Failure", code);
    });
    assert.equal(r.status, status);
    assert.equal(r.wpErrorType, error);
  }
});
test("HTTP 204 uses its actual status and blocked REST does not force outage", async () => {
  const r = await inspectSite({ url: "https://example.com/" }, async (url) => ({
    status: 204,
    body: "",
    contentType: "",
    finalUrl: url,
    ssl: { valid: true },
    elapsedMs: 100,
  }));
  assert.equal(r.status, "online");
  assert.equal(r.statusCode, 204);
  let n = 0;
  const blocked = await inspectSite(
    { url: "https://example.com/" },
    async (url) => ({
      status: ++n === 1 ? 200 : 403,
      body: "/wp-content/theme.css",
      contentType: "text/html",
      finalUrl: url,
      ssl: { valid: true },
      elapsedMs: 100,
    }),
  );
  assert.equal(blocked.restApiStatus, "blocked");
  assert.equal(blocked.status, "online");
});
test("database persists across restart; URL changes discard obsolete results", () => {
  const directory = mkdtempSync(path.join(tmpdir(), "pulse-store-"));
  const filename = path.join(directory, "db.sqlite");
  let s = new Store(filename);
  const site = s.saveSite(payload());
  s.record(site, result());
  s.close();
  s = new Store(filename);
  assert.equal(s.sites()[0].lastResult?.status, "online");
  s.saveSite(payload("https://1.1.1.1/"), site.id);
  assert.equal(s.site(site.id)?.lastResult, undefined);
  s.record(site, result("down"));
  assert.equal(s.site(site.id)?.lastResult, undefined);
  s.close();
  rmSync(directory, { recursive: true });
});
test("incidents require consecutive failures, survive restart, deduplicate and recover", () => {
  const s = new Store(":memory:");
  const site = s.saveSite(payload());
  assert.equal(s.record(site, result("down")).opened, undefined);
  const incident = s.record(site, result("down")).opened!;
  assert.ok(incident);
  assert.equal(s.record(site, result("down")).opened, undefined);
  s.record(site, result("unknown"));
  assert.equal(s.incidents()[0].resolvedAt, undefined);
  assert.equal(s.record(site, result("warning")).recovered?.id, incident.id);
  assert.ok(s.incidents()[0].resolvedAt);
  s.close();
});
test("maintenance records checks, silences new incidents, and resets failure accumulation", () => {
  const s = new Store(":memory:");
  const site = s.saveSite({
    ...payload(),
    maintenanceUntil: new Date(Date.now() + 60000).toISOString(),
  });
  s.record(site, result("down"));
  s.record(site, result("down"));
  assert.equal(s.incidents().length, 0);
  assert.equal(s.site(site.id)?.consecutiveFailures, 0);
  assert.equal(s.history(site.id).length, 2);
  s.close();
});
test("monitor limits concurrency, deduplicates queue and keeps results attached to IDs", async () => {
  const s = new Store(":memory:");
  const mailer = new Mailer(s, async () => {});
  const releases: (() => void)[] = [];
  let running = 0,
    max = 0;
  const m = new Monitor(s, mailer, 2, async (site) => {
    running++;
    max = Math.max(max, running);
    await new Promise<void>((r) => releases.push(r));
    running--;
    return result("online", site.url);
  });
  const sites = [
    s.saveSite(payload("https://8.8.8.8/")),
    s.saveSite(payload("https://1.1.1.1/")),
    s.saveSite(payload("https://9.9.9.9/")),
  ];
  sites.forEach((site) => m.enqueue(site.id));
  assert.equal(m.enqueue(sites[0].id), false);
  assert.equal(m.active.size, 2);
  s.removeSite(sites[0].id);
  s.saveSite(payload("https://8.8.4.4/"));
  releases.splice(0).forEach((r) => r());
  await pause();
  releases.splice(0).forEach((r) => r());
  await idle(m);
  assert.equal(max, 2);
  assert.equal(s.site(sites[1].id)?.lastResult?.url, sites[1].url);
  assert.equal(
    s.sites().find((x) => x.url === "https://8.8.4.4/")?.lastResult,
    undefined,
  );
  m.stop();
  s.close();
});
test("scheduler works independently of browser and manual-only mode stops periodic checks", async () => {
  const s = new Store(":memory:");
  const m = new Monitor(s, new Mailer(s, async () => {}), 1, async (site) =>
    result("online", site.url),
  );
  const site = s.saveSite(payload());
  m.tick();
  await idle(m);
  assert.equal(s.history(site.id).length, 1);
  m.tick();
  await idle(m);
  assert.equal(s.history(site.id).length, 1);
  s.saveSettings({ ...s.settings(), autoCheckIntervalMinutes: 0 });
  const second = s.saveSite(payload("https://1.1.1.1/"));
  m.tick();
  await idle(m);
  assert.equal(s.site(second.id)?.lastResult, undefined);
  m.stop();
  s.close();
});
test("outbox deduplicates; absent SMTP is disabled, failed delivery retries and can recover", async () => {
  const s = new Store(":memory:");
  const mail = {
    siteName: "Test",
    siteUrl: "https://example.com",
    errorType: "Issue",
    errorMessage: "Test",
    recipient: "admin@example.com",
  };
  const id = s.queueMail("event", mail);
  assert.equal(s.queueMail("event", mail), id);
  assert.equal(s.logs().length, 1);
  const absent = new Mailer(s);
  absent.configured = false;
  await absent.flush();
  assert.equal(s.log(id).status, "disabled");
  let attempts = 0;
  const failing = new Mailer(s, async () => {
    if (++attempts === 1) throw new Error("SMTP unreachable");
  });
  await failing.flush();
  assert.equal(s.log(id).status, "pending");
  assert.equal(s.log(id).attempts, 1);
  s.db.prepare("UPDATE notifications SET next_attempt=0").run();
  await failing.flush();
  assert.equal(s.log(id).status, "sent");
  assert.equal(s.log(id).attempts, 2);
  s.close();
});
test("SMTP fails truthfully after three attempts and HTML is escaped", async () => {
  const s = new Store(":memory:");
  const id = s.queueMail("event", {
    siteName: "<script>",
    siteUrl: "",
    errorType: "Test",
    errorMessage: "Test",
    recipient: "admin@example.com",
  });
  const mailer = new Mailer(s, async () => {
    throw new Error("failure");
  });
  for (let i = 0; i < 3; i++) {
    s.db.prepare("UPDATE notifications SET next_attempt=0").run();
    await mailer.flush();
  }
  assert.equal(s.log(id).status, "failed");
  assert.equal(s.log(id).attempts, 3);
  assert.equal(escapeHtml('<img src="x">'), "&lt;img src=&quot;x&quot;&gt;");
  s.close();
});
test("bulk import gives line errors and detects duplicate normalized URLs", () => {
  const p = parseImport(
    "Client | example.com\nClient 2, https://example.com/\nftp://bad.com",
  );
  assert.equal(p.sites.length, 1);
  assert.equal(p.errors.length, 2);
  assert.match(p.errors[0], /Baris 2/);
  assert.equal(
    parseImport(Array(101).fill("example.com").join("\n")).errors.length,
    1,
  );
});
test("settings validate thresholds, renewal dates and client email", () => {
  assert.throws(() =>
    validateSettings({ ...DEFAULT_SETTINGS, failureThreshold: 0 }),
  );
  assert.throws(() =>
    validateSite({ ...payload(), domainRenewalDate: "2026-02-30" }),
  );
  assert.throws(() => validateSite({ ...payload(), clientEmail: "invalid" }));
  assert.equal(validateSettings(DEFAULT_SETTINGS).failureThreshold, 2);
});
test("password hashes are salted and verification rejects wrong password", async () => {
  const h = await hashPassword("test-password-only");
  assert.notEqual(h, await hashPassword("test-password-only"));
  assert.equal(await verifyPassword("test-password-only", h), true);
  assert.equal(await verifyPassword("wrong", h), false);
});
test("monitor notifies once per incident, sends recovery and honors warning toggle", async () => {
  const s = new Store(":memory:");
  const site = s.saveSite(payload());
  let status: CheckResult["status"] = "down";
  const mailer = new Mailer(s, async () => {});
  const monitor = new Monitor(s, mailer, 1, async (site) => ({
    ...result(status, site.url),
    warnings: status === "warning" ? ["SSL akan luput dalam 7 hari."] : [],
  }));
  await monitor.run(site.id);
  assert.equal(s.logs().length, 0);
  await monitor.run(site.id);
  assert.equal(s.logs().length, 1);
  await monitor.run(site.id);
  assert.equal(s.logs().length, 1);
  status = "online";
  await monitor.run(site.id);
  assert.equal(s.logs().length, 2);
  assert.match(s.logs()[0].errorType, /pulih/);
  status = "warning";
  await monitor.run(site.id);
  assert.equal(s.logs().length, 2);
  s.saveSettings({ ...s.settings(), alertOnWarning: true });
  await monitor.run(site.id);
  await monitor.run(site.id);
  assert.equal(s.logs().length, 3);
  monitor.stop();
  s.close();
});

test("scheduler confirms a first failure after 30 seconds without waiting the full interval", async () => {
  const store = new Store(":memory:");
  const site = store.saveSite(payload());
  const first = {
    ...result("down"),
    checkedAt: new Date(Date.now() - 31000).toISOString(),
  };
  store.record(site, first);
  const monitor = new Monitor(
    store,
    new Mailer(store, async () => {}),
    1,
    async (s) => result("down", s.url),
  );
  monitor.tick();
  await idle(monitor);
  assert.equal(store.incidents().length, 1);
  monitor.stop();
  store.close();
});
