import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Header, NotFoundException, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import { Attachment, BrowseFilesQuery, EntityId, PresignFileUploadRequest } from "@slackwsh/contracts";
import { schema, withTenant } from "@slackwsh/data";
import { requireChannelMembership, requireWorkspaceMembership } from "@slackwsh/messaging";
import { and, desc, eq, ilike, isNotNull, isNull, lt, or } from "drizzle-orm";
import { AuthGuard, type RequestUser } from "../auth/auth.guard";
import { CurrentUser } from "../auth/current-user.decorator";
import { validateFileDeclaration, validateStoredFile } from "./file-validation";
import { ObjectStorageService } from "./object-storage.service";

function safeName(name: string) {
  return basename(name).replace(/[^\p{L}\p{N}._()\- ]+/gu, "_").slice(0, 180) || "file";
}

const LEGACY_UPLOAD_ROOT = resolve(process.cwd(), process.env.UPLOAD_DIR ?? ".uploads");

function wire(row: typeof schema.attachments.$inferSelect): Attachment {
  return {
    id: row.id,
    workspaceId: row.workspaceId,
    channelId: row.channelId,
    messageId: row.messageId,
    uploadedBy: row.uploadedBy,
    name: row.originalName,
    type: row.mimeType,
    size: row.size,
    category: row.category as Attachment["category"],
    createdAt: row.createdAt.toISOString(),
  };
}

@UseGuards(AuthGuard)
@Controller("workspaces/:workspaceId")
export class FilesController {
  constructor(private readonly storage: ObjectStorageService) {}

  @Post("channels/:channelId/files/presign")
  async presign(
    @CurrentUser() user: RequestUser,
    @Param("workspaceId") workspaceIdParam: string,
    @Param("channelId") channelIdParam: string,
    @Body() body: unknown,
  ) {
    const workspaceId = EntityId.parse(workspaceIdParam);
    const channelId = EntityId.parse(channelIdParam);
    const input = PresignFileUploadRequest.parse(body);
    const name = safeName(input.name);
    const rule = validateFileDeclaration(name, input.type, input.size);
    const objectKey = `${workspaceId}/${channelId}/${randomUUID()}${rule.extension}`;

    const attachment = await withTenant({ workspaceId, userId: user.userId }, async (tx) => {
      await requireChannelMembership(tx, channelId, user.userId);
      const [created] = await tx.insert(schema.attachments).values({
        workspaceId,
        channelId,
        uploadedBy: user.userId,
        objectKey,
        originalName: name,
        mimeType: rule.mime,
        size: input.size,
        category: rule.category,
      }).returning();
      if (!created) throw new Error("attachment insert returned no row");
      return created;
    });

    try {
      return {
        attachment: wire(attachment),
        uploadUrl: await this.storage.signedUpload(objectKey, rule.mime),
        uploadHeaders: { "content-type": rule.mime },
        expiresIn: 600,
      };
    } catch (error) {
      await withTenant({ workspaceId, userId: user.userId }, (tx) =>
        tx.delete(schema.attachments).where(eq(schema.attachments.id, attachment.id)),
      ).catch(() => undefined);
      throw error;
    }
  }

  /**
   * Browser PUT-to-S3 is blocked until the bucket has CORS. This path
   * streams the bytes through the API (same object key as presign).
   */
  @Post("channels/:channelId/files/:fileId/bytes")
  async putBytes(
    @CurrentUser() user: RequestUser,
    @Param("workspaceId") workspaceIdParam: string,
    @Param("channelId") channelIdParam: string,
    @Param("fileId") fileIdParam: string,
    @Req() req: { file: () => Promise<{ toBuffer: () => Promise<Buffer> } | undefined> },
  ) {
    const workspaceId = EntityId.parse(workspaceIdParam);
    const channelId = EntityId.parse(channelIdParam);
    const fileId = EntityId.parse(fileIdParam);
    const pending = await withTenant({ workspaceId, userId: user.userId }, async (tx) => {
      await requireChannelMembership(tx, channelId, user.userId);
      const [row] = await tx.select().from(schema.attachments).where(and(
        eq(schema.attachments.id, fileId),
        eq(schema.attachments.channelId, channelId),
        eq(schema.attachments.uploadedBy, user.userId),
        isNull(schema.attachments.deletedAt),
      )).limit(1);
      if (!row) throw new NotFoundException("file upload not found");
      return row;
    });
    if (pending.status === "ready") return { uploaded: true };
    const part = await req.file();
    if (!part) throw new BadRequestException("file is required");
    const bytes = await part.toBuffer();
    if (bytes.length !== pending.size) {
      throw new BadRequestException("uploaded file size does not match the signed request");
    }
    await this.storage.putObject(pending.objectKey, bytes, pending.mimeType);
    return { uploaded: true };
  }

  @Post("channels/:channelId/files/:fileId/complete")
  async complete(
    @CurrentUser() user: RequestUser,
    @Param("workspaceId") workspaceIdParam: string,
    @Param("channelId") channelIdParam: string,
    @Param("fileId") fileIdParam: string,
  ) {
    const workspaceId = EntityId.parse(workspaceIdParam);
    const channelId = EntityId.parse(channelIdParam);
    const fileId = EntityId.parse(fileIdParam);
    const pending = await withTenant({ workspaceId, userId: user.userId }, async (tx) => {
      await requireChannelMembership(tx, channelId, user.userId);
      const [row] = await tx.select().from(schema.attachments).where(and(
        eq(schema.attachments.id, fileId),
        eq(schema.attachments.channelId, channelId),
        eq(schema.attachments.uploadedBy, user.userId),
        isNull(schema.attachments.deletedAt),
      )).limit(1);
      if (!row) throw new NotFoundException("file upload not found");
      return row;
    });
    if (pending.status === "ready") return { attachment: wire(pending) };

    const stored = await this.storage.inspect(pending.objectKey).catch(() => {
      throw new BadRequestException("uploaded object was not found");
    });
    if (stored.size !== pending.size) {
      await this.storage.delete(pending.objectKey).catch(() => undefined);
      throw new BadRequestException("uploaded file size does not match the signed request");
    }
    try {
      validateStoredFile(pending.originalName, pending.mimeType, stored.size, stored.bytes);
    } catch (error) {
      await this.storage.delete(pending.objectKey).catch(() => undefined);
      throw error;
    }

    const updated = await withTenant({ workspaceId, userId: user.userId }, async (tx) => {
      const [row] = await tx.update(schema.attachments).set({ status: "ready", completedAt: new Date() })
        .where(and(eq(schema.attachments.id, fileId), eq(schema.attachments.uploadedBy, user.userId)))
        .returning();
      if (!row) throw new NotFoundException("file upload not found");
      return row;
    });
    return { attachment: wire(updated) };
  }

  @Get("channels/:channelId/files/:fileId/access")
  async access(
    @CurrentUser() user: RequestUser,
    @Param("workspaceId") workspaceIdParam: string,
    @Param("channelId") channelIdParam: string,
    @Param("fileId") fileIdParam: string,
  ) {
    const workspaceId = EntityId.parse(workspaceIdParam);
    const channelId = EntityId.parse(channelIdParam);
    const fileId = EntityId.parse(fileIdParam);
    const file = await withTenant({ workspaceId, userId: user.userId }, async (tx) => {
      await requireChannelMembership(tx, channelId, user.userId);
      const [row] = await tx.select().from(schema.attachments).where(and(
        eq(schema.attachments.id, fileId),
        eq(schema.attachments.channelId, channelId),
        eq(schema.attachments.status, "ready"),
        isNull(schema.attachments.deletedAt),
      )).limit(1);
      if (!row) throw new NotFoundException("file not found");
      return row;
    });
    return {
      url: await this.storage.signedDownload(file.objectKey, file.originalName, file.mimeType),
      expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
    };
  }

  /** Read-only compatibility for files uploaded by the old local-filesystem
   * endpoint. New uploads never use this path; preserving it avoids breaking
   * historical message blocks while teams migrate objects to S3. */
  @Get("channels/:channelId/files/:fileId/:name")
  @Header("cache-control", "private, max-age=3600")
  async legacyDownload(
    @CurrentUser() user: RequestUser,
    @Param("workspaceId") workspaceIdParam: string,
    @Param("channelId") channelIdParam: string,
    @Param("fileId") fileId: string,
    @Param("name") name: string,
    @Res() reply: { header: (name: string, value: string) => void; send: (payload: unknown) => unknown },
  ) {
    const workspaceId = EntityId.parse(workspaceIdParam);
    const channelId = EntityId.parse(channelIdParam);
    await withTenant({ workspaceId, userId: user.userId }, (tx) => requireChannelMembership(tx, channelId, user.userId));
    const path = join(LEGACY_UPLOAD_ROOT, String(workspaceId), String(channelId), basename(fileId));
    const info = await stat(path).catch(() => null);
    if (!info?.isFile()) throw new NotFoundException("file not found");
    reply.header("content-type", "application/octet-stream");
    reply.header("content-disposition", `attachment; filename*=UTF-8''${encodeURIComponent(safeName(name))}`);
    return reply.send(createReadStream(path));
  }

  @Delete("channels/:channelId/files/:fileId")
  async remove(
    @CurrentUser() user: RequestUser,
    @Param("workspaceId") workspaceIdParam: string,
    @Param("channelId") channelIdParam: string,
    @Param("fileId") fileIdParam: string,
  ) {
    const workspaceId = EntityId.parse(workspaceIdParam);
    const channelId = EntityId.parse(channelIdParam);
    const fileId = EntityId.parse(fileIdParam);
    const file = await withTenant({ workspaceId, userId: user.userId }, async (tx) => {
      await requireChannelMembership(tx, channelId, user.userId);
      const membership = await requireWorkspaceMembership(tx, workspaceId, user.userId);
      const [row] = await tx.select().from(schema.attachments).where(and(
        eq(schema.attachments.id, fileId), eq(schema.attachments.channelId, channelId), isNull(schema.attachments.deletedAt),
      )).limit(1);
      if (!row) throw new NotFoundException("file not found");
      let isMessageAuthor = false;
      if (row.messageId != null) {
        const [message] = await tx.select({ authorId: schema.messages.authorId }).from(schema.messages)
          .where(eq(schema.messages.id, row.messageId)).limit(1);
        isMessageAuthor = message?.authorId === user.userId;
      }
      if (row.uploadedBy !== user.userId && !isMessageAuthor && membership.role !== "owner" && membership.role !== "admin") {
        throw new ForbiddenException("you cannot delete this file");
      }
      return row;
    });
    await this.storage.delete(file.objectKey);
    await withTenant({ workspaceId, userId: user.userId }, (tx) =>
      tx.update(schema.attachments).set({ status: "deleted", deletedAt: new Date() }).where(eq(schema.attachments.id, fileId)),
    );
    return { deleted: true };
  }

  @Get("files")
  async browse(
    @CurrentUser() user: RequestUser,
    @Param("workspaceId") workspaceIdParam: string,
    @Query() query: unknown,
  ) {
    const workspaceId = EntityId.parse(workspaceIdParam);
    const input = BrowseFilesQuery.parse(query);
    return withTenant({ workspaceId, userId: user.userId }, async (tx) => {
      await requireWorkspaceMembership(tx, workspaceId, user.userId);
      const conditions = [
        eq(schema.attachments.workspaceId, workspaceId),
        eq(schema.attachments.status, "ready"),
        isNull(schema.attachments.deletedAt),
        isNotNull(schema.attachments.messageId),
        isNull(schema.messages.deletedAt),
        eq(schema.channelMembers.userId, user.userId),
      ];
      if (input.category) conditions.push(eq(schema.attachments.category, input.category));
      if (input.channelId) conditions.push(eq(schema.attachments.channelId, input.channelId));
      if (input.cursor) conditions.push(lt(schema.attachments.id, input.cursor));
      if (input.q?.trim()) {
        const term = `%${input.q.trim()}%`;
        conditions.push(or(
          ilike(schema.attachments.originalName, term),
          ilike(schema.users.name, term),
          ilike(schema.messages.text, term),
        )!);
      }
      const rows = await tx.select({
        attachment: schema.attachments,
        channelName: schema.channels.name,
        channelType: schema.channels.type,
        uploaderName: schema.users.name,
        messageText: schema.messages.text,
      }).from(schema.attachments)
        .innerJoin(schema.channelMembers, eq(schema.channelMembers.channelId, schema.attachments.channelId))
        .innerJoin(schema.channels, eq(schema.channels.id, schema.attachments.channelId))
        .innerJoin(schema.users, eq(schema.users.id, schema.attachments.uploadedBy))
        .leftJoin(schema.messages, eq(schema.messages.id, schema.attachments.messageId))
        .where(and(...conditions))
        .orderBy(desc(schema.attachments.id))
        .limit(input.limit + 1);
      const hasMore = rows.length > input.limit;
      const page = rows.slice(0, input.limit);
      return {
        files: page.map((row) => ({
          ...wire(row.attachment),
          channelName: row.channelName,
          channelType: row.channelType,
          uploaderName: row.uploaderName,
          messageText: row.messageText,
        })),
        nextCursor: hasMore ? page.at(-1)?.attachment.id ?? null : null,
      };
    });
  }
}
