import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, type DeepPartial } from 'typeorm';
import { extname } from 'node:path';
import { TenantEntity } from '../../entities/tenant.entity';
import { StorageService } from '../storage/storage.service';
import type { BaseMediaEntity } from './base-media.entity';
import type { MediaItemDto } from './media-item.dto';
import type { MediaRepository } from './media.repository';
import {
  DELETED_MEDIA_ROOT,
  isMediaFolder,
  MEDIA_POLICIES,
  type MediaFolder,
  type MediaType,
} from './media.constants';

const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.avi']);

/** A validated media item, ready to be written to a `{module}_media` table. */
export interface PreparedMedia {
  url: string;
  media_type: MediaType;
  filename: string;
  is_cover: boolean;
  sort_order: number;
}

export interface MediaView {
  id: string;
  url: string;
  media_type: MediaType;
  filename: string;
  is_cover: boolean;
  sort_order: number;
}

interface TenantScoped {
  currentTenantId(): string;
}

/**
 * Shared logic for the per-module media tables.
 *
 * Callers validate with {@link prepare} BEFORE saving the parent record
 * (so a bad payload never leaves a half-saved row), then persist with
 * {@link sync}. Removed items are soft-deleted and their file is moved
 * to `deleted/{subdomain}/{folder}/{filename}`.
 */
@Injectable()
export class MediaService {
  private readonly logger = new Logger(MediaService.name);

  constructor(
    private readonly storage: StorageService,
    @InjectRepository(TenantEntity)
    private readonly tenantRepo: Repository<TenantEntity>,
  ) {}

  async list<T extends BaseMediaEntity>(
    repo: MediaRepository<T>,
    parentId: string,
  ): Promise<MediaView[]> {
    const rows = await repo.findForParent(parentId);
    return rows.map((r) => ({
      id: r.id,
      url: r.url,
      media_type: r.media_type,
      filename: r.filename,
      is_cover: r.is_cover,
      sort_order: r.sort_order,
    }));
  }

  /**
   * Validate an incoming media list against the module policy and the
   * caller's tenant. Returns undefined when the payload omitted `media`
   * (PATCH without media → leave existing rows untouched).
   */
  async prepare<T extends BaseMediaEntity>(
    repo: MediaRepository<T>,
    folder: MediaFolder,
    items: MediaItemDto[] | undefined,
    parentId?: string,
  ): Promise<PreparedMedia[] | undefined> {
    if (items === undefined) return undefined;

    const subdomain = await this.subdomainOf(repo);
    const policy = MEDIA_POLICIES[folder];
    const seen = new Set<string>();
    const prepared: PreparedMedia[] = [];

    for (const item of items) {
      const key = this.ownedKey(item.url, subdomain, folder);
      const url = this.storage.url(key);
      if (seen.has(url)) continue;
      seen.add(url);

      const filename = key.split('/')[2];
      const media_type = this.mediaTypeOf(filename);
      if (media_type === 'video' && !policy.allowVideo) {
        throw new BadRequestException('Videos are not allowed for this record');
      }
      if (item.is_cover && media_type !== 'image') {
        throw new BadRequestException('Only a photo can be the cover');
      }

      const owner = await repo.findByUrl(url);
      if (owner && repo.parentIdOf(owner) !== parentId) {
        throw new BadRequestException('A media file is already attached to another record');
      }
      if (!owner && !(await this.storage.exists(key))) {
        throw new BadRequestException('A media file could not be found — please upload it again');
      }

      prepared.push({
        url,
        media_type,
        filename,
        is_cover: item.is_cover === true,
        sort_order: prepared.length,
      });
    }

    const images = prepared.filter((m) => m.media_type === 'image').length;
    const videos = prepared.length - images;
    if (images > policy.maxImages) {
      throw new BadRequestException(`A maximum of ${policy.maxImages} photos is allowed`);
    }
    if (videos > policy.maxVideos) {
      throw new BadRequestException(`A maximum of ${policy.maxVideos} videos is allowed`);
    }

    const covers = prepared.filter((m) => m.is_cover).length;
    if (covers > 1) {
      throw new BadRequestException('Only one photo can be the cover');
    }
    if (covers === 0) {
      const firstImage = prepared.find((m) => m.media_type === 'image');
      if (firstImage) firstImage.is_cover = true;
    }

    return prepared;
  }

  /** Persist a {@link prepare}d list for `parentId`. No-op when `prepared` is undefined. */
  async sync<T extends BaseMediaEntity>(
    repo: MediaRepository<T>,
    parentId: string,
    prepared: PreparedMedia[] | undefined,
  ): Promise<void> {
    if (prepared === undefined) return;

    const existing = await repo.findForParent(parentId);
    const existingByUrl = new Map(existing.map((row) => [row.url, row]));
    const keepUrls = new Set(prepared.map((m) => m.url));

    for (const row of existing) {
      if (keepUrls.has(row.url)) continue;
      await repo.softDelete(row.id);
      await this.trashUrl(row.url);
    }

    const toInsert: DeepPartial<T>[] = [];
    for (const item of prepared) {
      const row = existingByUrl.get(item.url);
      if (!row) {
        toInsert.push({ ...item, [repo.parentColumn]: parentId } as DeepPartial<T>);
        continue;
      }
      if (row.is_cover !== item.is_cover || row.sort_order !== item.sort_order) {
        await repo.update(row.id, {
          is_cover: item.is_cover,
          sort_order: item.sort_order,
        } as Partial<T>);
      }
    }
    if (toInsert.length) await repo.saveMany(toInsert);
  }

  /**
   * Validate a single-photo URL field (e.g. supplier `photo_url`).
   * Empty → null. A URL equal to `currentUrl` is accepted unchanged so
   * legacy values (uploaded before module folders existed) keep working.
   */
  async prepareSinglePhoto(
    scope: TenantScoped,
    folder: MediaFolder,
    url: string | null | undefined,
    currentUrl?: string | null,
  ): Promise<string | null> {
    if (!url) return null;
    if (currentUrl && url === currentUrl) return currentUrl;

    const subdomain = await this.subdomainOf(scope);
    const key = this.ownedKey(url, subdomain, folder);
    if (this.mediaTypeOf(key.split('/')[2]) !== 'image') {
      throw new BadRequestException('Only a photo can be used here');
    }
    if (!(await this.storage.exists(key))) {
      throw new BadRequestException('The photo could not be found — please upload it again');
    }
    return this.storage.url(key);
  }

  /**
   * Move a module media file into the trashed-media tree. Ignores URLs
   * that are not module media (legacy / external) and never throws —
   * the DB row is the source of truth, a stray file is only clutter.
   */
  async trashUrl(url: string | null | undefined): Promise<void> {
    if (!url) return;
    const key = this.storage.keyFromUrl(url);
    if (!key) return;
    const parts = key.split('/');
    if (parts.length !== 3 || !isMediaFolder(parts[1])) return;

    const [subdomain, folder, filename] = parts;
    try {
      await this.storage.move(key, `${DELETED_MEDIA_ROOT}/${subdomain}/${folder}/${filename}`);
    } catch (err: unknown) {
      this.logger.warn(`Could not move ${key} to trash: ${(err as Error)?.message}`);
    }
  }

  // ── internals ──────────────────────────────────────────────────────

  private ownedKey(url: string, subdomain: string, folder: MediaFolder): string {
    const key = this.storage.keyFromUrl(url);
    const parts = key?.split('/') ?? [];
    if (!key || parts.length !== 3 || parts[0] !== subdomain || parts[1] !== folder) {
      throw new BadRequestException('Invalid media file — please upload it again');
    }
    return key;
  }

  private mediaTypeOf(filename: string): MediaType {
    const ext = extname(filename).toLowerCase();
    if (IMAGE_EXTENSIONS.has(ext)) return 'image';
    if (VIDEO_EXTENSIONS.has(ext)) return 'video';
    throw new BadRequestException('Only photos and videos can be attached');
  }

  private async subdomainOf(scope: TenantScoped): Promise<string> {
    const tenant = await this.tenantRepo.findOne({
      where: { id: scope.currentTenantId() },
      select: { subdomain: true },
    });
    return tenant?.subdomain || 'default';
  }
}
