/**
 * Auth Database Integration Suite
 *
 * Exercises the parts of the auth system that only mean anything against a real
 * Postgres: the unique email key, verification-token lifecycle, Google account
 * linking, and session lookup.
 *
 * Run with:  npm run test:auth
 *
 * Requires a reachable DATABASE_URL with the migrations applied
 * (`docker compose up -d postgres && npm run db:push`). Without a database the
 * script reports a skip and exits 0 rather than pretending to have passed.
 */

// Keep first: populates DATABASE_URL before the database module below reads it.
import "./loadEnv";

import { and, eq, gt, like } from "drizzle-orm";
import { describe, test, expect, runAllTests } from "../e2e/runner";
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
import {
  email_verification_tokens,
  oauth_accounts,
  password_reset_tokens,
  sessions,
  users,
} from "../../src/lib/schema/db";
import {
  consumeVerificationToken,
  createUser,
  findUserByEmail,
  findUserByGoogleId,
  isGoogleOnlyAccount,
  isUniqueViolation,
  issuePasswordResetLink,
  issueVerificationLink,
  linkGoogleAccount,
  peekPasswordResetToken,
  resetPasswordWithToken,
} from "../../src/lib/auth/accounts";
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
import { hashToken, issueToken, newId } from "../../src/lib/auth/tokens";

/** Namespaced so cleanup can never touch a real account. */
const RUN_ID = Date.now().toString(36);
const LOCAL_PART = `authtest-${RUN_ID}`;
const BASE_EMAIL = `${LOCAL_PART}@gmail.com`;

function extractToken(devLink: string | undefined): string {
  if (!devLink) {
    throw new Error(
      "No dev link returned — the mailer tried to actually send. `loadEnv` should have cleared the SMTP variables; check it is still the first import."
    );
  }
  const token = new URL(devLink).searchParams.get("token");
  if (!token) throw new Error(`No token in dev link: ${devLink}`);
  return token;
}

async function cleanup() {
  // Children cascade from users; the LIKE keeps this scoped to this run.
  await db.delete(users).where(like(users.emailKey, `authtest-%`));
}

function registerSuites() {
  describe("Auth DB — one email, one account", () => {
    test("a fresh signup creates a non-guest, unverified account", async () => {
      const user = await createUser({
        email: BASE_EMAIL,
        name: "Auth Test",
        passwordHash: await hashPassword("Sicher1234!"),
      });

      expect(user.isGuest).toBe(false);
      expect(user.emailVerifiedAt).toBe(null);
      expect(user.emailKey).toBe(BASE_EMAIL);
      expect(isGoogleOnlyAccount(user)).toBe(false);
    });

    test("dotted and plus-tagged variants resolve to that same account", async () => {
      const dotted = `${LOCAL_PART.split("").join(".")}@gmail.com`;
      const tagged = `${LOCAL_PART}+throwaway@googlemail.com`;

      const viaDots = await findUserByEmail(dotted);
      const viaTag = await findUserByEmail(tagged);
      const direct = await findUserByEmail(BASE_EMAIL);

      expect(viaDots?.id).toBe(direct?.id);
      expect(viaTag?.id).toBe(direct?.id);
    });

    test("registering an aliased variant is rejected by the unique index", async () => {
      let violated = false;
      try {
        await createUser({
          email: `${LOCAL_PART}+second@gmail.com`,
          passwordHash: await hashPassword("Sicher1234!"),
        });
      } catch (error) {
        violated = isUniqueViolation(error);
      }
      expect(violated).toBe(true);
    });

    test("twenty aliased signup attempts yield exactly one row", async () => {
      for (let index = 0; index < 20; index += 1) {
        try {
          await createUser({
            email: `${LOCAL_PART}+bulk${index}@gmail.com`,
            passwordHash: "x",
          });
        } catch (error) {
          if (!isUniqueViolation(error)) throw error;
        }
      }

      const rows = await db.select().from(users).where(eq(users.emailKey, BASE_EMAIL));
      expect(rows.length).toBe(1);
    });
  });

  describe("Auth DB — confirmation link lifecycle", () => {
    test("issuing a link stores exactly one pending token", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      await issueVerificationLink(user, "en");
      await issueVerificationLink(user, "en");

      const pending = await db
        .select()
        .from(email_verification_tokens)
        .where(eq(email_verification_tokens.userId, user.id));

      // Re-issuing replaces the previous link rather than piling them up.
      expect(pending.length).toBe(1);
    });

    test("opening the link verifies the account", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const mail = await issueVerificationLink(user, "en");
      const token = extractToken(mail.devLink);

      const outcome = await consumeVerificationToken(token);
      expect(outcome.status).toBe("success");

      const refreshed = await findUserByEmail(BASE_EMAIL);
      expect(refreshed?.emailVerifiedAt !== null).toBe(true);
    });

    test("a token cannot be replayed", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const mail = await issueVerificationLink(user, "en");
      const token = extractToken(mail.devLink);

      // Already-verified accounts short-circuit; the point is it never re-succeeds.
      const first = await consumeVerificationToken(token);
      const second = await consumeVerificationToken(token);
      expect(first.status === "success" || first.status === "already_verified").toBe(true);
      expect(second.status === "success").toBe(false);
    });

    test("an unknown token is rejected", async () => {
      const outcome = await consumeVerificationToken(issueToken().token);
      expect(outcome.status).toBe("invalid");
    });

    test("an elapsed token reports as expired, not invalid", async () => {
      const expiredUser = await createUser({
        email: `${LOCAL_PART}-expired@example.com`,
        passwordHash: "x",
      });

      const { token, hash } = issueToken();
      await db.insert(email_verification_tokens).values({
        id: hash,
        userId: expiredUser.id,
        email: expiredUser.email ?? "",
        expiresAt: new Date(Date.now() - 60_000),
      });

      const outcome = await consumeVerificationToken(token);
      expect(outcome.status).toBe("expired");
    });

    test("only the digest is stored — the raw token appears nowhere", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const mail = await issueVerificationLink(user, "en");
      const token = extractToken(mail.devLink);

      const byRaw = await db
        .select()
        .from(email_verification_tokens)
        .where(eq(email_verification_tokens.id, token));
      const byHash = await db
        .select()
        .from(email_verification_tokens)
        .where(eq(email_verification_tokens.id, hashToken(token)));

      expect(byRaw.length).toBe(0);
      expect(byHash.length).toBe(1);
    });
  });

  describe("Auth DB — password reset", () => {
    test("issuing a reset link replaces any earlier pending one", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      await issuePasswordResetLink(user, "en");
      await issuePasswordResetLink(user, "en");

      const pending = await db
        .select()
        .from(password_reset_tokens)
        .where(eq(password_reset_tokens.userId, user.id));

      expect(pending.length).toBe(1);
    });

    test("a live token reads as valid without being spent", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const mail = await issuePasswordResetLink(user, "en");
      const token = extractToken(mail.devLink);

      expect(await peekPasswordResetToken(token)).toBe("valid");
      // Peeking twice must not consume it — the page loads before the form posts.
      expect(await peekPasswordResetToken(token)).toBe("valid");
    });

    test("the new password replaces the old one", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const mail = await issuePasswordResetLink(user, "en");
      const token = extractToken(mail.devLink);

      const outcome = await resetPasswordWithToken(token, "BrandNew5678!");
      expect(outcome.status).toBe("success");

      const updated = await findUserByEmail(BASE_EMAIL);
      expect(await verifyPassword("BrandNew5678!", updated?.passwordHash ?? null)).toBe(true);
      expect(await verifyPassword("Sicher1234!", updated?.passwordHash ?? null)).toBe(false);
    });

    test("resetting signs out every existing session", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      await db.insert(sessions).values({
        id: issueToken().hash,
        userId: user.id,
        expiresAt: new Date(Date.now() + 60_000),
      });
      await db.insert(sessions).values({
        id: issueToken().hash,
        userId: user.id,
        expiresAt: new Date(Date.now() + 60_000),
      });

      const mail = await issuePasswordResetLink(user, "en");
      await resetPasswordWithToken(extractToken(mail.devLink), "AnotherOne901!");

      const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id));
      expect(remaining.length).toBe(0);
    });

    test("a reset token cannot be replayed", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const mail = await issuePasswordResetLink(user, "en");
      const token = extractToken(mail.devLink);

      expect((await resetPasswordWithToken(token, "FirstUse123!")).status).toBe("success");
      expect((await resetPasswordWithToken(token, "SecondUse123!")).status).toBe("invalid");
      expect(await peekPasswordResetToken(token)).toBe("invalid");

      // The second attempt must not have taken effect.
      const updated = await findUserByEmail(BASE_EMAIL);
      expect(await verifyPassword("FirstUse123!", updated?.passwordHash ?? null)).toBe(true);
      expect(await verifyPassword("SecondUse123!", updated?.passwordHash ?? null)).toBe(false);
    });

    test("an elapsed reset token reports expired and changes nothing", async () => {
      const user = await createUser({
        email: `${LOCAL_PART}-reset-expired@example.com`,
        passwordHash: await hashPassword("Original123!"),
      });

      const { token, hash } = issueToken();
      await db.insert(password_reset_tokens).values({
        id: hash,
        userId: user.id,
        expiresAt: new Date(Date.now() - 60_000),
      });

      expect(await peekPasswordResetToken(token)).toBe("expired");
      expect((await resetPasswordWithToken(token, "Replacement123!")).status).toBe("expired");

      const unchanged = await findUserByEmail(user.email ?? "");
      expect(await verifyPassword("Original123!", unchanged?.passwordHash ?? null)).toBe(true);
    });

    test("an unknown reset token is rejected", async () => {
      const token = issueToken().token;
      expect(await peekPasswordResetToken(token)).toBe("invalid");
      expect((await resetPasswordWithToken(token, "Whatever123!")).status).toBe("invalid");
    });

    test("a Google-only account can add a password this way", async () => {
      const googleUser = await createUser({
        email: `${LOCAL_PART}-google-only@example.com`,
        emailVerified: true,
      });
      expect(isGoogleOnlyAccount(googleUser)).toBe(true);

      const mail = await issuePasswordResetLink(googleUser, "en");
      const outcome = await resetPasswordWithToken(extractToken(mail.devLink), "AddedLater123!");
      expect(outcome.status).toBe("success");

      const updated = await findUserByEmail(googleUser.email ?? "");
      expect(isGoogleOnlyAccount(updated!)).toBe(false);
      expect(await verifyPassword("AddedLater123!", updated?.passwordHash ?? null)).toBe(true);
    });

    test("resetting also confirms the address", async () => {
      const pending = await createUser({
        email: `${LOCAL_PART}-unconfirmed@example.com`,
        passwordHash: await hashPassword("Original123!"),
      });
      expect(pending.emailVerifiedAt).toBe(null);

      const mail = await issuePasswordResetLink(pending, "en");
      await resetPasswordWithToken(extractToken(mail.devLink), "Confirmed123!");

      // Opening the emailed link proves inbox control, so the address is verified.
      const updated = await findUserByEmail(pending.email ?? "");
      expect(updated?.emailVerifiedAt !== null).toBe(true);
    });
  });

  describe("Auth DB — Google identity linking", () => {
    test("a Google identity resolves back to the linked account", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const sub = `google-sub-${RUN_ID}`;
      await linkGoogleAccount(user.id, sub);

      const found = await findUserByGoogleId(sub);
      expect(found?.id).toBe(user.id);
    });

    test("linking the same identity twice is idempotent", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const sub = `google-sub-${RUN_ID}`;
      await linkGoogleAccount(user.id, sub);
      await linkGoogleAccount(user.id, sub);

      const rows = await db
        .select()
        .from(oauth_accounts)
        .where(
          and(eq(oauth_accounts.provider, "google"), eq(oauth_accounts.providerAccountId, sub))
        );
      expect(rows.length).toBe(1);
    });

    test("an unknown Google identity resolves to nothing", async () => {
      expect(await findUserByGoogleId(`no-such-sub-${RUN_ID}`)).toBe(null);
    });
  });

  describe("Auth DB — sessions", () => {
    test("a live session is found by its token digest", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const { token, hash } = issueToken();
      await db.insert(sessions).values({
        id: hash,
        userId: user.id,
        expiresAt: new Date(Date.now() + 60_000),
      });

      const rows = await db
        .select({ user: users })
        .from(sessions)
        .innerJoin(users, eq(sessions.userId, users.id))
        .where(and(eq(sessions.id, hashToken(token)), gt(sessions.expiresAt, new Date())));

      expect(rows.length).toBe(1);
      expect(rows[0].user.id).toBe(user.id);
    });

    test("an elapsed session is not returned", async () => {
      const user = await findUserByEmail(BASE_EMAIL);
      if (!user) throw new Error("Fixture user missing");

      const { token, hash } = issueToken();
      await db.insert(sessions).values({
        id: hash,
        userId: user.id,
        expiresAt: new Date(Date.now() - 60_000),
      });

      const rows = await db
        .select()
        .from(sessions)
        .where(and(eq(sessions.id, hashToken(token)), gt(sessions.expiresAt, new Date())));

      expect(rows.length).toBe(0);
    });

    test("deleting the account removes its sessions, tokens and links", async () => {
      const doomed = await createUser({
        email: `${LOCAL_PART}-cascade@example.com`,
        passwordHash: "x",
      });

      await db.insert(sessions).values({
        id: issueToken().hash,
        userId: doomed.id,
        expiresAt: new Date(Date.now() + 60_000),
      });
      await db.insert(email_verification_tokens).values({
        id: issueToken().hash,
        userId: doomed.id,
        email: doomed.email ?? "",
        expiresAt: new Date(Date.now() + 60_000),
      });
      await db.insert(oauth_accounts).values({
        id: newId("oa"),
        userId: doomed.id,
        provider: "google",
        providerAccountId: `cascade-${RUN_ID}`,
      });

      await db.delete(users).where(eq(users.id, doomed.id));

      const leftoverSessions = await db
        .select()
        .from(sessions)
        .where(eq(sessions.userId, doomed.id));
      const leftoverTokens = await db
        .select()
        .from(email_verification_tokens)
        .where(eq(email_verification_tokens.userId, doomed.id));
      const leftoverLinks = await db
        .select()
        .from(oauth_accounts)
        .where(eq(oauth_accounts.userId, doomed.id));

      expect(leftoverSessions.length).toBe(0);
      expect(leftoverTokens.length).toBe(0);
      expect(leftoverLinks.length).toBe(0);
    });
  });
}

async function main() {
  if (!(await isDatabaseAvailable())) {
    console.log(
      [
        "",
        "  SKIPPED — no database reachable at DATABASE_URL.",
        "",
        "  Start one and apply the schema, then re-run:",
        "    docker compose up -d postgres",
        "    npm run db:push",
        "    npm run test:auth",
        "",
      ].join("\n")
    );
    await pool.end();
    return;
  }

  // Leftovers from an interrupted earlier run would break the uniqueness tests.
  await cleanup();
  registerSuites();

  let passed = false;
  try {
    passed = await runAllTests();
  } finally {
    await cleanup();
    await pool.end();
  }

  if (!passed) process.exit(1);
}

main().catch(async (error) => {
  console.error("Auth DB suite crashed:", error);
  await pool.end().catch(() => undefined);
  process.exit(1);
});
