import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { AlertRepository } from "../../../database/repositories/alert-repository.js";
import { DataCrypto } from "../../../utils/data-crypto.js";

describe("AlertRepository", () => {
  let adapter: TestSqliteDatabase | null = null;

  afterEach(async () => {
    if (adapter) {
      await adapter.close();
      adapter = null;
    }
  });

  async function createRepository(
    onWrite?: () => void | Promise<void>,
  ): Promise<AlertRepository> {
    adapter = new TestSqliteDatabase();
    const context = await adapter.connect();
    await adapter.exec(`
      INSERT INTO users (id, username, password_hash)
      VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
      INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
      VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password');
    `);

    return new AlertRepository(context, onWrite);
  }

  it("manages notification channels", async () => {
    let writes = 0;
    const repo = await createRepository(() => {
      writes += 1;
    });

    const created = await repo.createNotificationChannel({
      userId: "user-1",
      name: "Ops",
      type: "webhook",
      config: '{"url":"https://example.test"}',
      enabled: true,
    });

    expect(created).toMatchObject({
      user_id: "user-1",
      name: "Ops",
      type: "webhook",
      enabled: 1,
    });

    const updated = await repo.updateNotificationChannel(created.id, "user-1", {
      name: "Ops disabled",
      enabled: false,
    });
    expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 });

    expect(await repo.listNotificationChannels("user-1")).toHaveLength(1);
    expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe(
      false,
    );
    expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe(
      true,
    );
    expect(await repo.listNotificationChannels("user-1")).toHaveLength(0);
    expect(writes).toBe(3);
  });

  it("manages alert rules and linked channels", async () => {
    const repo = await createRepository();
    const ownedChannel = await repo.createNotificationChannel({
      userId: "user-1",
      name: "Owned",
      type: "ntfy",
      config: '{"url":"https://ntfy.test","topic":"termix"}',
      enabled: true,
    });
    const foreignChannel = await repo.createNotificationChannel({
      userId: "user-2",
      name: "Foreign",
      type: "webhook",
      config: '{"url":"https://example.test"}',
      enabled: true,
    });

    const created = await repo.createAlertRule({
      userId: "user-1",
      hostId: null,
      name: "CPU high",
      enabled: true,
      triggerType: "cpu_threshold",
      thresholdValue: 80,
      thresholdDurationSeconds: 30,
      cooldownMinutes: 5,
      channels: [ownedChannel.id, foreignChannel.id],
      now: "2026-01-01T00:00:00.000Z",
    });

    expect(created).toMatchObject({
      user_id: "user-1",
      host_id: null,
      name: "CPU high",
      trigger_type: "cpu_threshold",
      threshold_value: 80,
      channels: [ownedChannel.id],
    });

    const updated = await repo.updateAlertRule(created.id, "user-1", {
      name: "CPU very high",
      hostId: 1,
      channels: [],
      now: "2026-01-02T00:00:00.000Z",
    });
    expect(updated).toMatchObject({
      name: "CPU very high",
      host_id: 1,
      channels: [],
      updated_at: "2026-01-02T00:00:00.000Z",
    });

    const rules = await repo.listAlertRules("user-1");
    expect(rules).toHaveLength(1);
    expect(rules[0].channels).toEqual([]);
    expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false);
    expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true);
  });

  it("lists, acknowledges, and prunes firings", async () => {
    const repo = await createRepository();
    const rule = await repo.createAlertRule({
      userId: "user-1",
      hostId: 1,
      name: "Host offline",
      enabled: true,
      triggerType: "host_offline",
      thresholdValue: null,
      thresholdDurationSeconds: null,
      cooldownMinutes: 15,
      channels: [],
      now: "2026-01-01T00:00:00.000Z",
    });

    await repo.createFiring({
      userId: "user-1",
      ruleId: rule.id,
      hostId: 1,
      hostName: "alpha",
      value: null,
      message: "down",
      severity: "critical",
    });

    const listed = await repo.listAlertFirings({
      userId: "user-1",
      limit: 10,
      offset: 0,
    });
    expect(listed.total).toBe(1);
    expect(listed.firings[0]).toMatchObject({
      rule_id: rule.id,
      host_name: "alpha",
      acknowledged: 0,
      rule_name: "Host offline",
    });

    await repo.acknowledgeFiring(listed.firings[0].id, "user-1");
    const unacknowledged = await repo.listAlertFirings({
      userId: "user-1",
      acknowledged: false,
      limit: 10,
      offset: 0,
    });
    expect(unacknowledged.total).toBe(0);

    await repo.acknowledgeAllFirings("user-1");
    await repo.pruneFiringsOlderThan("user-1", 0);
  });

  it("loads enabled rules and notification channels for the alert engine", async () => {
    const repo = await createRepository();
    const channel = await repo.createNotificationChannel({
      userId: "user-1",
      name: "Ops",
      type: "webhook",
      config: '{"url":"https://example.test"}',
      enabled: true,
    });
    const disabledChannel = await repo.createNotificationChannel({
      userId: "user-1",
      name: "Disabled",
      type: "webhook",
      config: '{"url":"https://disabled.test"}',
      enabled: false,
    });
    const rule = await repo.createAlertRule({
      userId: "user-1",
      hostId: null,
      name: "CPU high",
      enabled: true,
      triggerType: "cpu_threshold",
      thresholdValue: 90,
      thresholdDurationSeconds: 0,
      cooldownMinutes: 15,
      channels: [channel.id, disabledChannel.id],
      now: "2026-01-01T00:00:00.000Z",
    });

    expect(await repo.listEnabledRulesForHost(1)).toMatchObject([
      {
        id: rule.id,
        userId: "user-1",
        triggerType: "cpu_threshold",
        enabled: true,
      },
    ]);
    expect(await repo.findRuleById(rule.id)).toMatchObject({
      id: rule.id,
      cooldownMinutes: 15,
    });
    expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([
      {
        id: channel.id,
        type: "webhook",
        config: '{"url":"https://example.test"}',
        enabled: true,
      },
    ]);
  });

  it("keeps another user's wildcard rules off a host they do not own", async () => {
    const repo = await createRepository();
    await adapter!.exec(`
      INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
      VALUES (2, 'user-2', 'bravo', '127.0.0.2', 22, 'root', 'password');
    `);

    const ownerRule = await repo.createAlertRule({
      userId: "user-1",
      hostId: null,
      name: "Owner wildcard",
      enabled: true,
      triggerType: "cpu_threshold",
      thresholdValue: 90,
      thresholdDurationSeconds: 0,
      cooldownMinutes: 15,
      channels: [],
      now: "2026-01-01T00:00:00.000Z",
    });
    const otherRule = await repo.createAlertRule({
      userId: "user-2",
      hostId: null,
      name: "Other wildcard",
      enabled: true,
      triggerType: "cpu_threshold",
      thresholdValue: 90,
      thresholdDurationSeconds: 0,
      cooldownMinutes: 15,
      channels: [],
      now: "2026-01-01T00:00:00.000Z",
    });

    // Host 1 belongs to user-1, so only user-1's wildcard rule may fire.
    const forHostOne = await repo.listEnabledRulesForHost(1);
    expect(forHostOne.map((rule) => rule.id)).toEqual([ownerRule.id]);

    const forHostTwo = await repo.listEnabledRulesForHost(2);
    expect(forHostTwo.map((rule) => rule.id)).toEqual([otherRule.id]);
  });

  it("still matches a rule pinned to a specific host", async () => {
    const repo = await createRepository();
    const pinned = await repo.createAlertRule({
      userId: "user-1",
      hostId: 1,
      name: "Pinned",
      enabled: true,
      triggerType: "disk_threshold",
      thresholdValue: 90,
      thresholdDurationSeconds: 0,
      cooldownMinutes: 15,
      channels: [],
      now: "2026-01-01T00:00:00.000Z",
    });

    expect((await repo.listEnabledRulesForHost(1)).map((r) => r.id)).toEqual([
      pinned.id,
    ]);
  });

  it("encrypts channel configs at rest and returns them decrypted", async () => {
    const key = crypto.randomBytes(32);
    const spy = vi
      .spyOn(DataCrypto, "getUserDataKey")
      .mockImplementation(() => key);

    try {
      const repo = await createRepository();
      const secret = '{"url":"https://ntfy.test","token":"super-secret"}';

      const created = await repo.createNotificationChannel({
        userId: "user-1",
        name: "Ntfy",
        type: "ntfy",
        config: secret,
        enabled: true,
      });
      expect(created.config).toBe(secret);

      // The stored bytes must not contain the token in the clear.
      const stored = await adapter!.query<{ config: string }>(
        sql`SELECT config FROM notification_channels WHERE id = ${created.id}`,
      );
      expect(stored[0].config).not.toContain("super-secret");

      // Every read path hands back plaintext.
      const listed = await repo.listNotificationChannels("user-1");
      expect(listed[0].config).toBe(secret);
      expect(
        (await repo.findNotificationChannelForUser(created.id, "user-1"))
          ?.config,
      ).toBe(secret);

      const rule = await repo.createAlertRule({
        userId: "user-1",
        hostId: null,
        name: "CPU",
        enabled: true,
        triggerType: "cpu_threshold",
        thresholdValue: 90,
        thresholdDurationSeconds: 0,
        cooldownMinutes: 15,
        channels: [created.id],
        now: "2026-01-01T00:00:00.000Z",
      });
      const engineChannels = await repo.listEnabledChannelsForRule(rule.id);
      expect(engineChannels[0].config).toBe(secret);

      const rotated = '{"url":"https://ntfy.test","token":"rotated"}';
      const updated = await repo.updateNotificationChannel(
        created.id,
        "user-1",
        { config: rotated },
      );
      expect(updated?.config).toBe(rotated);
    } finally {
      spy.mockRestore();
    }
  });

  it("still reads channel configs written before encryption", async () => {
    const repo = await createRepository();
    const plaintext = '{"url":"https://legacy.test"}';
    const created = await repo.createNotificationChannel({
      userId: "user-1",
      name: "Legacy",
      type: "webhook",
      config: plaintext,
      enabled: true,
    });

    const key = crypto.randomBytes(32);
    const spy = vi
      .spyOn(DataCrypto, "getUserDataKey")
      .mockImplementation(() => key);
    try {
      const found = await repo.findNotificationChannelForUser(
        created.id,
        "user-1",
      );
      expect(found?.config).toBe(plaintext);
    } finally {
      spy.mockRestore();
    }
  });

  it("loads host display names for alert payloads", async () => {
    const repo = await createRepository();

    expect(await repo.getHostDisplayName(1)).toBe("alpha");
    expect(await repo.getHostDisplayName(999)).toBeNull();
  });

  it("deletes all alert data for a user", async () => {
    let writes = 0;
    const repo = await createRepository(() => {
      writes += 1;
    });

    const userChannel = await repo.createNotificationChannel({
      userId: "user-1",
      name: "Ops",
      type: "webhook",
      config: '{"url":"https://example.test"}',
      enabled: true,
    });
    const otherChannel = await repo.createNotificationChannel({
      userId: "user-2",
      name: "Other",
      type: "webhook",
      config: '{"url":"https://other.test"}',
      enabled: true,
    });
    const userRule = await repo.createAlertRule({
      userId: "user-1",
      hostId: 1,
      name: "CPU high",
      enabled: true,
      triggerType: "cpu_threshold",
      thresholdValue: 80,
      thresholdDurationSeconds: 30,
      cooldownMinutes: 5,
      channels: [userChannel.id],
      now: "2026-01-01T00:00:00.000Z",
    });
    const otherRule = await repo.createAlertRule({
      userId: "user-2",
      hostId: null,
      name: "Memory high",
      enabled: true,
      triggerType: "memory_threshold",
      thresholdValue: 90,
      thresholdDurationSeconds: 60,
      cooldownMinutes: 10,
      channels: [otherChannel.id],
      now: "2026-01-01T00:00:00.000Z",
    });
    await repo.createFiring({
      userId: "user-1",
      ruleId: userRule.id,
      hostId: 1,
      hostName: "alpha",
      value: 95,
      message: "high",
      severity: "warning",
    });
    await repo.createFiring({
      userId: "user-2",
      ruleId: otherRule.id,
      hostId: 1,
      hostName: "alpha",
      value: 91,
      message: "other",
      severity: "warning",
    });

    await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
      firingsDeleted: 1,
      ruleLinksDeleted: 1,
      rulesDeleted: 1,
      channelsDeleted: 1,
    });
    await expect(repo.deleteByUserId("missing")).resolves.toEqual({
      firingsDeleted: 0,
      ruleLinksDeleted: 0,
      rulesDeleted: 0,
      channelsDeleted: 0,
    });

    expect(await repo.listNotificationChannels("user-1")).toEqual([]);
    expect(await repo.listAlertRules("user-1")).toEqual([]);
    expect(
      await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }),
    ).toEqual({ firings: [], total: 0 });
    expect(await repo.listNotificationChannels("user-2")).toHaveLength(1);
    expect(await repo.listAlertRules("user-2")).toHaveLength(1);
    expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1);
    expect(writes).toBe(7);
  });
});
