import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import {
  buildFileImageUrl,
  fetchAttachmentFiles,
} from '@/lib/data/repositories/file-repository';
import type { MerchantDetail, MerchantDetailSummary } from '@/lib/domain/merchant-detail';
import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form';
import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';

/**
 * 가맹몰 상세 Repository — edupay-backend origin/develop 1c7b0e0(2026-09-17) 기준.
 *
 * ```
 * GET    /api/v1/mngr/voc/detail/pagination   상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL) + 메뉴 건수
 * GET    /api/v1/mngr/voc/detail/{frcsNo}     상세가 없으면 성공 봉투에 data: null (구 버전은 NOT_FOUND)
 * POST   /api/v1/mngr/voc/detail              @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404
 * PUT    /api/v1/mngr/voc/detail/{frcsNo}     @RequestBody · 전 컬럼 덮어씀
 * DELETE /api/v1/mngr/voc/{frcsNo}            논리 삭제(DEL_YN)
 * GET    /api/v1/mngr/voc/pagination          가맹점 전체 — 목록 합치기·등록 선택지·상세 없는 단건에 쓴다
 * ```
 *
 * 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호·
 * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)·메뉴 건수(`productCnt`)를
 * 돌려준다. 업종·업태 이름(`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 시안에 자리가 없어
 * 읽지 않는다. 카테고리 코드표 조인은 1c7b0e0에서 빠져 어느 그룹의 코드든 목록에 남는다.
 * 추가 이미지 장수는 묶음 파일 목록 API로 알아낸다(더듬기 없음).
 *
 * ⚠️ 백엔드 결함(보고함, 미수정):
 * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 목록은 가맹점 목록의
 *    이름으로 메우고, 단건은 코드만 오면 가맹점 목록을 한 번 더 읽는다.
 * 2. `selectOne`이 DEL_YN을 거르지 않는다. 삭제한 상세가 남은 가맹점은 목록·앱에서는 빠지는데 단건은
 *    삭제된 값을 돌려주고(수정 화면이 그 값을 보인다) 등록은 409로 막힌다 — 삭제 뒤 재등록 불가.
 *    응답에 delYn이 없어(@JsonIgnore) 프론트가 가려낼 수도 없다.
 * 3. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 부풀어 온다 — 목록을 여기서 합쳐 세므로 쓰지 않는다.
 * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다 — 여기서 등록일로 거른다.
 * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
 */

const DETAIL_PATH = '/api/v1/mngr/voc/detail';
const VOC_PATH = '/api/v1/mngr/voc';
// 등록 선택지·상세 보유 목록을 한 번에 훑는 크기. 가맹점은 많아야 수백 건이다.
const SCAN_PAGE_SIZE = 500;
const MAX_SCAN_PAGES = 20;

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];
  if (typeof value === 'string' && value !== '') return value;
  if (typeof value === 'number') return String(value);
  return null;
}

function readNumber(source: Record<string, unknown>, key: string): number | null {
  const value = source[key];
  if (typeof value === 'number' && Number.isFinite(value)) return value;
  if (typeof value === 'string' && value.trim() !== '') {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : null;
  }
  return null;
}

function readYesNo(source: Record<string, unknown>, key: string): boolean | null {
  const value = readString(source, key);
  return value === null ? null : value.toUpperCase() === 'Y';
}

/**
 * 등록일은 `d.FRST_REG_DT`가 별칭 없이 오므로 `frstRegDt`(Date → ISO 문자열 또는 epoch)로
 * 받는다. 다른 화면의 `frstRegDtStr`가 있으면 그것을 우선한다.
 */
function readDate(source: Record<string, unknown>, key: string): string | null {
  const formatted = readString(source, `${key}Str`);
  if (formatted) return formatted.slice(0, 10);

  const raw = source[key];
  const date =
    typeof raw === 'number'
      ? new Date(raw)
      : typeof raw === 'string' && raw !== ''
        ? new Date(raw)
        : null;
  if (date === null || Number.isNaN(date.getTime())) return null;
  return date.toISOString().slice(0, 10);
}

/** 상세 목록·단건의 한 행. 가맹점 API의 행은 `toCandidate`가 따로 맡는다. */
function toSummary(raw: unknown): MerchantDetailSummary[] {
  if (!isRecord(raw)) return [];
  const id = readString(raw, 'frcsNo');
  if (!id) return [];

  const registeredAt = readDate(raw, 'frstRegDt');
  return [
    {
      id,
      // 결함 1 — 업체명이 안 오면 코드라도 보인다.
      name: readString(raw, 'frcsNm') ?? id,
      phoneNumber: readString(raw, 'rprsvTelno'),
      address: readString(raw, 'frcsPostAddr'),
      addressDetail: readString(raw, 'frcsDaddr'),
      postalCode: readString(raw, 'frcsPostZip'),
      thumbnailFileId: readString(raw, 'thumbAtchFileId'),
      thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
      categoryCode: readString(raw, 'frcsTypeCd'),
      categoryLabel: readString(raw, 'frcsTypeCdNm'),
      isVisible: readYesNo(raw, 'useYn'),
      productCount: readNumber(raw, 'productCnt'),
      registeredAt,
      hasDetail:
        registeredAt !== null ||
        readString(raw, 'thumbAtchFileId') !== null ||
        readString(raw, 'frcsTypeCd') !== null,
    },
  ];
}

/** 가맹점 API(`/mngr/voc/pagination`)의 한 행 — 아직 상세가 없는 가맹점의 선택지. */
function toCandidate(raw: unknown): MerchantDetailSummary[] {
  if (!isRecord(raw)) return [];
  const id = readString(raw, 'frcsNo');
  const name = readString(raw, 'frcsNm');
  if (!id || !name) return [];
  return [
    {
      id,
      name,
      phoneNumber: readString(raw, 'rprsvTelno'),
      address: readString(raw, 'frcsPostAddr'),
      addressDetail: readString(raw, 'frcsDaddr'),
      postalCode: readString(raw, 'frcsPostZip'),
      thumbnailFileId: null,
      thumbnailUrl: null,
      categoryCode: null,
      categoryLabel: null,
      isVisible: null,
      productCount: null,
      registeredAt: null,
      hasDetail: false,
    },
  ];
}

/** 추가 이미지 묶음의 URL을 `fileSn` 순으로 — 묶음 파일 목록 API(백엔드 1c7b0e0)로 장수를 안다. */
async function resolveImageUrls(fileId: string | null): Promise<string[]> {
  if (!fileId) return [];
  const files = await fetchAttachmentFiles(fileId);
  return files
    .map((file) => buildFileImageUrl(fileId, file.fileSn))
    .filter((url): url is string => url !== null);
}

async function toDetail(raw: unknown, withImages: boolean): Promise<MerchantDetail | null> {
  const [summary] = toSummary(raw);
  if (!summary || !isRecord(raw)) return null;

  const imageFileId = readString(raw, 'atchFileId');
  return {
    ...summary,
    imageUrls: withImages ? await resolveImageUrls(imageFileId) : [],
    description: readString(raw, 'frcsCn'),
    openTime: readString(raw, 'startHour'),
    closeTime: readString(raw, 'endHour'),
    closedDays: readString(raw, 'offDayCn'),
    imageFileId,
    sortOrder: readNumber(raw, 'sortOrdr'),
    registeredBy: readString(raw, 'frstRgtrNmStr') ?? readString(raw, 'frstRgtrNm'),
    modifiedAt: readDate(raw, 'lastMdfcnDt'),
    // 수정자는 `lastMdfrNm`이 JSON에서 빠지고 *Str 별칭도 없어 아직 안 온다.
    modifiedBy: readString(raw, 'lastMdfrNm'),
  };
}

/**
 * 목록 — 가맹점 전체에 상세를 덧입혀 상세가 없는 가맹점도 세운다(사용자 확정). 백엔드 목록은 상세가
 * 있는 것만 주므로 가맹점 목록과 상세 목록을 모두 받아 합치고, 검색·기간·페이지는 여기서 처리한다.
 * 부수 효과로 백엔드가 쓰지 않던 등록일 기간 조건과 응답에 빠진 업체명(결함 1)이 여기서 채워진다.
 */
export async function fetchMerchantDetails(
  query: MerchantDetailQuery
): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
  const merged = await fetchMergedSummaries();
  const keyword = query.keyword.trim().toLowerCase();
  const filtered = merged.filter((row) => {
    if (keyword && !row.name.toLowerCase().includes(keyword)) return false;
    if (query.category && row.categoryCode !== query.category) return false;
    if (query.visibility && row.isVisible !== (query.visibility === 'Y')) return false;
    if (query.startDate && (row.registeredAt === null || row.registeredAt < query.startDate)) return false;
    if (query.endDate && (row.registeredAt === null || row.registeredAt > query.endDate)) return false;
    return true;
  });
  const start = (query.page - 1) * query.pageSize;
  return {
    items: filtered.slice(start, start + query.pageSize),
    totalCount: filtered.length,
  };
}

/** 상세 있는 가맹점을 등록일 내림차순으로 먼저, 나머지 가맹점을 코드 내림차순으로 뒤에 둔다. */
function compareSummaryOrder(a: MerchantDetailSummary, b: MerchantDetailSummary): number {
  if (a.hasDetail !== b.hasDetail) return a.hasDetail ? -1 : 1;
  const dateA = a.registeredAt ?? '';
  const dateB = b.registeredAt ?? '';
  if (dateA !== dateB) return dateA < dateB ? 1 : -1;
  return b.id.localeCompare(a.id, undefined, { numeric: true });
}

async function fetchMergedSummaries(): Promise<MerchantDetailSummary[]> {
  const [merchants, details] = await Promise.all([
    fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
    fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
  ]);
  const byId = new Map(merchants.map((row) => [row.id, row]));
  for (const detail of details) {
    // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 섞어 준다 — 상세 있는 행만 덧입힌다.
    if (!detail.hasDetail) continue;
    const merchant = byId.get(detail.id);
    byId.set(detail.id, {
      ...detail,
      // 결함 1 — 상세 응답에 업체명이 없으면 가맹점 목록의 이름을 쓴다.
      name: detail.name === detail.id && merchant ? merchant.name : detail.name,
    });
  }
  return [...byId.values()].sort(compareSummaryOrder);
}

/**
 * 단건. `withImages`를 끄면 추가 이미지 목록 조회를 건너뛴다 — 노출 토글처럼 값만 다시 보내는 곳은
 * URL이 필요 없다.
 */
export async function fetchMerchantDetail(
  frcsNo: string,
  { withImages = true }: { withImages?: boolean } = {}
): Promise<MerchantDetail | null> {
  const accessToken = await getSessionAccessToken();

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

  if (!result.ok) {
    // 구 버전은 상세가 없으면 NOT_FOUND 봉투였다. 지금은 성공 봉투에 data: null이라 아래 toDetail이 null을 준다.
    if (result.code === 404) return null;
    throw new BackendRequestError(result);
  }
  return toDetail(result.data, withImages);
}

async function fetchPage<T>(
  path: string,
  pageIndex: number,
  pageSize: number,
  toRow: (raw: unknown) => T[]
): Promise<{ rows: T[]; rowCount: number; totalCount: number }> {
  const accessToken = await getSessionAccessToken();
  const result = await backendFetch<unknown>(path, {
    method: 'GET',
    query: { pageIndex, recordCountPerPage: pageSize },
    accessToken: accessToken ?? undefined,
    cache: 'no-store',
    canHaveNullData: true,
  });
  if (!result.ok) {
    throw new BackendRequestError(result);
  }
  const data = result.data;
  if (!isRecord(data) || !Array.isArray(data.list)) {
    return { rows: [], rowCount: 0, totalCount: 0 };
  }
  return {
    rows: data.list.flatMap(toRow),
    rowCount: data.list.length,
    totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
  };
}

async function fetchAllPages<T>(path: string, toRow: (raw: unknown) => T[]): Promise<T[]> {
  const rows: T[] = [];
  let seen = 0;
  for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) {
    const page = await fetchPage(path, pageIndex, SCAN_PAGE_SIZE, toRow);
    rows.push(...page.rows);
    seen += page.rowCount;
    if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) break;
  }
  return rows;
}

/**
 * 방금 만든 가맹점의 코드 — 등록 응답에 코드가 없을 때 가맹점 목록에서 이름·연락처로 찾는다.
 * 같은 이름이 여럿이면 가장 최근 것(목록이 등록일 내림차순).
 */
export async function findMerchantByName(
  name: string,
  phoneNumber: string
): Promise<MerchantDetailSummary | null> {
  const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate);
  const digits = phoneNumber.replace(/\D/g, '');
  return (
    merchants.find(
      (row) => row.name === name && (row.phoneNumber ?? '').replace(/\D/g, '') === digits
    ) ??
    merchants.find((row) => row.name === name) ??
    null
  );
}

/** 가맹점 한 건(상세 없이) — 가맹점 단건 API가 없어 목록에서 찾는다. */
export async function fetchMerchantSummary(
  frcsNo: string
): Promise<MerchantDetailSummary | null> {
  const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate);
  return merchants.find((row) => row.id === frcsNo) ?? null;
}

function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
  return {
    frcsNo: values.frcsNo,
    frcsTypeCd: values.categoryCode,
    frcsCn: values.description,
    startHour: values.openTime,
    endHour: values.closeTime,
    offDayCn: values.closedDays,
    thumbAtchFileId: values.thumbnailFileId,
    atchFileId: values.imageFileId || null,
    sortOrdr: values.sortOrder,
    useYn: values.isVisible ? 'Y' : 'N',
    // 요청 VO에 없어 버려진다(⚠️ 3). 자리가 생기면 그대로 저장된다.
    voucherUseYn: values.isVoucherUsable ? 'Y' : 'N',
  };
}

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

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

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

export async function createMerchantDetail(
  values: MerchantDetailValues
): Promise<void> {
  await sendWrite(DETAIL_PATH, 'POST', toWritePayload(values));
}

export async function updateMerchantDetail(
  values: MerchantDetailValues
): Promise<void> {
  await sendWrite(
    `${DETAIL_PATH}/${encodeURIComponent(values.frcsNo)}`,
    'PUT',
    toWritePayload(values)
  );
}

export async function deleteMerchantDetail(frcsNo: string): Promise<void> {
  await sendWrite(`${VOC_PATH}/${encodeURIComponent(frcsNo)}`, 'DELETE');
}
