import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
import { CONTENT_TYPE_CODE } from '@/lib/domain/content';
import type { ContentItem, ContentKeyword } from '@/lib/domain/content';
import type { ContentQuery } from '@/lib/domain/content-query';
import type {
  ComicFormValues,
  ShortsFormValues,
} from '@/lib/domain/content-form';

/**
 * 금융쇼츠·4컷금융만화 Repository — 두 화면은 같은 테이블(`TB_COM_CNTNTS`)을 쓰고
 * `CNTNTS_TYPE`으로 갈린다(`V` 쇼츠 / `T` 만화).
 *
 * ```
 * GET    /api/v1/mngr/cntntns/pagination    searchCntntsType 필수
 * GET    /api/v1/mngr/cntntns/{cntntsId}    단건
 * POST   /api/v1/mngr/cntntns               어노테이션 없음 → form(중첩 목록은 인덱스 표기)
 * PUT    /api/v1/mngr/cntntns/{cntntsId}    @RequestBody(JSON)
 * DELETE /api/v1/mngr/cntntns/{cntntsId}    soft delete(DEL_YN)
 * ```
 *
 * 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다.
 *
 * ⚠️ 백엔드 결함(보고함, 미수정) — 그래서 목록은 전량 수령 후 로컬 검색·정렬·페이징이다.
 * 1. 검색 조건(`c.CNTNTS_TITLE`·`k.CNTNTS_KEYWORD`)이 별칭을 쓰는데 `selectPaginationCount`는
 *    `FROM TB_COM_CNTNTS`뿐이라 검색어를 실으면 count가 SQL 오류로 죽는다.
 * 2. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시 뒤집어
 *    **옛 글부터** 나간다. 새로 등록한 글이 첫 페이지가 아니라 마지막 페이지에 붙는다.
 * 3. 키워드·학교급·파일 LEFT JOIN으로 한 콘텐츠가 여러 행이 되고 LIMIT이 그 행 기준이라, 페이지
 *    경계에서 잘린 콘텐츠는 키워드·컷 목록이 반쪽으로 온다.
 * 4. 학교·학년 파라미터는 `<if>` 본문이 값 대신 `#{searchKeyword}`를 비교해 보내도 의미가 없다.
 */

const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns';

type ContentApiKind = 'shorts' | 'comic';

function isRecord(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === 'object';
}

function readString(source: Record<string, unknown>, key: string): string | null {
  const value = source[key];
  return typeof value === 'string' && value.length > 0 ? value : null;
}

function toKeywords(raw: unknown): ContentKeyword[] {
  if (!Array.isArray(raw)) {
    return [];
  }
  return raw.flatMap((entry) => {
    if (!isRecord(entry)) {
      return [];
    }
    const keyword = readString(entry, 'cntntsKeyword');
    if (!keyword) {
      return [];
    }
    return [{ keyword, description: readString(entry, 'cntntsExpln') ?? '' }];
  });
}

/** 학교급/학년 매핑은 학년마다 한 줄씩 온다 — 학교는 하나로 접고 학년만 모은다. */
function toSchoolGrades(raw: unknown): { schoolCode: string; grades: string[] } {
  if (!Array.isArray(raw)) {
    return { schoolCode: '', grades: [] };
  }

  const grades: string[] = [];
  let schoolCode = '';

  for (const entry of raw) {
    if (!isRecord(entry)) {
      continue;
    }
    schoolCode = schoolCode || (readString(entry, 'schulCd') ?? '');
    const grade = readString(entry, 'grdCd');
    if (grade && !grades.includes(grade)) {
      grades.push(grade);
    }
  }

  return { schoolCode, grades };
}

/** 첨부의 원본 파일명 — 조회로 연 화면이 어떤 파일이 붙어 있는지 보여 줄 때 쓴다. */
function toFirstFileName(raw: unknown): string | null {
  if (!Array.isArray(raw)) {
    return null;
  }
  for (const entry of raw) {
    if (isRecord(entry)) {
      const name = readString(entry, 'orgnlFileNm');
      if (name) {
        return name;
      }
    }
  }
  return null;
}

/** 한 `atchFileId` 아래 여러 컷이 `fileSn`으로 들어온다. */
function toFileNumbers(raw: unknown): number[] {
  if (!Array.isArray(raw)) {
    return [];
  }
  return raw
    .flatMap((entry) => (isRecord(entry) ? [Number(entry.fileSn)] : []))
    .filter((value) => Number.isInteger(value) && value > 0)
    .sort((a, b) => a - b);
}

function toContentItem(raw: unknown): ContentItem {
  if (!isRecord(raw)) {
    throw new Error('콘텐츠 응답 항목의 형식이 올바르지 않습니다.');
  }

  const cntntsId = readString(raw, 'cntntsId');
  if (!cntntsId) {
    throw new Error('콘텐츠 응답에 cntntsId이 없습니다.');
  }

  const schulGrd = toSchoolGrades(raw.schulGrdList);

  return {
    cntntsId,
    cutFileNumbers: toFileNumbers(raw.fileList),
    name: readString(raw, 'cntntsNm') ?? '',
    title: readString(raw, 'cntntsTitle') ?? '',
    subject: readString(raw, 'cntntsSbjct') ?? '',
    message: readString(raw, 'cntntsMssage') ?? '',
    schoolCode: schulGrd.schoolCode,
    grades: schulGrd.grades,
    keywords: toKeywords(raw.keywordList),
    attachmentId: readString(raw, 'atchFileId'),
    attachmentFileName: toFirstFileName(raw.fileList),
    thumbnailFileId: readString(raw, 'thumbAtchFileId'),
    isVisible: readString(raw, 'useYn') !== 'N',
    modifiedAt:
      readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'),
  };
}

function buildListParams(
  kind: ContentApiKind,
  pageIndex: number,
  recordCountPerPage: number
): Record<string, string | number> {
  return {
    // 없으면 `CNTNTS_TYPE = NULL`이 되어 한 건도 오지 않는다.
    // 검색어는 보내지 않는다 — 검색 조건이 c.·k. 별칭을 쓰는데 count 쿼리에는 별칭이 없어
    // 키워드를 실으면 count가 SQL 오류로 죽는다(파일 상단 ⚠️ 1). 검색은 아래에서 로컬로 건다.
    searchCntntsType: CONTENT_TYPE_CODE[kind],
    pageIndex,
    recordCountPerPage,
  };
}

async function requestList(
  kind: ContentApiKind,
  pageIndex: number,
  recordCountPerPage: number
): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
  const accessToken = await getSessionAccessToken();

  const result = await backendFetch<unknown>(`${CONTENT_BASE_PATH}/pagination`, {
    method: 'GET',
    query: buildListParams(kind, pageIndex, recordCountPerPage),
    accessToken: accessToken ?? undefined,
    cache: 'no-store',
    canHaveNullData: true,
    canHaveEmptyBody: true,
  });

  if (!result.ok) {
    throw new BackendRequestError(result);
  }

  const data = result.data;
  // 금융쇼츠는 컨트롤러가 null을 돌려준다(파일 상단 이슈) — 빈 목록으로 다룬다.
  if (data === null || data === undefined) {
    return { items: [], reportedTotalCount: 0 };
  }
  if (!isRecord(data) || !Array.isArray(data.list)) {
    throw new Error('콘텐츠 목록 응답의 형식이 올바르지 않습니다.');
  }

  return {
    items: data.list.map(toContentItem),
    reportedTotalCount:
      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
  };
}

export type ContentPage = {
  items: ContentItem[];
  totalCount: number;
  isTotalCountExact: boolean;
};

// TODO(백엔드): 목록 정렬·LIMIT이 고쳐지면 서버 페이징으로 되돌린다. 그전까지 이 상한을
// 넘는 콘텐츠는 목록에서 조용히 잘린다.
const FETCH_LIMIT = 500;

function matchesContentQuery(item: ContentItem, query: ContentQuery): boolean {
  if (query.school && item.schoolCode !== query.school) {
    return false;
  }
  if (query.grade && !item.grades.includes(query.grade)) {
    return false;
  }

  const keyword = query.keyword.trim().toLowerCase();
  if (!keyword) {
    return true;
  }
  if (query.searchField === 'keyword') {
    return item.keywords.some((entry) =>
      entry.keyword.toLowerCase().includes(keyword)
    );
  }
  // title(쇼츠)·name(만화) 모두 같은 컬럼(CNTNTS_NM=CNTNTS_TITLE)이라 한 값을 본다.
  return item.title.toLowerCase().includes(keyword);
}

export async function fetchContents(
  kind: ContentApiKind,
  query: ContentQuery
): Promise<ContentPage> {
  // 서버 페이징을 쓰지 않는다 — 목록 SQL이 rnum(등록일 내림차순)을 다시 뒤집어 옛 글부터
  // 내보내고, LIMIT도 조인으로 불어난 행 기준이라 새 글이 마지막 페이지로 밀린다(⚠️ 2·3).
  const { items } = await requestList(kind, 1, FETCH_LIMIT);

  const matched = items
    .filter((item) => matchesContentQuery(item, query))
    // rnum은 resultMap에 없고 등록일은 일 단위라, 새 글이 먼저 오도록 일련번호로 세운다.
    .sort((a, b) => Number(b.cntntsId) - Number(a.cntntsId));

  const offset = (query.page - 1) * query.pageSize;

  return {
    items: matched.slice(offset, offset + query.pageSize),
    totalCount: matched.length,
    isTotalCountExact: true,
  };
}

/** 단건 조회 — 목록과 같은 SQL이라 키워드·학교급·파일 목록이 함께 온다. */
export async function fetchContentDetail(
  kind: ContentApiKind,
  cntntsId: string
): Promise<ContentItem | null> {
  void kind;
  const accessToken = await getSessionAccessToken();

  const result = await backendFetch<unknown>(
    `${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsId)}`,
    {
      method: 'GET',
      accessToken: accessToken ?? undefined,
      cache: 'no-store',
      canHaveNullData: true,
    }
  );

  if (!result.ok) {
    throw new BackendRequestError(result);
  }
  if (result.data === null || result.data === undefined) {
    return null;
  }

  return toContentItem(result.data);
}

// 이슈: 주제·전달메시지를 담을 컬럼이 테이블에 없다. 화면은 기획대로 두고 값은 보내지 않는다.
type ContentWritePayload = {
  cntntsType?: string;
  cntntsNm: string;
  cntntsTitle: string;
  atchFileId: string;
  thumbAtchFileId: string;
  useYn: string;
  keywordList: Array<{ cntntsKeyword: string; cntntsExpln: string }>;
  schulGrdList: Array<{ schulCd: string; grdCd: string | null }>;
};

function toKeywordPayload(keywords: readonly ContentKeyword[]) {
  return keywords.map((item) => ({
    cntntsKeyword: item.keyword,
    cntntsExpln: item.description,
  }));
}

/** 학년마다 한 줄이다. 학년을 고르지 않았으면 학교만 담은 한 줄을 남긴다. */
function toSchoolGradePayload(schoolCode: string, grades: readonly string[]) {
  if (!schoolCode) {
    return [];
  }
  if (grades.length === 0) {
    // 학년 없는 학교(전체연령 등)는 학교만 남긴다 — 빈 문자열을 넣으면 GRD_CD에 ''가 박힌다.
    return [{ schulCd: schoolCode, grdCd: null }];
  }
  return grades.map((grade) => ({ schulCd: schoolCode, grdCd: grade }));
}

export function buildShortsPayload(
  values: ShortsFormValues
): ContentWritePayload {
  return {
    cntntsNm: values.title,
    cntntsTitle: values.title,
    atchFileId: values.videoFileId,
    thumbAtchFileId: values.thumbnailFileId,
    useYn: values.isVisible ? 'Y' : 'N',
    keywordList: toKeywordPayload(values.keywords),
    schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
  };
}

export function buildComicPayload(values: ComicFormValues): ContentWritePayload {
  return {
    cntntsNm: values.name,
    cntntsTitle: values.name,
    // 컷은 한 첨부 아래 fileSn 1..N으로 들어간다 — 배열 값이 모두 같은 첨부 아이디다.
    atchFileId: values.cutFileIds[0] ?? '',
    thumbAtchFileId: values.thumbnailFileId,
    useYn: values.isVisible ? 'Y' : 'N',
    keywordList: toKeywordPayload(values.keywords),
    schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
  };
}


/**
 * 등록은 `@RequestBody`가 없어 form으로 간다 — 중첩 목록은 Spring이 읽는 인덱스 표기
 * (`keywordList[0].cntntsKeyword`)로 편다. JSON으로 보내면 한 칸도 바인딩되지 않는다.
 *
 * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로
 * 통일한다(사용자가 그 방향으로 확정했고, 백엔드 반영만 남았다). 반영 여부는 배포 서버의
 * `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 확인한다 — 지금은
 * `parameters: [{ name: "requestVo", in: "query" }]`라 JSON 본문을 보내면 400이 온다.
 */
function toFormParams(
  payload: ContentWritePayload & { cntntsType?: string }
): Record<string, string | number | undefined> {
  const params: Record<string, string | number | undefined> = {
    cntntsType: payload.cntntsType,
    cntntsNm: payload.cntntsNm,
    cntntsTitle: payload.cntntsTitle,
    atchFileId: payload.atchFileId,
    thumbAtchFileId: payload.thumbAtchFileId,
    useYn: payload.useYn,
  };

  payload.keywordList.forEach((item, index) => {
    params[`keywordList[${index}].cntntsKeyword`] = item.cntntsKeyword;
    params[`keywordList[${index}].cntntsExpln`] = item.cntntsExpln;
  });
  payload.schulGrdList.forEach((item, index) => {
    params[`schulGrdList[${index}].schulCd`] = item.schulCd;
    // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다.
    params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined;
  });

  return params;
}

/**
 * 실패한 쓰기의 사유와 보낸 본문을 남긴다. 요청 자체는 테스트 환경에서 backend-fetch가
 * 전부 찍으므로(중앙 로거) 여기는 실패만 맡는다 — 운영에서도 원인 추적에 이게 유일한 단서다.
 */
function logWriteFailure(
  method: string,
  path: string,
  body: unknown,
  form: Record<string, string | number | undefined> | undefined,
  failure: { code: number; message: string }
): void {
  const lines = [
    `[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`,
    body === undefined ? null : `body(JSON):\n${JSON.stringify(body, null, 2)}`,
    form === undefined
      ? null
      : `form:\n${new URLSearchParams(
          Object.entries(form).flatMap(([key, value]) =>
            value === undefined ? [] : [[key, String(value)] as [string, string]]
          )
        ).toString()}`,
  ].filter(Boolean);

  console.error(lines.join('\n'));
}

async function sendWrite(
  path: string,
  method: 'POST' | 'PUT' | 'DELETE',
  body?: unknown,
  form?: Record<string, string | number | undefined>
): Promise<string | null> {
  const accessToken = await getSessionAccessToken();

  const result = await backendFetch<unknown>(path, {
    method,
    ...(body === undefined ? {} : { body }),
    ...(form === undefined ? {} : { form }),
    accessToken: accessToken ?? undefined,
    cache: 'no-store',
    canHaveNullData: true,
    canHaveEmptyBody: true,
  });

  if (!result.ok) {
    logWriteFailure(method, path, body, form, result);
    throw new BackendRequestError(result);
  }

  return typeof result.data === 'string' ? result.data : null;
}

export async function createContent(
  kind: ContentApiKind,
  payload: ContentWritePayload,
  cntntsType: string
): Promise<string | null> {
  return sendWrite(CONTENT_BASE_PATH, 'POST', undefined, toFormParams({ ...payload, cntntsType }));
}

export async function updateContent(
  kind: ContentApiKind,
  cntntsId: string,
  payload: ContentWritePayload
): Promise<void> {
  await sendWrite(
    `${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsId)}`,
    'PUT',
    // 수정 SQL이 `CNTNTS_TYPE = #{cntntsType}`을 조건 없이 덮는다. 빼고 보내면 종류가 지워져
    // 목록(`CNTNTS_TYPE = 'V'|'T'`)에서 사라진다.
    { ...payload, cntntsType: CONTENT_TYPE_CODE[kind] }
  );
}

export async function deleteContent(
  kind: ContentApiKind,
  cntntsId: string
): Promise<void> {
  await sendWrite(`${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsId)}`, 'DELETE');
}

export function contentThumbnailUrl(item: ContentItem): string | null {
  return buildFileImageUrl(item.thumbnailFileId);
}

/** 컷 이미지 주소 — 응답이 준 파일 번호를 쓰고, 없으면 1..count로 채운다. */
export function contentCutUrls(item: ContentItem, count: number): string[] {
  const numbers =
    item.cutFileNumbers.length > 0
      ? item.cutFileNumbers
      : Array.from({ length: count }, (_, index) => index + 1);

  return numbers
    .map((fileSn) => buildFileImageUrl(item.attachmentId, fileSn))
    .filter((url): url is string => url !== null);
}
