import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import * as argon2 from "argon2";
import { and, eq, isNull } from "drizzle-orm";
import { getDb, schema } from "@slackwsh/data";
import { AuthSession, DeviceSession } from "@slackwsh/contracts";
import { EmailService } from "./email.service";
import { JwtService } from "./jwt.service";
import { generateToken, hashToken } from "./token.util";

const REFRESH_TOKEN_TTL_DAYS = Number(process.env.REFRESH_TOKEN_TTL_DAYS ?? 30);
const EMAIL_VERIFY_TTL_HOURS = 24;
const PASSWORD_RESET_TTL_HOURS = 1;
const MAGIC_LINK_TTL_MINUTES = 15;
const USERNAME_UNIQUE_VIOLATION = "23505";

function baseUsernameFrom(email: string): string {
  const local = email.split("@")[0] ?? "user";
  const sanitized = local.toLowerCase().replace(/[^a-z0-9_]/g, "");
  return (sanitized || "user").slice(0, 24);
}

export interface RequestMeta {
  deviceLabel?: string;
  ip?: string;
  userAgent?: string;
}

@Injectable()
export class AuthService {
  constructor(
    private readonly email: EmailService,
    private readonly jwt: JwtService,
  ) {}

  async signup(input: { email: string; password: string; name: string }) {
    const db = getDb();
    const existing = await db.select().from(schema.users).where(eq(schema.users.email, input.email)).limit(1);
    if (existing.length > 0) {
      throw new BadRequestException("an account with this email already exists");
    }

    const passwordHash = await argon2.hash(input.password, { type: argon2.argon2id });
    const user = await this.insertUserWithUniqueUsername(db, {
      email: input.email,
      passwordHash,
      name: input.name,
    });

    const token = await this.issueToken(user.id, "email_verify", EMAIL_VERIFY_TTL_HOURS * 3600, (token) =>
      this.email.sendVerificationEmail(user.email, token),
    );

    return { userId: user.id, verificationLink: `/verify-email?token=${token}` };
  }

  async verifyEmail(token: string): Promise<void> {
    const userId = await this.consumeToken(token, "email_verify");
    const db = getDb();
    await db.update(schema.users).set({ emailVerifiedAt: new Date() }).where(eq(schema.users.id, userId));
  }

  async login(input: { email: string; password: string }, meta: RequestMeta): Promise<AuthSession> {
    const db = getDb();
    const [user] = await db.select().from(schema.users).where(eq(schema.users.email, input.email)).limit(1);
    if (!user) throw new UnauthorizedException("invalid email or password");

    const valid = await argon2.verify(user.passwordHash, input.password).catch(() => false);
    if (!valid) throw new UnauthorizedException("invalid email or password");

    if (!user.emailVerifiedAt) {
      throw new UnauthorizedException("email not verified");
    }

    const tokens = await this.createSession(user.id, meta);
    return {
      user: this.toPublicUser(user),
      tokens,
    };
  }

  async refresh(refreshToken: string, meta: RequestMeta) {
    const db = getDb();
    const presentedHash = hashToken(refreshToken);
    const [session] = await db.select().from(schema.sessions).where(eq(schema.sessions.refreshTokenHash, presentedHash)).limit(1);

    if (!session) throw new UnauthorizedException("invalid refresh token");

    if (session.revokedAt) {
      // Reuse of an already-rotated-away token: treat as compromise and
      // revoke the whole chain rather than just this session (§7 security).
      await this.revokeChain(session.userId);
      throw new UnauthorizedException("refresh token reuse detected — all sessions revoked");
    }

    if (session.expiresAt.getTime() < Date.now()) {
      throw new UnauthorizedException("refresh token expired");
    }

    await db.update(schema.sessions).set({ revokedAt: new Date() }).where(eq(schema.sessions.id, session.id));

    const tokens = await this.createSession(session.userId, meta, session.id);
    return { tokens };
  }

  async logout(refreshToken: string): Promise<void> {
    const db = getDb();
    const presentedHash = hashToken(refreshToken);
    await db.update(schema.sessions).set({ revokedAt: new Date() }).where(eq(schema.sessions.refreshTokenHash, presentedHash));
  }

  async listDevices(userId: number, currentRefreshTokenHash?: string): Promise<DeviceSession[]> {
    const db = getDb();
    const rows = await db
      .select()
      .from(schema.sessions)
      .where(and(eq(schema.sessions.userId, userId), isNull(schema.sessions.revokedAt)));

    return rows.map((row) => ({
      id: row.id,
      deviceLabel: row.deviceLabel,
      ip: row.ip,
      userAgent: row.userAgent,
      createdAt: row.createdAt.toISOString(),
      isCurrent: row.refreshTokenHash === currentRefreshTokenHash,
    }));
  }

  async revokeSession(userId: number, sessionId: number): Promise<void> {
    const db = getDb();
    await db
      .update(schema.sessions)
      .set({ revokedAt: new Date() })
      .where(and(eq(schema.sessions.id, sessionId), eq(schema.sessions.userId, userId)));
  }

  async requestPasswordReset(emailAddress: string): Promise<void> {
    const db = getDb();
    const [user] = await db.select().from(schema.users).where(eq(schema.users.email, emailAddress)).limit(1);
    // Never reveal whether the email exists — issue the token only if it does.
    if (!user) return;
    await this.issueToken(user.id, "password_reset", PASSWORD_RESET_TTL_HOURS * 3600, (token) =>
      this.email.sendPasswordResetEmail(user.email, token),
    );
  }

  async confirmPasswordReset(token: string, newPassword: string): Promise<void> {
    const userId = await this.consumeToken(token, "password_reset");
    const db = getDb();
    const passwordHash = await argon2.hash(newPassword, { type: argon2.argon2id });
    await db.update(schema.users).set({ passwordHash }).where(eq(schema.users.id, userId));
    // A password reset invalidates every existing session, not just the
    // credential — otherwise a stolen session survives the "fix".
    await this.revokeChain(userId);
  }

  async requestMagicLink(emailAddress: string): Promise<void> {
    const db = getDb();
    const [user] = await db.select().from(schema.users).where(eq(schema.users.email, emailAddress)).limit(1);
    if (!user) return;
    await this.issueToken(user.id, "magic_link", MAGIC_LINK_TTL_MINUTES * 60, (token) =>
      this.email.sendMagicLinkEmail(user.email, token),
    );
  }

  async consumeMagicLink(token: string, meta: RequestMeta): Promise<AuthSession> {
    const userId = await this.consumeToken(token, "magic_link");
    const db = getDb();
    const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
    if (!user) throw new UnauthorizedException("account no longer exists");
    if (!user.emailVerifiedAt) {
      await db.update(schema.users).set({ emailVerifiedAt: new Date() }).where(eq(schema.users.id, userId));
    }
    const tokens = await this.createSession(userId, meta);
    return { user: this.toPublicUser(user), tokens };
  }

  // ---- internals -------------------------------------------------------

  /**
   * Username is auto-generated from the email local-part (never exposed as
   * a signup field yet — this just needs to exist and be unique so
   * @mention resolution, added after the fact, has something to join on).
   * A conflict on retry can't be recovered inside the same failed insert
   * (see the postgres.js SAVEPOINT lesson in libs/messaging/sendMessage) —
   * each attempt here is its own fresh statement, which is simpler still
   * since there's nothing else in the same transaction to roll back.
   */
  private async insertUserWithUniqueUsername(
    db: ReturnType<typeof getDb>,
    input: { email: string; passwordHash: string; name: string },
  ): Promise<typeof schema.users.$inferSelect> {
    const base = baseUsernameFrom(input.email);
    for (let attempt = 0; attempt < 5; attempt++) {
      const username = attempt === 0 ? base : `${base}${Math.floor(1000 + Math.random() * 9000)}`;
      try {
        const [user] = await db.insert(schema.users).values({ ...input, username }).returning();
        if (!user) throw new Error("user insert returned no row");
        return user;
      } catch (err: any) {
        if (err?.code !== USERNAME_UNIQUE_VIOLATION || attempt === 4) throw err;
      }
    }
    throw new Error("unreachable");
  }

  private async createSession(userId: number, meta: RequestMeta, rotatedFrom?: number) {
    const db = getDb();
    const refreshToken = generateToken();
    const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 3600 * 1000);

    await db.insert(schema.sessions).values({
      userId,
      refreshTokenHash: hashToken(refreshToken),
      deviceLabel: meta.deviceLabel ?? null,
      ip: meta.ip ?? null,
      userAgent: meta.userAgent ?? null,
      rotatedFrom: rotatedFrom ?? null,
      expiresAt,
    });

    return {
      accessToken: this.jwt.sign(userId),
      refreshToken,
      expiresIn: this.jwt.accessTokenTtlSeconds,
    };
  }

  private async revokeChain(userId: number): Promise<void> {
    const db = getDb();
    await db
      .update(schema.sessions)
      .set({ revokedAt: new Date() })
      .where(and(eq(schema.sessions.userId, userId), isNull(schema.sessions.revokedAt)));
  }

  private async issueToken(
    userId: number,
    purpose: "email_verify" | "password_reset" | "magic_link",
    ttlSeconds: number,
    deliver: (token: string) => Promise<void>,
  ): Promise<string> {
    const db = getDb();
    const token = generateToken();
    await db.insert(schema.authTokens).values({
      userId,
      purpose,
      tokenHash: hashToken(token),
      expiresAt: new Date(Date.now() + ttlSeconds * 1000),
    });
    await deliver(token);
    return token;
  }

  private async consumeToken(
    token: string,
    purpose: "email_verify" | "password_reset" | "magic_link",
  ): Promise<number> {
    const db = getDb();
    const tokenHash = hashToken(token);
    const [row] = await db
      .select()
      .from(schema.authTokens)
      .where(and(eq(schema.authTokens.tokenHash, tokenHash), eq(schema.authTokens.purpose, purpose)))
      .limit(1);

    if (!row) throw new BadRequestException("invalid token");
    if (row.usedAt) throw new BadRequestException("token already used");
    if (row.expiresAt.getTime() < Date.now()) throw new BadRequestException("token expired");

    await db.update(schema.authTokens).set({ usedAt: new Date() }).where(eq(schema.authTokens.id, row.id));
    return row.userId;
  }

  private toPublicUser(user: typeof schema.users.$inferSelect) {
    return {
      id: user.id,
      email: user.email,
      emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null,
      name: user.name,
      username: user.username,
      avatarUrl: user.avatarUrl,
      tz: user.tz,
      createdAt: user.createdAt.toISOString(),
    };
  }
}
