import { Op } from 'sequelize';
import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction';
import ValidationError from '@/shared/errors/validation';
import ForbiddenError from '@/shared/errors/forbidden';
import {
  assertAuthenticatedTenantUser,
  getCampusId,
  getOrganizationId,
  hasGlobalAccess,
} from '@/services/shared/access';
import { canManageAudioFile } from '@/services/shared/audio-access';
import { resolvePagination } from '@/shared/constants/pagination';
import {
  AUDIO_FILE_DEFAULT_KIND,
  isAudioFileKind,
  type AudioFileKind,
} from '@/shared/constants/audio-files';
import type { AudioFiles, AudioRecipe } from '@/db/models/audio_files';
import type { CurrentUser } from '@/db/api/types';

interface AudioFileInput {
  kind?: unknown;
  title?: unknown;
  url?: unknown;
  recipe?: unknown;
}

interface AudioFilesFilter {
  limit?: number | string;
  page?: number | string;
}

interface ResolvedAudioContent {
  kind: AudioFileKind;
  url: string | null;
  recipe: AudioRecipe | null;
}

function requiredString(value: unknown, code?: string): string {
  if (typeof value !== 'string' || value.trim().length === 0) {
    throw new ValidationError(code);
  }
  return value.trim();
}

function isAudioRecipe(value: unknown): value is AudioRecipe {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  const voices = (value as { voices?: unknown }).voices;
  return Array.isArray(voices) && voices.length > 0;
}

/**
 * Resolves the storage content from the request per `kind`: a `recipe` row must
 * carry a `recipe` (and no `url`); a `file`/`url` row must carry a `url` (and no
 * `recipe`). When `kind` is omitted it defaults to a `url`/`file` upload for
 * backward compatibility.
 */
function resolveAudioContent(
  data: AudioFileInput,
  fallbackKind: AudioFileKind = AUDIO_FILE_DEFAULT_KIND,
): ResolvedAudioContent {
  const kind = data.kind === undefined ? fallbackKind : data.kind;
  if (!isAudioFileKind(kind)) {
    throw new ValidationError('audioFileKindInvalid');
  }

  if (kind === 'recipe') {
    if (!isAudioRecipe(data.recipe)) {
      throw new ValidationError('audioFileRecipeRequired');
    }
    return { kind, url: null, recipe: data.recipe };
  }

  return { kind, url: requiredString(data.url, 'audioFileUrlRequired'), recipe: null };
}

function toDto(record: AudioFiles) {
  const plain = record.get({ plain: true });
  return {
    id: plain.id,
    title: plain.title,
    kind: plain.kind,
    url: plain.url,
    recipe: plain.recipe,
    is_default: plain.is_default,
    organizationId: plain.organizationId,
    campusId: plain.campusId,
    createdAt: plain.createdAt,
    updatedAt: plain.updatedAt,
  };
}

class AudioFilesService {
  /** Global defaults (no org) + the caller's org/campus uploads. */
  static async list(filter: AudioFilesFilter, currentUser?: CurrentUser) {
    assertAuthenticatedTenantUser(currentUser);
    const { limit, offset } = resolvePagination(filter.limit, filter.page);

    const where = hasGlobalAccess(currentUser)
      ? {}
      : {
          [Op.or]: [
            { organizationId: null }, // global defaults, visible to everyone
            {
              organizationId: getOrganizationId(currentUser),
              campusId: { [Op.or]: [getCampusId(currentUser), null] },
            },
          ],
        };

    const result = await db.audio_files.findAndCountAll({
      where,
      order: [
        ['is_default', 'desc'],
        ['title', 'asc'],
      ],
      limit,
      offset,
    });

    return { rows: result.rows.map(toDto), count: result.count };
  }

  static async create(data: AudioFileInput, currentUser?: CurrentUser) {
    assertAuthenticatedTenantUser(currentUser);
    const title = requiredString(data.title);
    const { kind, url, recipe } = resolveAudioContent(data);

    const created = await db.audio_files.create({
      title,
      kind,
      url,
      recipe,
      is_default: false,
      organizationId: getOrganizationId(currentUser),
      campusId: getCampusId(currentUser),
      createdById: currentUser?.id ?? null,
      updatedById: currentUser?.id ?? null,
    });

    return toDto(created);
  }

  /** Loads a row the caller is allowed to manage (own org; never a global default). */
  private static async findManageable(
    id: string,
    currentUser?: CurrentUser,
  ): Promise<AudioFiles> {
    const record = await db.audio_files.findByPk(id);
    if (!record) {
      throw new ValidationError('audioFileNotFound');
    }
    // A non-global user may only manage their own organization's uploads (never
    // a global default). Rule lives in `services/shared/audio-access.ts`.
    if (!canManageAudioFile(record, currentUser)) {
      throw new ForbiddenError();
    }
    return record;
  }

  static async update(
    id: string,
    data: AudioFileInput,
    currentUser?: CurrentUser,
  ) {
    assertAuthenticatedTenantUser(currentUser);
    const record = await this.findManageable(id, currentUser);

    return withTransaction(async (transaction) => {
      const payload: Partial<
        Pick<AudioFiles, 'title' | 'kind' | 'url' | 'recipe' | 'updatedById'>
      > = { updatedById: currentUser?.id ?? null };
      if (data.title !== undefined) payload.title = requiredString(data.title);
      // Only re-resolve the stored content when a content field is supplied;
      // a title-only edit leaves kind/url/recipe untouched.
      if (
        data.kind !== undefined ||
        data.url !== undefined ||
        data.recipe !== undefined
      ) {
        const { kind, url, recipe } = resolveAudioContent(data, record.kind);
        payload.kind = kind;
        payload.url = url;
        payload.recipe = recipe;
      }
      await record.update(payload, { transaction });
      return toDto(record);
    });
  }

  static async remove(id: string, currentUser?: CurrentUser) {
    assertAuthenticatedTenantUser(currentUser);
    const record = await this.findManageable(id, currentUser);
    await record.destroy();
  }
}

export default AudioFilesService;
