import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';

type PhotoPrismSessionBody = {
  baseUrl?: string;
  username?: string;
  password?: string;
};

type PhotoPrismFavoriteBody = {
  baseUrl?: string;
  token?: string;
  uid?: string;
  favorite?: boolean;
};

type PhotoPrismAlbumMembershipBody = {
  baseUrl?: string;
  token?: string;
  uid?: string;
  albumUid?: string;
};

type PhotoPrismQuery = {
  baseUrl?: string;
  token?: string;
  count?: string;
  offset?: string;
  order?: string;
  q?: string;
  albumUid?: string;
  hash?: string;
  uid?: string;
  previewToken?: string;
  downloadToken?: string;
  size?: string;
};

function normalizeBaseUrl(raw: string | undefined) {
  const value = raw?.trim();

  if (!value) {
    throw new Error('PhotoPrism 주소를 입력해 주세요.');
  }

  let url: URL;

  try {
    url = new URL(value);
  } catch {
    throw new Error('PhotoPrism 주소 형식이 올바르지 않습니다.');
  }

  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new Error('PhotoPrism 주소는 http 또는 https만 지원합니다.');
  }

  url.pathname = '';
  url.search = '';
  url.hash = '';

  return url.toString().replace(/\/+$/, '');
}

function getToken(value: string | undefined) {
  const token = value?.trim();

  if (!token) {
    throw new Error('PhotoPrism 인증 토큰이 없습니다.');
  }

  return token;
}

function appendAuthHeaders(headers: Headers, token: string) {
  headers.set('Authorization', `Bearer ${token}`);
  headers.set('X-Auth-Token', token);
}

function appendStreamingRequestHeaders(headers: Headers, request: FastifyRequest) {
  const range = request.headers.range;
  const ifRange = request.headers['if-range'];

  if (typeof range === 'string' && range.trim()) {
    headers.set('Range', range.trim());
  }

  if (typeof ifRange === 'string' && ifRange.trim()) {
    headers.set('If-Range', ifRange.trim());
  }
}

async function parseJsonResponse(response: Response) {
  const text = await response.text();

  if (!text.trim()) {
    return null;
  }

  try {
    return JSON.parse(text) as Record<string, unknown> | unknown[];
  } catch {
    return {
      message: text,
    };
  }
}

function extractErrorMessage(payload: unknown, fallback: string) {
  if (payload && typeof payload === 'object') {
    const record = payload as Record<string, unknown>;

    if (typeof record.message === 'string' && record.message.trim()) {
      return record.message.trim();
    }

    if (typeof record.error === 'string' && record.error.trim()) {
      return record.error.trim();
    }
  }

  return fallback;
}

function extractSessionToken(payload: unknown, response: Response) {
  const headerToken = response.headers.get('x-auth-token')?.trim();

  if (headerToken) {
    return headerToken;
  }

  if (!payload || typeof payload !== 'object') {
    return '';
  }

  const record = payload as Record<string, unknown>;
  const candidates = [
    record.accessToken,
    record.access_token,
    record.authToken,
    record.auth_token,
    record.id,
    record.sessionId,
    record.session_id,
  ];

  for (const candidate of candidates) {
    if (typeof candidate === 'string' && candidate.trim()) {
      return candidate.trim();
    }
  }

  const sessionRecord = record.session;

  if (sessionRecord && typeof sessionRecord === 'object') {
    const nested = sessionRecord as Record<string, unknown>;
    const nestedCandidates = [nested.accessToken, nested.access_token, nested.id, nested.sessionId];

    for (const candidate of nestedCandidates) {
      if (typeof candidate === 'string' && candidate.trim()) {
        return candidate.trim();
      }
    }
  }

  return '';
}

function buildJsonProxyMeta(response: Response) {
  const count = Number(response.headers.get('x-count') ?? 0);
  const limit = Number(response.headers.get('x-limit') ?? 0);
  const offset = Number(response.headers.get('x-offset') ?? 0);

  return {
    count,
    limit,
    offset,
    previewToken: response.headers.get('x-preview-token') ?? '',
    downloadToken: response.headers.get('x-download-token') ?? '',
  };
}

function normalizeArrayPayload<T>(payload: unknown): T[] {
  if (Array.isArray(payload)) {
    return payload as T[];
  }

  if (!payload || typeof payload !== 'object') {
    return [];
  }

  const record = payload as Record<string, unknown>;
  const candidates = [record.items, record.results, record.data, record.photos, record.albums];

  for (const candidate of candidates) {
    if (Array.isArray(candidate)) {
      return candidate as T[];
    }
  }

  return [];
}

function normalizeItemPayload<T>(payload: unknown): T | null {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
    return null;
  }

  const record = payload as Record<string, unknown>;
  const candidates = [record.item, record.photo, record.data, record.result];

  for (const candidate of candidates) {
    if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
      return candidate as T;
    }
  }

  return record as T;
}

function isLikelyVideoFile(file: Record<string, unknown>) {
  const mime = typeof file.Mime === 'string' ? file.Mime.trim().toLowerCase() : '';
  const name = typeof file.Name === 'string' ? file.Name.trim().toLowerCase() : '';
  const originalName = typeof file.OriginalName === 'string' ? file.OriginalName.trim().toLowerCase() : '';
  const type = typeof file.Type === 'string' ? file.Type.trim().toLowerCase() : '';
  const isVideoFlag = file.Video === true;
  const candidateName = originalName || name;

  if (mime.startsWith('image/')) {
    return false;
  }

  if (/\.(?:avif|gif|heic|jpeg|jpg|png|webp)$/i.test(candidateName)) {
    return false;
  }

  if (/\.(?:mp4|mov|m4v|webm|avi|mkv|3gp|mts|m2ts)\.(?:avif|gif|heic|jpeg|jpg|png|webp)$/i.test(candidateName)) {
    return false;
  }

  if (mime.startsWith('video/')) {
    return true;
  }

  if (type.includes('video')) {
    return true;
  }

  if (isVideoFlag) {
    return true;
  }

  return /\.(mp4|mov|m4v|webm|avi|mkv|3gp|mts|m2ts)$/i.test(name);
}

async function resolvePreferredDownloadHash(baseUrl: string, token: string, uid: string) {
  const detail = await proxyPhotoPrismJson(`/api/v1/photos/${encodeURIComponent(uid)}`, baseUrl, token, {});
  const photo = normalizeItemPayload<Record<string, unknown>>(detail.payload);
  const files = Array.isArray(photo?.Files) ? photo.Files.filter((file): file is Record<string, unknown> => Boolean(file) && typeof file === 'object') : [];

  if (!files.length) {
    return '';
  }

  const preferredFile =
    files.find((file) => isLikelyVideoFile(file)) ??
    files.find((file) => file.Primary === true) ??
    files[0];

  return typeof preferredFile?.Hash === 'string' ? preferredFile.Hash.trim() : '';
}

function dedupeItemsByKey<T>(items: T[], getKey: (item: T) => string) {
  const seen = new Set<string>();

  return items.filter((item) => {
    const key = getKey(item);

    if (!key) {
      return true;
    }

    if (seen.has(key)) {
      return false;
    }

    seen.add(key);
    return true;
  });
}

function buildPagedMeta(
  meta: ReturnType<typeof buildJsonProxyMeta>,
  sourceItemCount: number,
) {
  const normalizedSourceItemCount = Number.isFinite(sourceItemCount) && sourceItemCount >= 0 ? sourceItemCount : 0;
  const limit = Number.isFinite(meta.limit) && meta.limit > 0 ? meta.limit : normalizedSourceItemCount;
  const offset = Number.isFinite(meta.offset) && meta.offset >= 0 ? meta.offset : 0;
  const nextOffset = offset + normalizedSourceItemCount;
  const count = Number.isFinite(meta.count) && meta.count > 0 ? meta.count : 0;
  const hasMore = count > 0 ? nextOffset < count : normalizedSourceItemCount >= limit && limit > 0;

  return {
    ...meta,
    hasMore,
    nextOffset: hasMore ? nextOffset : offset,
  };
}

function buildSlicePagedMeta(input: {
  meta: ReturnType<typeof buildJsonProxyMeta>;
  requestedCount: number;
  sourceItemCount: number;
}) {
  const requestedCount =
    Number.isFinite(input.requestedCount) && input.requestedCount > 0 ? Math.round(input.requestedCount) : 0;
  const sourceItemCount =
    Number.isFinite(input.sourceItemCount) && input.sourceItemCount >= 0 ? Math.max(0, Math.round(input.sourceItemCount)) : 0;
  const offset = Number.isFinite(input.meta.offset) && input.meta.offset >= 0 ? input.meta.offset : 0;
  const consumedCount = requestedCount > 0 ? Math.min(sourceItemCount, requestedCount) : sourceItemCount;
  const hasMore = requestedCount > 0 ? sourceItemCount > requestedCount : false;

  return {
    ...input.meta,
    count: Number.isFinite(input.meta.count) && input.meta.count > 0 ? input.meta.count : offset + consumedCount + (hasMore ? 1 : 0),
    limit: requestedCount > 0 ? requestedCount : input.meta.limit,
    hasMore,
    nextOffset: hasMore ? offset + consumedCount : offset,
  };
}

async function collectPagedUniquePhotoItems(input: {
  baseUrl: string;
  token: string;
  requestedCount: number;
  offset: number;
  order: string;
  query: string;
  albumUid: string;
}) {
  const normalizedRequestedCount =
    Number.isFinite(input.requestedCount) && input.requestedCount > 0 ? Math.min(1000, Math.max(1, Math.round(input.requestedCount))) : 120;
  const targetUniqueCount = Math.min(1000, normalizedRequestedCount + 1);
  const dedupeKey = (item: Record<string, unknown>) => {
    if (typeof item.UID === 'string' && item.UID.trim()) {
      return item.UID;
    }

    if (typeof item.Hash === 'string' && item.Hash.trim()) {
      return item.Hash;
    }

    return '';
  };
  const uniqueItems: Record<string, unknown>[] = [];
  const seenKeys = new Set<string>();
  let requestOffset = Number.isFinite(input.offset) && input.offset >= 0 ? Math.max(0, Math.round(input.offset)) : 0;
  let totalSourceItemCount = 0;
  let lastMeta: ReturnType<typeof buildJsonProxyMeta> | null = null;

  for (let attempt = 0; attempt < 8 && uniqueItems.length < targetUniqueCount; attempt += 1) {
    const { payload, meta } = await proxyPhotoPrismJson('/api/v1/photos', input.baseUrl, input.token, {
      count: Math.min(1000, targetUniqueCount),
      offset: requestOffset,
      order: input.order,
      q: input.query,
      s: input.albumUid,
      merged: true,
      primary: true,
    });
    const sourceItems = normalizeArrayPayload<Record<string, unknown>>(payload);
    lastMeta = meta;

    if (!sourceItems.length) {
      break;
    }

    totalSourceItemCount += sourceItems.length;
    requestOffset += sourceItems.length;

    sourceItems.forEach((item) => {
      if (uniqueItems.length >= targetUniqueCount) {
        return;
      }

      const key = dedupeKey(item);

      if (key) {
        if (seenKeys.has(key)) {
          return;
        }

        seenKeys.add(key);
      }

      uniqueItems.push(item);
    });

    if (sourceItems.length < targetUniqueCount) {
      break;
    }
  }

  return {
    items: uniqueItems.slice(0, normalizedRequestedCount),
    meta: buildSlicePagedMeta({
      meta: lastMeta ?? {
        count: 0,
        limit: normalizedRequestedCount,
        offset: Number.isFinite(input.offset) && input.offset >= 0 ? Math.max(0, Math.round(input.offset)) : 0,
        previewToken: '',
        downloadToken: '',
      },
      requestedCount: normalizedRequestedCount,
      sourceItemCount: totalSourceItemCount,
    }),
  };
}

async function proxyPhotoPrismJson(
  endpoint: string,
  baseUrl: string,
  token: string,
  query: Record<string, string | number | boolean | null | undefined>,
) {
  const targetUrl = new URL(endpoint, `${baseUrl}/`);

  Object.entries(query).forEach(([key, value]) => {
    if (value === null || value === undefined || value === '') {
      return;
    }

    targetUrl.searchParams.set(key, String(value));
  });

  const headers = new Headers({
    Accept: 'application/json',
  });
  appendAuthHeaders(headers, token);

  const response = await fetch(targetUrl, {
    headers,
  });
  const payload = await parseJsonResponse(response);

  if (!response.ok) {
    throw new Error(extractErrorMessage(payload, `PhotoPrism 요청에 실패했습니다. (${response.status})`));
  }

  return {
    payload,
    meta: buildJsonProxyMeta(response),
  };
}

async function proxyPhotoPrismMutation(
  endpoint: string,
  baseUrl: string,
  token: string,
  options?: {
    method?: string;
    query?: Record<string, string | number | boolean | null | undefined>;
    body?: unknown;
  },
) {
  const targetUrl = new URL(endpoint, `${baseUrl}/`);

  Object.entries(options?.query ?? {}).forEach(([key, value]) => {
    if (value === null || value === undefined || value === '') {
      return;
    }

    targetUrl.searchParams.set(key, String(value));
  });

  const headers = new Headers({
    Accept: 'application/json',
  });
  appendAuthHeaders(headers, token);

  let body: string | undefined;

  if (options && 'body' in options && options.body !== undefined) {
    headers.set('Content-Type', 'application/json');
    body = JSON.stringify(options.body);
  }

  const response = await fetch(targetUrl, {
    method: options?.method ?? 'POST',
    headers,
    body,
  });
  const payload = await parseJsonResponse(response);

  if (!response.ok) {
    throw new Error(extractErrorMessage(payload, `PhotoPrism 요청에 실패했습니다. (${response.status})`));
  }

  return payload;
}

async function proxyBinaryResponse(
  reply: FastifyReply,
  targetUrl: URL,
  init?: RequestInit,
) {
  const response = await fetch(targetUrl, init);

  if (!response.ok || !response.body) {
    const payload = await parseJsonResponse(response);
    reply.code(response.status >= 400 ? response.status : 502);
    return {
      message: extractErrorMessage(payload, `PhotoPrism 파일 요청에 실패했습니다. (${response.status})`),
    };
  }

  const contentType = response.headers.get('content-type');
  const contentDisposition = response.headers.get('content-disposition');
  const contentLength = response.headers.get('content-length');
  const lastModified = response.headers.get('last-modified');
  const cacheControl = response.headers.get('cache-control');
  const etag = response.headers.get('etag');
  const expires = response.headers.get('expires');
  const vary = response.headers.get('vary');
  const acceptRanges = response.headers.get('accept-ranges');
  const contentRange = response.headers.get('content-range');

  reply.code(response.status);

  if (contentType) {
    reply.header('content-type', contentType);
  }

  if (contentDisposition) {
    reply.header('content-disposition', contentDisposition);
  }

  if (contentLength) {
    reply.header('content-length', contentLength);
  }

  if (lastModified) {
    reply.header('last-modified', lastModified);
  }

  if (cacheControl) {
    reply.header('cache-control', cacheControl);
  }

  if (etag) {
    reply.header('etag', etag);
  }

  if (expires) {
    reply.header('expires', expires);
  }

  if (vary) {
    reply.header('vary', vary);
  }

  if (acceptRanges) {
    reply.header('accept-ranges', acceptRanges);
  }

  if (contentRange) {
    reply.header('content-range', contentRange);
  }

  return reply.send(response.body);
}

export async function registerPhotoPrismRoutes(app: FastifyInstance) {
  app.post('/api/play-apps/photoprism/session', async (request: FastifyRequest<{ Body: PhotoPrismSessionBody }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.body?.baseUrl);
    const username = request.body?.username?.trim();
    const password = request.body?.password?.trim();

    if (!username || !password) {
      return reply.code(400).send({
        message: '아이디와 비밀번호를 입력해 주세요.',
      });
    }

    const targetUrl = new URL('/api/v1/session', `${baseUrl}/`);
    const response = await fetch(targetUrl, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        username,
        password,
      }),
    });
    const payload = await parseJsonResponse(response);

    if (!response.ok) {
      return reply.code(response.status >= 400 ? response.status : 502).send({
        message: extractErrorMessage(payload, `PhotoPrism 로그인에 실패했습니다. (${response.status})`),
      });
    }

    const token = extractSessionToken(payload, response);

    if (!token) {
      return reply.code(502).send({
        message: 'PhotoPrism 로그인 응답에서 인증 토큰을 찾지 못했습니다.',
      });
    }

    return {
      ok: true,
      token,
      session: payload,
    };
  });

  app.get('/api/play-apps/photoprism/albums', async (request: FastifyRequest<{ Querystring: PhotoPrismQuery }>) => {
    const baseUrl = normalizeBaseUrl(request.query.baseUrl);
    const token = getToken(request.query.token);
    const count = Number(request.query.count ?? 120);
    const offset = Number(request.query.offset ?? 0);

    const { payload, meta } = await proxyPhotoPrismJson('/api/v1/albums', baseUrl, token, {
      count: Number.isFinite(count) ? Math.min(1000, Math.max(1, Math.round(count))) : 120,
      offset: Number.isFinite(offset) ? Math.max(0, Math.round(offset)) : 0,
      order: request.query.order?.trim() || 'added',
      q: request.query.q?.trim() || '',
    });
    const sourceItems = normalizeArrayPayload<Record<string, unknown>>(payload);
    const items = dedupeItemsByKey(sourceItems, (item) =>
      typeof item.UID === 'string' ? item.UID : '',
    );

    return {
      ok: true,
      items,
      meta: buildPagedMeta(meta, sourceItems.length),
    };
  });

  app.get('/api/play-apps/photoprism/photos', async (request: FastifyRequest<{ Querystring: PhotoPrismQuery }>) => {
    const baseUrl = normalizeBaseUrl(request.query.baseUrl);
    const token = getToken(request.query.token);
    const count = Number(request.query.count ?? 120);
    const offset = Number(request.query.offset ?? 0);
    const normalizedCount = Number.isFinite(count) ? Math.min(1000, Math.max(1, Math.round(count))) : 120;
    const { items, meta } = await collectPagedUniquePhotoItems({
      baseUrl,
      token,
      requestedCount: normalizedCount,
      offset: Number.isFinite(offset) ? Math.max(0, Math.round(offset)) : 0,
      order: request.query.order?.trim() || 'newest',
      query: request.query.q?.trim() || '',
      albumUid: request.query.albumUid?.trim() || '',
    });

    return {
      ok: true,
      items,
      meta,
    };
  });

  app.get('/api/play-apps/photoprism/photo', async (request: FastifyRequest<{ Querystring: PhotoPrismQuery }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.query.baseUrl);
    const token = getToken(request.query.token);
    const uid = request.query.uid?.trim();

    if (!uid) {
      return reply.code(400).send({
        message: '상세 사진 UID가 없습니다.',
      });
    }

    const { payload } = await proxyPhotoPrismJson(`/api/v1/photos/${encodeURIComponent(uid)}`, baseUrl, token, {});
    const item = normalizeItemPayload<Record<string, unknown>>(payload);

    if (!item || (typeof item.UID === 'string' && item.UID.trim() === '') || !('UID' in item)) {
      return reply.code(502).send({
        message: 'PhotoPrism 상세 사진 응답을 해석하지 못했습니다.',
      });
    }

    return {
      ok: true,
      item,
    };
  });

  app.post('/api/play-apps/photoprism/photo/favorite', async (request: FastifyRequest<{ Body: PhotoPrismFavoriteBody }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.body?.baseUrl);
    const token = getToken(request.body?.token);
    const uid = request.body?.uid?.trim();
    const favorite = request.body?.favorite === true;

    if (!uid) {
      return reply.code(400).send({
        message: '즐겨찾기를 변경할 사진 UID가 없습니다.',
      });
    }

    try {
      if (favorite) {
        await proxyPhotoPrismMutation(`/api/v1/photos/${encodeURIComponent(uid)}/like`, baseUrl, token, {
          method: 'POST',
        });
      } else {
        try {
          await proxyPhotoPrismMutation(`/api/v1/photos/${encodeURIComponent(uid)}/like`, baseUrl, token, {
            method: 'DELETE',
          });
        } catch {
          await proxyPhotoPrismMutation(`/api/v1/photos/${encodeURIComponent(uid)}`, baseUrl, token, {
            method: 'PUT',
            body: {
              Favorite: false,
            },
          });
        }
      }

      const { payload } = await proxyPhotoPrismJson(`/api/v1/photos/${encodeURIComponent(uid)}`, baseUrl, token, {});
      const item = normalizeItemPayload<Record<string, unknown>>(payload);

      if (!item || (typeof item.UID === 'string' && item.UID.trim() === '') || !('UID' in item)) {
        return {
          ok: true,
          item: {
            UID: uid,
            Favorite: favorite,
          },
        };
      }

      return {
        ok: true,
        item,
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : '즐겨찾기 상태를 변경하지 못했습니다.';
      return reply.code(502).send({
        message,
      });
    }
  });

  app.put(
    '/api/play-apps/photoprism/photo/favorite',
    async (request: FastifyRequest<{ Body: PhotoPrismFavoriteBody }>, reply) => {
      const baseUrl = normalizeBaseUrl(request.body?.baseUrl);
      const token = getToken(request.body?.token);
      const uid = request.body?.uid?.trim();
      const favorite = request.body?.favorite;

      if (!uid) {
        return reply.code(400).send({
          message: '즐겨찾기 사진 UID가 없습니다.',
        });
      }

      if (typeof favorite !== 'boolean') {
        return reply.code(400).send({
          message: '즐겨찾기 상태 값이 올바르지 않습니다.',
        });
      }

      const targetUrl = new URL(`/api/v1/photos/${encodeURIComponent(uid)}`, `${baseUrl}/`);
      const headers = new Headers({
        Accept: 'application/json',
        'Content-Type': 'application/json',
      });
      appendAuthHeaders(headers, token);

      const response = await fetch(targetUrl, {
        method: 'PUT',
        headers,
        body: JSON.stringify({
          Favorite: favorite,
        }),
      });
      const payload = await parseJsonResponse(response);

      if (!response.ok) {
        return reply.code(response.status >= 400 ? response.status : 502).send({
          message: extractErrorMessage(payload, `PhotoPrism 즐겨찾기 변경에 실패했습니다. (${response.status})`),
        });
      }

      const item = normalizeItemPayload<Record<string, unknown>>(payload);

      if (!item || (typeof item.UID === 'string' && item.UID.trim() === '') || !('UID' in item)) {
        return reply.code(502).send({
          message: 'PhotoPrism 즐겨찾기 변경 응답을 해석하지 못했습니다.',
        });
      }

      return {
        ok: true,
        item,
      };
    },
  );

  app.post('/api/play-apps/photoprism/photo/albums', async (request: FastifyRequest<{ Body: PhotoPrismAlbumMembershipBody }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.body?.baseUrl);
    const token = getToken(request.body?.token);
    const uid = request.body?.uid?.trim();
    const albumUid = request.body?.albumUid?.trim();

    if (!uid) {
      return reply.code(400).send({
        message: '앨범에 추가할 사진 UID가 없습니다.',
      });
    }

    if (!albumUid) {
      return reply.code(400).send({
        message: '추가 대상 앨범 UID가 없습니다.',
      });
    }

    try {
      await proxyPhotoPrismMutation(`/api/v1/albums/${encodeURIComponent(albumUid)}/photos`, baseUrl, token, {
        method: 'POST',
        body: {
          photos: [uid],
        },
      });

      const { payload } = await proxyPhotoPrismJson(`/api/v1/photos/${encodeURIComponent(uid)}`, baseUrl, token, {});
      const item = normalizeItemPayload<Record<string, unknown>>(payload);

      if (!item || (typeof item.UID === 'string' && item.UID.trim() === '') || !('UID' in item)) {
        return {
          ok: true,
          item: {
            UID: uid,
          },
        };
      }

      return {
        ok: true,
        item,
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : '사진을 앨범에 추가하지 못했습니다.';
      return reply.code(502).send({
        message,
      });
    }
  });

  app.delete('/api/play-apps/photoprism/photo/albums', async (request: FastifyRequest<{ Body: PhotoPrismAlbumMembershipBody }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.body?.baseUrl);
    const token = getToken(request.body?.token);
    const uid = request.body?.uid?.trim();
    const albumUid = request.body?.albumUid?.trim();

    if (!uid) {
      return reply.code(400).send({
        message: '앨범에서 제거할 사진 UID가 없습니다.',
      });
    }

    if (!albumUid) {
      return reply.code(400).send({
        message: '제거 대상 앨범 UID가 없습니다.',
      });
    }

    try {
      await proxyPhotoPrismMutation(`/api/v1/albums/${encodeURIComponent(albumUid)}/photos`, baseUrl, token, {
        method: 'DELETE',
        body: {
          photos: [uid],
        },
      });

      const { payload } = await proxyPhotoPrismJson(`/api/v1/photos/${encodeURIComponent(uid)}`, baseUrl, token, {});
      const item = normalizeItemPayload<Record<string, unknown>>(payload);

      if (!item || (typeof item.UID === 'string' && item.UID.trim() === '') || !('UID' in item)) {
        return {
          ok: true,
          item: {
            UID: uid,
          },
        };
      }

      return {
        ok: true,
        item,
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : '사진을 앨범에서 제거하지 못했습니다.';
      return reply.code(502).send({
        message,
      });
    }
  });

  app.get('/api/play-apps/photoprism/thumb', async (request: FastifyRequest<{ Querystring: PhotoPrismQuery }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.query.baseUrl);
    const hash = request.query.hash?.trim();
    const previewToken = request.query.previewToken?.trim();
    const token = request.query.token?.trim();
    const size = request.query.size?.trim() || 'fit_720';

    if (!hash || !previewToken) {
      return reply.code(400).send({
        message: '썸네일 요청 정보가 부족합니다.',
      });
    }

    const targetUrl = new URL(`/api/v1/t/${encodeURIComponent(hash)}/${encodeURIComponent(previewToken)}/${encodeURIComponent(size)}`, `${baseUrl}/`);
    const headers = new Headers({
      Accept: 'image/*',
    });

    if (token) {
      appendAuthHeaders(headers, token);
    }

    return proxyBinaryResponse(reply, targetUrl, {
      headers,
    });
  });

  app.get('/api/play-apps/photoprism/viewer', async (request: FastifyRequest<{ Querystring: PhotoPrismQuery }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.query.baseUrl);
    const token = getToken(request.query.token);
    const hash = request.query.hash?.trim();
    const uid = request.query.uid?.trim();
    const previewToken = request.query.previewToken?.trim();
    const downloadToken = request.query.downloadToken?.trim();
    const size = request.query.size?.trim() || 'fit_2560';

    if (hash && previewToken) {
      const thumbUrl = new URL(`/api/v1/t/${encodeURIComponent(hash)}/${encodeURIComponent(previewToken)}/${encodeURIComponent(size)}`, `${baseUrl}/`);
      const thumbHeaders = new Headers({
        Accept: 'image/*',
      });
      appendAuthHeaders(thumbHeaders, token);
      const thumbResponse = await fetch(thumbUrl, {
        headers: thumbHeaders,
      });

      if (thumbResponse.ok && thumbResponse.body) {
        const contentType = thumbResponse.headers.get('content-type');
        const contentLength = thumbResponse.headers.get('content-length');
        const lastModified = thumbResponse.headers.get('last-modified');
        const cacheControl = thumbResponse.headers.get('cache-control');
        const etag = thumbResponse.headers.get('etag');
        const expires = thumbResponse.headers.get('expires');
        const vary = thumbResponse.headers.get('vary');

        if (contentType) {
          reply.header('content-type', contentType);
        }

        if (contentLength) {
          reply.header('content-length', contentLength);
        }

        if (lastModified) {
          reply.header('last-modified', lastModified);
        }

        if (cacheControl) {
          reply.header('cache-control', cacheControl);
        }

        if (etag) {
          reply.header('etag', etag);
        }

        if (expires) {
          reply.header('expires', expires);
        }

        if (vary) {
          reply.header('vary', vary);
        }

        return reply.send(thumbResponse.body);
      }
    }

    if (!uid && !hash) {
      return reply.code(400).send({
        message: '상세 이미지 요청 정보가 부족합니다.',
      });
    }

    const tryRequests: Array<{ url: URL; headers: Headers }> = [];

    const appendDownloadAttempts = (withDownloadToken: boolean) => {
      const headers = new Headers({
        Accept: 'video/*,image/*,application/octet-stream',
      });
      appendAuthHeaders(headers, token);
      appendStreamingRequestHeaders(headers, request);

      if (withDownloadToken && downloadToken) {
        headers.set('X-Download-Token', downloadToken);
      }

      if (uid) {
        const photoDownloadUrl = new URL(`/api/v1/photos/${encodeURIComponent(uid)}/dl`, `${baseUrl}/`);

        if (withDownloadToken && downloadToken) {
          photoDownloadUrl.searchParams.set('t', downloadToken);
        }

        tryRequests.push({
          url: photoDownloadUrl,
          headers,
        });
      }

      if (hash) {
        const fileDownloadUrl = new URL(`/api/v1/dl/${encodeURIComponent(hash)}`, `${baseUrl}/`);

        if (withDownloadToken && downloadToken) {
          fileDownloadUrl.searchParams.set('t', downloadToken);
        }

        tryRequests.push({
          url: fileDownloadUrl,
          headers,
        });
      }
    };

    if (downloadToken) {
      appendDownloadAttempts(true);
    }
    appendDownloadAttempts(false);

    for (let index = 0; index < tryRequests.length; index += 1) {
      const response = await fetch(tryRequests[index].url, {
        headers: tryRequests[index].headers,
      });

      if (response.ok && response.body) {
        const contentType = response.headers.get('content-type');
        const contentDisposition = response.headers.get('content-disposition');
        const contentLength = response.headers.get('content-length');
        const lastModified = response.headers.get('last-modified');
        const cacheControl = response.headers.get('cache-control');
        const etag = response.headers.get('etag');
        const expires = response.headers.get('expires');
        const vary = response.headers.get('vary');
        const acceptRanges = response.headers.get('accept-ranges');
        const contentRange = response.headers.get('content-range');

        reply.code(response.status);

        if (contentType) {
          reply.header('content-type', contentType);
        }

        if (contentDisposition) {
          reply.header('content-disposition', contentDisposition);
        }

        if (contentLength) {
          reply.header('content-length', contentLength);
        }

        if (lastModified) {
          reply.header('last-modified', lastModified);
        }

        if (cacheControl) {
          reply.header('cache-control', cacheControl);
        }

        if (etag) {
          reply.header('etag', etag);
        }

        if (expires) {
          reply.header('expires', expires);
        }

        if (vary) {
          reply.header('vary', vary);
        }

        if (acceptRanges) {
          reply.header('accept-ranges', acceptRanges);
        }

        if (contentRange) {
          reply.header('content-range', contentRange);
        }

        return reply.send(response.body);
      }

      if (index === tryRequests.length - 1) {
        const payload = await parseJsonResponse(response);
        return reply.code(response.status >= 400 ? response.status : 502).send({
          message: extractErrorMessage(payload, `PhotoPrism 상세 이미지 요청에 실패했습니다. (${response.status})`),
        });
      }
    }

    return reply.code(502).send({
      message: 'PhotoPrism 상세 이미지 경로를 확인하지 못했습니다.',
    });
  });

  app.get('/api/play-apps/photoprism/download', async (request: FastifyRequest<{ Querystring: PhotoPrismQuery }>, reply) => {
    const baseUrl = normalizeBaseUrl(request.query.baseUrl);
    const token = getToken(request.query.token);
    const downloadToken = request.query.downloadToken?.trim();
    const uid = request.query.uid?.trim();
    const hash = request.query.hash?.trim();

    if (!uid && !hash) {
      return reply.code(400).send({
        message: '원본 다운로드 정보가 부족합니다.',
      });
    }

    let resolvedHash = hash;

    if (uid) {
      try {
        const preferredHash = await resolvePreferredDownloadHash(baseUrl, token, uid);

        if (preferredHash) {
          resolvedHash = preferredHash;
        }
      } catch {
        resolvedHash = hash;
      }
    }

    const tryRequests: Array<{ url: URL; headers: Headers }> = [];

    const appendDownloadAttempts = (withDownloadToken: boolean) => {
      const headers = new Headers({
        Accept: 'application/octet-stream',
      });
      appendAuthHeaders(headers, token);
      appendStreamingRequestHeaders(headers, request);

      if (withDownloadToken && downloadToken) {
        headers.set('X-Download-Token', downloadToken);
      }

      if (resolvedHash) {
        const fileDownloadUrl = new URL(`/api/v1/dl/${encodeURIComponent(resolvedHash)}`, `${baseUrl}/`);

        if (withDownloadToken && downloadToken) {
          fileDownloadUrl.searchParams.set('t', downloadToken);
        }

        tryRequests.push({
          url: fileDownloadUrl,
          headers,
        });
      }

      if (uid) {
        const photoDownloadUrl = new URL(`/api/v1/photos/${encodeURIComponent(uid)}/dl`, `${baseUrl}/`);

        if (withDownloadToken && downloadToken) {
          photoDownloadUrl.searchParams.set('t', downloadToken);
        }

        tryRequests.push({
          url: photoDownloadUrl,
          headers,
        });
      }
    };

    if (downloadToken) {
      appendDownloadAttempts(true);
    }
    appendDownloadAttempts(false);

    for (let index = 0; index < tryRequests.length; index += 1) {
      const currentRequest = tryRequests[index];
      const response = await fetch(currentRequest.url, {
        headers: currentRequest.headers,
      });

      if (response.ok && response.body) {
        const contentType = response.headers.get('content-type');
        const contentDisposition = response.headers.get('content-disposition');
        const contentLength = response.headers.get('content-length');
        const acceptRanges = response.headers.get('accept-ranges');
        const contentRange = response.headers.get('content-range');

        reply.code(response.status);

        if (contentType) {
          reply.header('content-type', contentType);
        }

        if (contentDisposition) {
          reply.header('content-disposition', contentDisposition);
        }

        if (contentLength) {
          reply.header('content-length', contentLength);
        }

        if (acceptRanges) {
          reply.header('accept-ranges', acceptRanges);
        }

        if (contentRange) {
          reply.header('content-range', contentRange);
        }

        return reply.send(response.body);
      }

      if (index === tryRequests.length - 1) {
        const payload = await parseJsonResponse(response);
        return reply.code(response.status >= 400 ? response.status : 502).send({
          message: extractErrorMessage(payload, `PhotoPrism 원본 다운로드에 실패했습니다. (${response.status})`),
        });
      }
    }

    return reply.code(502).send({
      message: 'PhotoPrism 원본 다운로드 경로를 확인하지 못했습니다.',
    });
  });
}
