import "dotenv/config";
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { Store } from "./backend/store.ts";
import { Monitor } from "./backend/monitor.ts";
import { Mailer } from "./backend/mail.ts";
import {
  authMiddleware,
  createSession,
  hashPassword,
  rateLimit,
  readSession,
  verifyPassword,
  type Session,
} from "./backend/auth.ts";
import {
  InputError,
  text,
  passwordText,
  validateSettings,
  validateSite,
} from "./backend/validation.ts";
import { publicTarget } from "./backend/network.ts";
export function createApp(
  store: Store,
  monitor: Monitor,
  options: { production?: boolean; appUrl?: string } = {},
) {
  const app = express();
  const production =
    options.production ?? process.env.NODE_ENV === "production";
  app.disable("x-powered-by");
  app.set("trust proxy", false);
  app.use((req, res, next) => {
    res.setHeader("X-Content-Type-Options", "nosniff");
    res.setHeader("X-Frame-Options", "DENY");
    res.setHeader("Referrer-Policy", "same-origin");
    res.setHeader("X-Robots-Tag", "noindex, nofollow");
    res.setHeader(
      "Content-Security-Policy",
      production
        ? "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
        : "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
    );
    if (req.path.startsWith("/api/"))
      res.setHeader("Cache-Control", "no-store");
    next();
  });
  app.use("/api", rateLimit(180, 60000));
  app.use(express.json({ limit: "1mb" }));
  app.use("/api", (req, res, next) => {
    if (["GET", "HEAD", "OPTIONS"].includes(req.method)) {
      next();
      return;
    }
    const expected =
      options.appUrl || process.env.APP_URL || `http://${req.headers.host}`;
    if (
      req.headers.origin !== new URL(expected).origin ||
      req.headers["x-wp-pulse"] !== "1"
    ) {
      res
        .status(403)
        .json({ error: "Permintaan mesti datang daripada portal ini." });
      return;
    }
    next();
  });
  const wrap =
    (handler: (req: Request, res: Response) => unknown) =>
    (req: Request, res: Response, next: NextFunction) =>
      Promise.resolve()
        .then(() => handler(req, res))
        .catch(next);
  app.get("/api/auth/session", (req, res) => {
    const s = readSession(store, req);
    res.json(
      s
        ? { authenticated: true, username: s.username, csrf: s.csrf }
        : {
            authenticated: false,
            configured: !!store.db
              .prepare("SELECT username FROM admins LIMIT 1")
              .get(),
          },
    );
  });
  app.post(
    "/api/auth/login",
    rateLimit(8, 15 * 60000),
    wrap(async (req, res) => {
      const username = text(req.body.username, 80, true);
      const password = passwordText(req.body.password);
      const user = store.db
        .prepare("SELECT password_hash FROM admins WHERE username=?")
        .get(username) as { password_hash: string } | undefined;
      const dummy =
        "scrypt:00112233445566778899aabbccddeeff:" + "00".repeat(64);
      if (
        !(await verifyPassword(password, user?.password_hash || dummy)) ||
        !user
      ) {
        res
          .status(401)
          .json({ error: "Nama pengguna atau kata laluan tidak betul." });
        return;
      }
      const existing = readSession(store, req);
      if (existing)
        store.db
          .prepare("DELETE FROM sessions WHERE token_hash=?")
          .run(existing.token_hash);
      const session = createSession(store, username);
      res.cookie("wp_pulse_session", session.token, {
        httpOnly: true,
        sameSite: "strict",
        secure: production,
        maxAge: 12 * 3600000,
        path: "/",
      });
      store.audit(username, "login", "portal");
      res.json({ authenticated: true, username, csrf: session.csrf });
    }),
  );
  app.use("/api", authMiddleware(store));
  const actor = (res: Response) => (res.locals.session as Session).username;
  app.post("/api/auth/logout", (req, res) => {
    store.db
      .prepare("DELETE FROM sessions WHERE token_hash=?")
      .run((res.locals.session as Session).token_hash);
    res.clearCookie("wp_pulse_session", {
      path: "/",
      httpOnly: true,
      sameSite: "strict",
      secure: production,
    });
    res.json({ success: true });
  });
  app.post(
    "/api/auth/password",
    rateLimit(5, 15 * 60000),
    wrap(async (req, res) => {
      const username = actor(res);
      const current = passwordText(req.body.currentPassword);
      const next = passwordText(req.body.newPassword);
      if (next.length < 12)
        throw new InputError(
          "Kata laluan baharu mesti sekurang-kurangnya 12 aksara.",
        );
      const user = store.db
        .prepare("SELECT password_hash FROM admins WHERE username=?")
        .get(username) as { password_hash: string };
      if (!(await verifyPassword(current, user.password_hash))) {
        res.status(403).json({ error: "Kata laluan semasa tidak betul." });
        return;
      }
      const hash = await hashPassword(next);
      store.transaction(() => {
        store.db
          .prepare("UPDATE admins SET password_hash=? WHERE username=?")
          .run(hash, username);
        store.db.prepare("DELETE FROM sessions WHERE username=?").run(username);
        store.audit(username, "password_changed", "portal");
      });
      res.clearCookie("wp_pulse_session", {
        path: "/",
        httpOnly: true,
        sameSite: "strict",
        secure: production,
      });
      res.json({ success: true });
    }),
  );
  app.get("/api/state", (req, res) =>
    res.json({
      sites: store
        .sites()
        .map((s) => ({
          ...s,
          isChecking: monitor.active.has(s.id) || monitor.queue.has(s.id),
        })),
      settings: store.settings(),
      alertLogs: store.logs(),
      incidents: store.incidents(),
      monitor: {
        running: monitor.active.size,
        queued: monitor.queue.size,
        smtpConfigured: monitor.mailer.configured,
        lastTickAt: monitor.lastTickAt,
        retentionDays: monitor.retentionDays,
      },
    }),
  );
  async function ensurePublic(value: ReturnType<typeof validateSite>) {
    let timer: ReturnType<typeof setTimeout>;
    await Promise.race([
      Promise.all([
        publicTarget(value.url),
        ...(value.restApiUrl ? [publicTarget(value.restApiUrl)] : []),
      ]),
      new Promise<never>((_, reject) => {
        timer = setTimeout(
          () =>
            reject(new InputError("DNS tidak dapat disahkan. Cuba semula.")),
          6000,
        );
      }),
    ]).finally(() => clearTimeout(timer));
  }
  app.post(
    "/api/sites",
    wrap(async (req, res) => {
      if (store.sites().length >= 1000)
        throw new InputError("Had 1,000 website telah dicapai.");
      const value = validateSite(req.body);
      await ensurePublic(value);
      const site = store.saveSite(value);
      store.audit(actor(res), "site_added", site.id);
      monitor.enqueue(site.id);
      res.status(201).json(site);
    }),
  );
  app.put(
    "/api/sites/:id",
    wrap(async (req, res) => {
      if (!store.site(req.params.id)) {
        res.status(404).json({ error: "Website tidak ditemui." });
        return;
      }
      const value = validateSite(req.body);
      await ensurePublic(value);
      const site = store.saveSite(value, req.params.id);
      store.audit(actor(res), "site_updated", site.id);
      monitor.enqueue(site.id);
      res.json(site);
    }),
  );
  app.delete("/api/sites/:id", (req, res) => {
    store.removeSite(req.params.id);
    monitor.queue.delete(req.params.id);
    store.audit(actor(res), "site_deleted", req.params.id);
    res.json({ success: true });
  });
  app.post(
    "/api/sites/import",
    wrap(async (req, res) => {
      if (
        !Array.isArray(req.body.sites) ||
        !req.body.sites.length ||
        req.body.sites.length > 100
      )
        throw new InputError("Import 1–100 website setiap kali.");
      const entries = req.body.sites.map(validateSite);
      for (const entry of entries) await ensurePublic(entry);
      const existing = new Set(store.sites().map((s) => s.url));
      const added: string[] = [];
      let duplicates = 0;
      store.transaction(() => {
        for (const entry of entries) {
          if (existing.has(entry.url)) {
            duplicates++;
            continue;
          }
          if (existing.size >= 1000)
            throw new InputError("Had 1,000 website telah dicapai.");
          const site = store.saveSite(entry);
          added.push(site.id);
          existing.add(entry.url);
        }
        store.audit(actor(res), "sites_imported", String(added.length));
      });
      for (const id of added) monitor.enqueue(id);
      res.json({ added: added.length, duplicates });
    }),
  );
  app.put(
    "/api/settings",
    wrap((req, res) => {
      const settings = validateSettings(req.body);
      store.saveSettings(settings);
      store.audit(actor(res), "settings_updated", "portal");
      monitor.tick();
      res.json(settings);
    }),
  );
  app.post(
    "/api/check",
    rateLimit(20, 60000),
    wrap((req, res) => {
      const ids = req.body.siteIds;
      if (
        !Array.isArray(ids) ||
        ids.length > 1000 ||
        ids.some((id) => typeof id !== "string")
      )
        throw new InputError("Senarai ID website tidak sah.");
      let queued = 0;
      for (const id of new Set<string>(ids)) if (monitor.enqueue(id)) queued++;
      res.status(202).json({ queued });
    }),
  );
  app.get("/api/sites/:id/history", (req, res) =>
    res.json(store.history(req.params.id)),
  );
  app.put(
    "/api/incidents/:id",
    wrap((req, res) => {
      const notes = text(req.body.notes, 4000);
      const r = store.db
        .prepare("UPDATE incidents SET notes=? WHERE id=?")
        .run(notes, req.params.id);
      if (!r.changes) {
        res.status(404).json({ error: "Insiden tidak ditemui." });
        return;
      }
      store.audit(actor(res), "incident_notes_updated", req.params.id);
      res.json({ success: true });
    }),
  );
  app.get("/api/audit", (req, res) =>
    res.json(
      store.db
        .prepare(
          "SELECT at,actor,action,target FROM audit ORDER BY id DESC LIMIT 200",
        )
        .all(),
    ),
  );
  app.get("/api/export", (req, res) => {
    res.setHeader(
      "Content-Disposition",
      'attachment; filename="alfatih-clients-export.json"',
    );
    res.json({
      version: "0.2.1",
      exportedAt: new Date().toISOString(),
      sites: store.sites(),
      settings: store.settings(),
      incidents: store.incidents(),
    });
  });
  app.post(
    "/api/email",
    rateLimit(5, 60000),
    wrap(async (req, res) => {
      const mode = req.body.mode;
      if (!["test", "summary", "site"].includes(mode))
        throw new InputError("Jenis notifikasi tidak sah.");
      const recipient = store.settings().adminEmail;
      let name = "Ujian notifikasi",
        url = "",
        type = "Ujian SMTP",
        message = "Ini ialah emel ujian Alfatih Client Centre.";
      if (mode === "site") {
        const site = store.site(text(req.body.siteId, 80, true));
        if (!site?.lastResult) throw new InputError("Semak website dahulu.");
        name = site.name;
        url = site.url;
        type =
          site.lastResult.status === "online"
            ? "Status website"
            : "Isu website";
        message = site.lastResult.wpErrorMessage || site.lastResult.statusText;
      }
      if (mode === "summary") {
        const sites = store
          .sites()
          .filter((s) => s.lastResult && s.lastResult.status !== "online");
        if (!sites.length) throw new InputError("Tiada isu untuk dihantar.");
        name = `Ringkasan ${sites.length} website`;
        type = "Ringkasan isu";
        message = sites
          .map(
            (s) =>
              `${s.name} (${s.url}): ${s.lastResult!.status} — ${s.lastResult!.wpErrorMessage || ""}`,
          )
          .join("\n");
      }
      const logId = store.queueMail(`manual:${randomUUID()}`, {
        siteName: name,
        siteUrl: url,
        errorType: type,
        errorMessage: message,
        recipient,
      });
      store.audit(actor(res), "email_requested", mode);
      await monitor.mailer.flush(logId);
      const log = store.log(logId);
      res.json({
        success: log.status === "sent",
        status: log.status,
        log,
        message:
          log.status === "sent"
            ? "SMTP telah menerima emel untuk penghantaran."
            : log.status === "disabled"
              ? "SMTP belum dikonfigurasi. Tiada emel dihantar."
              : log.status === "failed"
                ? "Penghantaran emel gagal."
                : "Emel dalam queue; sistem akan cuba menghantarnya.",
      });
    }),
  );
  app.use("/api", (req, res) =>
    res.status(404).json({ error: "Endpoint tidak ditemui." }),
  );
  app.use(
    (
      err: Error & { code?: string; status?: number; type?: string },
      req: Request,
      res: Response,
      next: NextFunction,
    ) => {
      if (err instanceof InputError)
        res.status(400).json({ error: err.message });
      else if (err.message?.includes("UNIQUE constraint failed: sites.url"))
        res
          .status(409)
          .json({ error: "URL website ini sudah ada dalam senarai." });
      else if (err.type === "entity.too.large")
        res.status(413).json({ error: "Data terlalu besar." });
      else if (err instanceof SyntaxError)
        res.status(400).json({ error: "JSON tidak sah." });
      else {
        console.error("API error:", err.code || err.name);
        res
          .status(500)
          .json({
            error: "Server tidak dapat menyelesaikan permintaan. Cuba semula.",
          });
      }
    },
  );
  return app;
}
async function main() {
  const production = process.env.NODE_ENV === "production";
  const port = Number(process.env.PORT || 3000);
  if (production && !process.env.APP_URL)
    throw new Error("APP_URL diperlukan untuk production.");
  if (production && new URL(process.env.APP_URL!).protocol !== "https:")
    throw new Error("APP_URL production mesti HTTPS.");
  const store = new Store(
    process.env.DATABASE_PATH || "./data/wp-pulse.sqlite",
  );
  const mailer = new Mailer(store);
  const concurrency = Math.min(
    10,
    Math.max(1, Number(process.env.CHECK_CONCURRENCY) || 3),
  );
  const retention = Math.min(
    365,
    Math.max(7, Number(process.env.RETENTION_DAYS) || 90),
  );
  const monitor = new Monitor(store, mailer, concurrency, undefined, retention);
  const app = createApp(store, monitor, { production });
  const root = path.dirname(fileURLToPath(import.meta.url));
  if (!production) {
    const { createServer } = await import("vite");
    const vite = await createServer({
      server: { middlewareMode: true },
      appType: "spa",
    });
    app.use(vite.middlewares);
  } else {
    app.use(express.static(path.join(root, "dist")));
    app.get("*", (req, res) =>
      res.sendFile(path.join(root, "dist", "index.html")),
    );
  }
  const server = app.listen(port, process.env.HOST || "127.0.0.1", () => {
    console.log(`Alfatih Client Centre 0.2.1 ready on port ${port}`);
    monitor.start();
  });
  server.requestTimeout = 20000;
  server.headersTimeout = 10000;
  const stop = () => {
    monitor.stop();
    server.close(() => {
      store.close();
      process.exit(0);
    });
    setTimeout(() => process.exit(0), 20000).unref();
  };
  process.on("SIGTERM", stop);
  process.on("SIGINT", stop);
}
if (
  process.argv[1] &&
  path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
)
  main().catch((e) => {
    console.error(e.message);
    process.exit(1);
  });
