import {
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { HttpStatus } from '@nestjs/common';
import { In, IsNull, LessThan, Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
import { StorageService } from '../../common/storage/storage.service';
import type { PresignedUpload } from '../../common/storage/storage.types';
import { UserService, type PublicUser } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { DriveFolder } from './entities/drive-folder.entity';
import { DriveFile } from './entities/drive-file.entity';
import { ProjectFolderLink } from './entities/project-folder-link.entity';
import type { AttachmentKind } from '../task/entities/attachment.entity';
import type { DriveFileStatus } from './entities/drive-file.entity';
import {
  DRIVE_MAX_FILE_SIZE,
  buildObjectKey,
  deriveKind,
  isAllowedMime,
} from './drive.config';
import { CreateFolderDto } from './dto/create-folder.dto';
import { PresignUploadDto } from './dto/presign-upload.dto';

// 폴더/파일에 대한 접근 수준 — 높을수록 권한이 강하다.
export type DriveAccess = 'owner' | 'write' | 'read';
const ACCESS_RANK: Record<DriveAccess, number> = {
  read: 1,
  write: 2,
  owner: 3,
};

// --- 응답 형태 ---
export interface DriveFolderResponse {
  id: string;
  name: string;
  parentId: string | null;
  owner: PublicUser | null;
  createdAt: string;
  updatedAt: string;
}

export interface DriveFileResponse {
  id: string;
  name: string;
  size: number;
  mime: string;
  kind: AttachmentKind;
  status: DriveFileStatus;
  folderId: string | null;
  owner: PublicUser | null;
  createdAt: string;
  updatedAt: string;
}

export interface DriveListResponse {
  // 현재 폴더(루트면 null) + 루트→현재 경로(브레드크럼)
  current: DriveFolderResponse | null;
  breadcrumb: { id: string; name: string }[];
  folders: DriveFolderResponse[];
  files: DriveFileResponse[];
}

export interface PresignUploadResponse {
  fileId: string;
  file: DriveFileResponse;
  upload: PresignedUpload;
}

@Injectable()
export class DriveService {
  private readonly logger = new Logger(DriveService.name);

  constructor(
    @InjectRepository(DriveFolder)
    private readonly folderRepo: Repository<DriveFolder>,
    @InjectRepository(DriveFile)
    private readonly fileRepo: Repository<DriveFile>,
    @InjectRepository(ProjectFolderLink)
    private readonly linkRepo: Repository<ProjectFolderLink>,
    @InjectRepository(ProjectMember)
    private readonly memberRepo: Repository<ProjectMember>,
    private readonly storage: StorageService,
    private readonly userService: UserService,
  ) {}

  /* ===================== 조회 ===================== */

  // 드라이브 목록 — folderId 없으면 내 루트, 있으면 해당 폴더(읽기 권한 필요).
  async list(userId: string, folderId?: string): Promise<DriveListResponse> {
    if (!folderId) {
      // 루트 — 내 소유의 최상위 폴더/파일만
      const [folders, files] = await Promise.all([
        this.folderRepo.find({
          where: { owner: { id: userId }, parent: IsNull() },
          relations: { owner: true },
          order: { name: 'ASC' },
        }),
        this.fileRepo.find({
          where: { owner: { id: userId }, folder: IsNull(), status: 'active' },
          relations: { owner: true },
          order: { name: 'ASC' },
        }),
      ]);
      return {
        current: null,
        breadcrumb: [],
        folders: folders.map((f) => this.toFolderResponse(f, null)),
        files: files.map((f) => this.toFileResponse(f, null)),
      };
    }

    const folder = await this.getFolderForAccess(userId, folderId, 'read');
    const [folders, files, breadcrumb] = await Promise.all([
      this.folderRepo.find({
        where: { parent: { id: folder.id } },
        relations: { owner: true },
        order: { name: 'ASC' },
      }),
      this.fileRepo.find({
        where: { folder: { id: folder.id }, status: 'active' },
        relations: { owner: true },
        order: { name: 'ASC' },
      }),
      this.buildBreadcrumb(folder),
    ]);
    return {
      current: this.toFolderResponse(folder, folder.parent?.id ?? null),
      breadcrumb,
      folders: folders.map((f) => this.toFolderResponse(f, folder.id)),
      files: files.map((f) => this.toFileResponse(f, folder.id)),
    };
  }

  /* ===================== 폴더 CRUD ===================== */

  // 폴더 생성 — 루트면 내 드라이브, 하위면 상위 폴더에 쓰기 권한 필요.
  async createFolder(
    userId: string,
    dto: CreateFolderDto,
  ): Promise<DriveFolderResponse> {
    let parent: DriveFolder | null = null;
    if (dto.parentId) {
      parent = await this.getFolderForAccess(userId, dto.parentId, 'write');
    }
    const owner = await this.getUserOrThrow(userId);
    // 1차 저장으로 id 확보 → materialized path 구성 후 재저장
    const created = await this.folderRepo.save(
      this.folderRepo.create({
        owner,
        parent: parent ? { id: parent.id } : null,
        name: dto.name,
        path: '',
      }),
    );
    created.path = `${parent ? parent.path : ''}${created.id}/`;
    await this.folderRepo.save(created);
    return this.toFolderResponse({ ...created, owner }, parent?.id ?? null);
  }

  // 폴더 이름 변경 — 쓰기 권한 필요.
  async renameFolder(
    userId: string,
    folderId: string,
    name: string,
  ): Promise<DriveFolderResponse> {
    const folder = await this.getFolderForAccess(userId, folderId, 'write');
    folder.name = name;
    await this.folderRepo.save(folder);
    return this.toFolderResponse(folder, folder.parent?.id ?? null);
  }

  // 폴더 삭제 — 쓰기 권한 필요. 하위 트리의 객체 스토리지 파일까지 정리.
  async deleteFolder(userId: string, folderId: string): Promise<void> {
    const folder = await this.getFolderForAccess(userId, folderId, 'write');
    // 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨)
    const descendantFiles = await this.fileRepo
      .createQueryBuilder('f')
      .innerJoin('f.folder', 'fd')
      .where('fd.path LIKE :prefix', { prefix: `${folder.path}%` })
      .getMany();
    await this.folderRepo.remove(folder);
    await this.deleteObjects(descendantFiles.map((f) => f.objectKey));
  }

  /* ===================== 파일 업로드(Presigned) ===================== */

  // ① 업로드용 presigned URL 발급 + pending 메타 생성.
  async presignUpload(
    userId: string,
    dto: PresignUploadDto,
  ): Promise<PresignUploadResponse> {
    if (!isAllowedMime(dto.contentType)) {
      throw new BusinessException(
        'VAL_001',
        '업로드할 수 없는 파일 형식입니다.',
        HttpStatus.BAD_REQUEST,
      );
    }
    let folder: DriveFolder | null = null;
    if (dto.folderId) {
      folder = await this.getFolderForAccess(userId, dto.folderId, 'write');
    }
    const owner = await this.getUserOrThrow(userId);
    const objectKey = buildObjectKey(userId, dto.filename);
    const saved = await this.fileRepo.save(
      this.fileRepo.create({
        owner,
        folder: folder ? { id: folder.id } : null,
        name: dto.filename,
        bucket: this.storage.bucket,
        objectKey,
        size: dto.size,
        mime: dto.contentType,
        kind: deriveKind(dto.contentType, dto.filename),
        status: 'pending',
      }),
    );
    const upload = await this.storage.presignUpload({
      key: objectKey,
      contentType: dto.contentType,
    });
    return {
      fileId: saved.id,
      file: this.toFileResponse({ ...saved, owner }, folder?.id ?? null),
      upload,
    };
  }

  // ③ 업로드 확정 — HEAD 로 실제 크기/타입 재검증 후 active 전환. 발급자 본인만 호출.
  async completeUpload(
    userId: string,
    fileId: string,
  ): Promise<DriveFileResponse> {
    const file = await this.fileRepo.findOne({
      where: { id: fileId },
      relations: { owner: true, folder: true },
    });
    if (!file) {
      throw new NotFoundException();
    }
    if (file.owner?.id !== userId) {
      throw new ForbiddenException();
    }
    if (file.status === 'active') {
      // 멱등 — 이미 확정된 경우 현재 상태 반환
      return this.toFileResponse(file, file.folder?.id ?? null);
    }

    const head = await this.storage.headObject(file.objectKey);
    if (!head) {
      throw new BusinessException(
        'BIZ_001',
        '업로드가 완료되지 않았습니다. 파일 전송 후 다시 시도해 주세요.',
        HttpStatus.BAD_REQUEST,
      );
    }
    // 실제 저장된 객체 기준으로 검증(클라이언트 신고값을 신뢰하지 않음)
    if (head.size > DRIVE_MAX_FILE_SIZE) {
      await this.discardFile(file);
      throw new BusinessException(
        'VAL_001',
        '허용 최대 파일 크기를 초과했습니다.',
        HttpStatus.BAD_REQUEST,
      );
    }
    if (head.contentType && !isAllowedMime(head.contentType)) {
      await this.discardFile(file);
      throw new BusinessException(
        'VAL_001',
        '업로드할 수 없는 파일 형식입니다.',
        HttpStatus.BAD_REQUEST,
      );
    }

    file.size = head.size;
    file.status = 'active';
    await this.fileRepo.save(file);
    return this.toFileResponse(file, file.folder?.id ?? null);
  }

  /* ===================== 파일 CRUD ===================== */

  // 파일 이름 변경 — 쓰기 권한 필요.
  async renameFile(
    userId: string,
    fileId: string,
    name: string,
  ): Promise<DriveFileResponse> {
    const file = await this.getFileForAccess(userId, fileId, 'write');
    file.name = name;
    await this.fileRepo.save(file);
    return this.toFileResponse(file, file.folder?.id ?? null);
  }

  // 파일 삭제 — 쓰기 권한 필요. 객체 스토리지 파일도 정리.
  async deleteFile(userId: string, fileId: string): Promise<void> {
    const file = await this.getFileForAccess(userId, fileId, 'write');
    await this.fileRepo.remove(file);
    await this.deleteObjects([file.objectKey]);
  }

  // 다운로드용 단기 presigned GET URL 발급 — 읽기 권한 필요.
  async getDownloadUrl(
    userId: string,
    fileId: string,
  ): Promise<{ url: string }> {
    const file = await this.getFileForAccess(userId, fileId, 'read');
    if (file.status !== 'active') {
      // 아직 업로드 확정 전 — 다운로드 불가
      throw new NotFoundException();
    }
    const url = await this.storage.presignDownload({
      key: file.objectKey,
      filename: file.name,
      contentType: file.mime,
    });
    return { url };
  }

  /* ===================== 권한 해석 ===================== */

  // 폴더를 로딩하고 요구 접근 수준을 만족하는지 검사(불충분 시 403, 미존재 404).
  private async getFolderForAccess(
    userId: string,
    folderId: string,
    required: DriveAccess,
  ): Promise<DriveFolder> {
    const folder = await this.folderRepo.findOne({
      where: { id: folderId },
      relations: { owner: true, parent: true },
    });
    if (!folder) {
      throw new NotFoundException();
    }
    const access = await this.resolveFolderAccess(userId, folder);
    this.assertAccess(access, required);
    return folder;
  }

  // 파일을 로딩하고 요구 접근 수준을 만족하는지 검사.
  private async getFileForAccess(
    userId: string,
    fileId: string,
    required: DriveAccess,
  ): Promise<DriveFile> {
    const file = await this.fileRepo.findOne({
      where: { id: fileId },
      relations: { owner: true, folder: true },
    });
    if (!file) {
      throw new NotFoundException();
    }
    const access = await this.resolveFileAccess(userId, file);
    this.assertAccess(access, required);
    return file;
  }

  // 폴더 접근 수준 산출 — 소유자 / 연동(쓰기·읽기) / 없음.
  private async resolveFolderAccess(
    userId: string,
    folder: DriveFolder,
  ): Promise<DriveAccess | null> {
    if (folder.owner?.id === userId) return 'owner';
    // 자기 + 조상 폴더 중 하나라도 내가 멤버인 프로젝트에 연동되어 있으면 그 권한을 적용
    return this.resolveLinkedAccess(this.parseFolderPath(folder.path), userId);
  }

  // 파일 접근 수준 산출 — 소유자 / 소속 폴더의 연동 권한 / 없음(루트 파일은 소유자 전용).
  private async resolveFileAccess(
    userId: string,
    file: DriveFile,
  ): Promise<DriveAccess | null> {
    if (file.owner?.id === userId) return 'owner';
    if (!file.folder) return null;
    return this.resolveLinkedAccess(
      this.parseFolderPath(file.folder.path),
      userId,
    );
  }

  // 폴더 id 집합(자기+조상)에 대해, 내가 멤버인 프로젝트로의 연동 권한 중 최상위를 반환.
  private async resolveLinkedAccess(
    folderIds: string[],
    userId: string,
  ): Promise<DriveAccess | null> {
    if (folderIds.length === 0) return null;
    const links = await this.linkRepo.find({
      where: { folder: { id: In(folderIds) } },
      relations: { project: true },
    });
    if (links.length === 0) return null;
    const projectIds = [...new Set(links.map((l) => l.project.id))];
    const memberships = await this.memberRepo.find({
      where: { project: { id: In(projectIds) }, user: { id: userId } },
      relations: { project: true },
    });
    const memberProjectIds = new Set(memberships.map((m) => m.project.id));
    const accessible = links.filter((l) => memberProjectIds.has(l.project.id));
    if (accessible.length === 0) return null;
    return accessible.some((l) => l.permission === 'read-write')
      ? 'write'
      : 'read';
  }

  // 접근 수준이 요구치 미만이면 403
  private assertAccess(
    access: DriveAccess | null,
    required: DriveAccess,
  ): void {
    if (!access || ACCESS_RANK[access] < ACCESS_RANK[required]) {
      throw new ForbiddenException();
    }
  }

  // "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
  private parseFolderPath(path: string): string[] {
    return path.split('/').filter(Boolean);
  }

  // 브레드크럼(루트→현재) — path 의 폴더들을 이름과 함께 순서대로 반환
  private async buildBreadcrumb(
    folder: DriveFolder,
  ): Promise<{ id: string; name: string }[]> {
    const ids = this.parseFolderPath(folder.path);
    if (ids.length === 0) return [];
    const rows = await this.folderRepo.find({
      where: { id: In(ids) },
      select: { id: true, name: true },
    });
    const byId = new Map(rows.map((r) => [r.id, r.name]));
    return ids
      .filter((id) => byId.has(id))
      .map((id) => ({ id, name: byId.get(id) as string }));
  }

  /* ===================== 내부 유틸 ===================== */

  private async getUserOrThrow(userId: string): Promise<User> {
    const user = await this.userService.findById(userId);
    if (!user) {
      throw new NotFoundException();
    }
    return user;
  }

  // 검증 실패한 업로드 폐기 — 객체 + 메타 행 제거
  private async discardFile(file: DriveFile): Promise<void> {
    await this.fileRepo.remove(file);
    await this.deleteObjects([file.objectKey]);
  }

  // 객체 스토리지 파일 삭제(베스트 에포트 — 실패해도 본 흐름을 막지 않음)
  private async deleteObjects(keys: string[]): Promise<void> {
    for (const key of keys) {
      try {
        await this.storage.deleteObject(key);
      } catch (err: unknown) {
        this.logger.warn(
          `객체 삭제 실패(무시): ${key} — ${err instanceof Error ? err.message : String(err)}`,
        );
      }
    }
  }

  // 미완료(pending) 업로드 정리 — 1시간 이상 확정되지 않은 행/객체 제거
  @Cron(CronExpression.EVERY_HOUR)
  async cleanupStalePending(): Promise<void> {
    try {
      const cutoff = new Date(Date.now() - 60 * 60 * 1000);
      const stale = await this.fileRepo.find({
        where: { status: 'pending', createdAt: LessThan(cutoff) },
      });
      if (stale.length === 0) return;
      await this.deleteObjects(stale.map((f) => f.objectKey));
      await this.fileRepo.remove(stale);
      this.logger.log(`미완료 업로드 ${stale.length}건 정리`);
    } catch (err: unknown) {
      this.logger.warn(
        `미완료 업로드 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
  }

  /* ===================== 매핑 ===================== */

  private toFolderResponse(
    folder: DriveFolder,
    parentId: string | null,
  ): DriveFolderResponse {
    return {
      id: folder.id,
      name: folder.name,
      parentId,
      owner: folder.owner ? UserService.toPublic(folder.owner) : null,
      createdAt: folder.createdAt.toISOString(),
      updatedAt: folder.updatedAt.toISOString(),
    };
  }

  private toFileResponse(
    file: DriveFile,
    folderId: string | null,
  ): DriveFileResponse {
    return {
      id: file.id,
      name: file.name,
      size: file.size,
      mime: file.mime,
      kind: file.kind,
      status: file.status,
      folderId,
      owner: file.owner ? UserService.toPublic(file.owner) : null,
      createdAt: file.createdAt.toISOString(),
      updatedAt: file.updatedAt.toISOString(),
    };
  }
}
