/**
 * Auth Security Suite
 *
 * Covers the rules that keep one person from holding twenty accounts: email
 * alias normalisation, disposable-domain rejection, password hashing, and
 * single-use token handling. Pure logic only — no database required.
 */

import { describe, test, expect } from "./runner";
import {
  canonicaliseEmail,
  isDisposableEmail,
  normaliseEmail,
  splitEmail,
} from "../../src/lib/auth/email";
import { equaliseTiming, hashPassword, verifyPassword } from "../../src/lib/auth/password";
import { hashIp, hashToken, issueToken, newId } from "../../src/lib/auth/tokens";
import {
  MIN_PASSWORD_LENGTH,
  scorePassword,
  validateEmail,
  validatePassword,
} from "../../src/lib/auth/validation";
import { authErrorMessage, isAuthErrorCode } from "../../src/lib/auth/errors";
import { PASSWORD_RESET_TTL_MS, VERIFICATION_TTL_MS } from "../../src/lib/auth/config";

describe("Auth — email normalisation (one inbox, one account)", () => {
  test("Gmail dots are cosmetic and collapse onto the same key", () => {
    expect(normaliseEmail("t.i.m.o@gmail.com")).toBe("timo@gmail.com");
    expect(normaliseEmail("timo@gmail.com")).toBe("timo@gmail.com");
  });

  test("plus-tag aliases collapse onto the base address", () => {
    expect(normaliseEmail("timo+spam1@gmail.com")).toBe("timo@gmail.com");
    expect(normaliseEmail("timo+spam2@gmail.com")).toBe("timo@gmail.com");
  });

  test("googlemail.com is an alias of gmail.com", () => {
    expect(normaliseEmail("timo@googlemail.com")).toBe("timo@gmail.com");
  });

  test("twenty Gmail variants all reduce to a single uniqueness key", () => {
    const variants = Array.from({ length: 20 }, (_, index) => `t.i.mo+throwaway${index}@gmail.com`);
    const keys = new Set(variants.map((variant) => normaliseEmail(variant)));
    expect(keys.size).toBe(1);
    expect([...keys][0]).toBe("timo@gmail.com");
  });

  test("plus-tags are stripped on non-Gmail providers too", () => {
    expect(normaliseEmail("bob+newsletter@outlook.com")).toBe("bob@outlook.com");
  });

  test("dots are preserved outside Gmail — they are significant there", () => {
    expect(normaliseEmail("first.last@company.de")).toBe("first.last@company.de");
  });

  test("normalisation is case-insensitive", () => {
    expect(normaliseEmail("Timo@Example.COM")).toBe("timo@example.com");
    expect(canonicaliseEmail("  Timo@Example.COM  ")).toBe("timo@example.com");
  });

  test("addresses with multiple @ use the last one as the separator", () => {
    expect(splitEmail("weird\"@\"name@example.com")?.domain).toBe("example.com");
  });

  test("malformed input yields no key rather than a bogus one", () => {
    expect(normaliseEmail("not-an-email")).toBe(null);
    expect(normaliseEmail("@example.com")).toBe(null);
    expect(normaliseEmail("user@")).toBe(null);
    expect(normaliseEmail("user@localhost")).toBe(null);
    expect(normaliseEmail("+tag@gmail.com")).toBe("+tag@gmail.com");
    expect(normaliseEmail("")).toBe(null);
  });
});

describe("Auth — disposable mailbox rejection", () => {
  test("known throw-away providers are refused", () => {
    expect(isDisposableEmail("abc@mailinator.com")).toBe(true);
    expect(isDisposableEmail("abc@guerrillamail.com")).toBe(true);
    expect(isDisposableEmail("abc@yopmail.com")).toBe(true);
  });

  test("detection ignores casing and surrounding whitespace", () => {
    expect(isDisposableEmail("  ABC@MAILINATOR.COM ")).toBe(true);
  });

  test("real providers pass through", () => {
    expect(isDisposableEmail("timo@gmail.com")).toBe(false);
    expect(isDisposableEmail("timo@company.de")).toBe(false);
  });

  test("malformed addresses are not treated as disposable", () => {
    expect(isDisposableEmail("nonsense")).toBe(false);
  });
});

describe("Auth — password hashing", () => {
  test("digest is self-describing and carries its cost parameters", async () => {
    const digest = await hashPassword("Sicher1234!");
    const parts = digest.split("$");
    expect(parts[0]).toBe("scrypt");
    expect(parts.length).toBe(6);
    expect(Number(parts[1]) >= 16384).toBe(true);
  });

  test("the plaintext never appears in the digest", async () => {
    const digest = await hashPassword("Sicher1234!");
    expect(digest.includes("Sicher1234!")).toBe(false);
  });

  test("the same password hashes differently every time (unique salt)", async () => {
    const first = await hashPassword("Sicher1234!");
    const second = await hashPassword("Sicher1234!");
    expect(first === second).toBe(false);
  });

  test("verification accepts the correct password", async () => {
    const digest = await hashPassword("Sicher1234!");
    expect(await verifyPassword("Sicher1234!", digest)).toBe(true);
  });

  test("verification rejects a wrong password, empty input and a null digest", async () => {
    const digest = await hashPassword("Sicher1234!");
    expect(await verifyPassword("sicher1234!", digest)).toBe(false);
    expect(await verifyPassword("", digest)).toBe(false);
    expect(await verifyPassword("Sicher1234!", null)).toBe(false);
  });

  test("a tampered or foreign digest is rejected, never crashes", async () => {
    expect(await verifyPassword("Sicher1234!", "garbage")).toBe(false);
    expect(await verifyPassword("Sicher1234!", "scrypt$1$2$3$4$5")).toBe(false);
    expect(await verifyPassword("Sicher1234!", "bcrypt$16384$8$1$c2FsdA==$aGFzaA==")).toBe(false);
  });

  test("unicode passwords survive normalisation round-trip", async () => {
    const digest = await hashPassword("Pässwörd-Ünïcode1!");
    expect(await verifyPassword("Pässwörd-Ünïcode1!", digest)).toBe(true);
  });

  test("the timing equaliser resolves without throwing", async () => {
    await equaliseTiming();
    expect(true).toBe(true);
  });
});

describe("Auth — tokens and identifiers", () => {
  test("only the digest is meant for storage — it differs from the token", () => {
    const { token, hash } = issueToken();
    expect(token === hash).toBe(false);
    expect(hash.length).toBe(64);
  });

  test("hashing is deterministic, so a cookie can be looked up", () => {
    const { token, hash } = issueToken();
    expect(hashToken(token)).toBe(hash);
  });

  test("tokens are unique across many issuances", () => {
    const tokens = new Set(Array.from({ length: 500 }, () => issueToken().token));
    expect(tokens.size).toBe(500);
  });

  test("IP hashing is stable and returns null for missing addresses", () => {
    expect(hashIp("203.0.113.7")).toBe(hashIp("203.0.113.7"));
    expect(hashIp(null)).toBe(null);
    expect(hashIp(undefined)).toBe(null);
  });

  test("generated ids stay inside the varchar(64) column", () => {
    const id = newId("usr");
    expect(id.startsWith("usr_")).toBe(true);
    expect(id.length <= 64).toBe(true);
  });
});

describe("Auth — credential validation", () => {
  test("signup demands length plus variety", () => {
    expect(validatePassword("short1A", false, { requireStrength: true }) !== null).toBe(true);
    expect(validatePassword("alllowercase", false, { requireStrength: true }) !== null).toBe(true);
    expect(validatePassword("Sicher1234", false, { requireStrength: true })).toBe(null);
  });

  test("login accepts any non-empty password so old accounts keep working", () => {
    expect(validatePassword("legacy", false)).toBe(null);
    expect(validatePassword("", false) !== null).toBe(true);
  });

  test("strength scoring gates on length before counting variety", () => {
    expect(scorePassword("Ab1!").score).toBe(1);
    expect(scorePassword("").score).toBe(0);
    expect(scorePassword("abcdefgh").score).toBe(1);
    expect(scorePassword("Abcdefgh").score).toBe(2);
    expect(scorePassword("Abcdefg1").score).toBe(3);
    expect(scorePassword("Abcdefg1!").score).toBe(4);
  });

  test("the minimum length constant matches the scoring gate", () => {
    expect(scorePassword("A1!".padEnd(MIN_PASSWORD_LENGTH - 1, "x")).checks.length).toBe(false);
    expect(scorePassword("A1!".padEnd(MIN_PASSWORD_LENGTH, "x")).checks.length).toBe(true);
  });

  test("email validation rejects the usual malformed shapes", () => {
    expect(validateEmail("timo@example.com", false)).toBe(null);
    expect(validateEmail("", false) !== null).toBe(true);
    expect(validateEmail("timo@", false) !== null).toBe(true);
    expect(validateEmail("timo example.com", false) !== null).toBe(true);
  });
});

describe("Auth — password reset link shape", () => {
  test("reset links live an hour, confirmation links a day", () => {
    expect(PASSWORD_RESET_TTL_MS).toBe(60 * 60 * 1000);
    expect(VERIFICATION_TTL_MS).toBe(24 * 60 * 60 * 1000);
    // A reset hands over an existing account, so its window must be the shorter one.
    expect(PASSWORD_RESET_TTL_MS < VERIFICATION_TTL_MS).toBe(true);
  });

  test("a reset token is a fresh secret each time, stored only as a digest", () => {
    const first = issueToken();
    const second = issueToken();
    expect(first.token === second.token).toBe(false);
    expect(first.hash === hashToken(first.token)).toBe(true);
    expect(first.hash.includes(first.token)).toBe(false);
  });

  test("the new password faces the same strength rules as signup", () => {
    expect(validatePassword("short1A", false, { requireStrength: true }) !== null).toBe(true);
    expect(validatePassword("Sicher1234!", false, { requireStrength: true })).toBe(null);
  });

  test("link and expiry codes are distinct and both localised", () => {
    expect(isAuthErrorCode("invalid_token")).toBe(true);
    expect(isAuthErrorCode("expired_token")).toBe(true);
    expect(authErrorMessage("invalid_token", true) === authErrorMessage("expired_token", true)).toBe(
      false
    );
    expect(authErrorMessage("expired_token", true) === authErrorMessage("expired_token", false)).toBe(
      false
    );
  });
});

describe("Auth — error vocabulary", () => {
  test("known codes resolve in both languages", () => {
    expect(isAuthErrorCode("email_taken")).toBe(true);
    expect(authErrorMessage("email_taken", true).length > 0).toBe(true);
    expect(authErrorMessage("email_taken", false).length > 0).toBe(true);
    expect(authErrorMessage("email_taken", true) === authErrorMessage("email_taken", false)).toBe(
      false
    );
  });

  test("unknown codes fall back instead of leaking raw values", () => {
    expect(isAuthErrorCode("wat")).toBe(false);
    expect(authErrorMessage("wat", false)).toBe(authErrorMessage("server_error", false));
    expect(authErrorMessage(null, false)).toBe(authErrorMessage("server_error", false));
  });

  test("rate limiting reports the wait in whole minutes", () => {
    expect(authErrorMessage("rate_limited", false, 90).includes("2 minutes")).toBe(true);
    expect(authErrorMessage("rate_limited", false, 30).includes("1 minute")).toBe(true);
    expect(authErrorMessage("rate_limited", true, 120).includes("2 Minuten")).toBe(true);
  });
});
