// 변경 이력 — 실서버 GET /admin/audit/list (AdminAuditLogController, yumyumpickup#287)
//
// 왜 있는가. 2026-09-29 에 이 어드민에서 FAQ 목록의 **맨 위 행**을 눌러 시드 FAQ(id=1)를
// 지웠습니다. "누가 무엇을 지웠나"에 화면이 답하지 못해서 사람이 서버 코드를 읽어야 했습니다.
// 그 질문에 답하는 화면이 이것입니다.
//
// 두 갈래로 씁니다(서버가 이 두 쓰임을 보고 만든 API 입니다):
//  1) 대상으로 — ?targetType=FAQ&targetId=16 → 상세 드로어의 "변경 이력"(AuditTrail).
//     "이 FAQ 를 누가 만졌나".
//  2) 사람으로 — ?aid=10000001&fromDate=… → 이 화면(AuditLog). "이 운영자가 무엇을 했나".
//
// 2026-09-30 dev 에서 직접 부르고 응답을 본 것만 그립니다. 응답 모양과 주의점은 api.js 의
// `audit` 주석에, action·targetType 라벨은 labels.jsx 의 AUDIT_ACTION 에 있습니다.
//
// 화면이 지키는 것 넷 — 넷 다 "빈 칸을 결함으로 읽지 않게" 하려는 것입니다:
//  1) detailPurged=true 를 **빈 칸으로 두지 않습니다.** 보관 기한이 지나 지웠다고 적습니다.
//  2) 상세를 일부러 남기지 않는 조작(비밀번호 초기화·계정 발급)도 그렇다고 적습니다.
//  3) 이력에 **개인정보가 없습니다**(일부러). "왜 사유가 없지" 하고 빈 칸을 만들지 않습니다.
//  4) 1단계라 **정산·쿠폰·주문·가게·메뉴는 아직 안 남습니다.** 고를 수 없게 하고, 적어 둡니다.
//
// 서버가 주지 않아 화면에 없는 것(지어내지 않습니다):
//  - 운영자 이름으로 찾기: 필터는 aid(숫자)뿐입니다. adminName 은 응답에만 있습니다.
//  - 대상의 이름: 응답에 targetId 만 있습니다. "FAQ 19" 처럼 번호로만 가리킵니다
//    (summary 에 그 번호가 들어 있어 그대로 읽힙니다).
//  - 이력 내보내기(CSV)·정렬 바꾸기: 그런 파라미터가 없습니다. 정렬은 최신순 하나입니다.

const AUDIT_PAGE_SIZE = 20;

// before/after JSON 안의 칸 이름 → 사람이 읽는 말. 서버가 담는 칸만 적습니다
// (AdminAuditLogger.json 을 부르는 자리 전부를 2026-09-30 에 읽고 옮겼습니다).
// 모르는 칸은 서버 이름을 그대로 보여 줍니다 — 없는 뜻을 붙이지 않습니다.
const AUDIT_FIELD = {
  title: '제목',
  content: '내용',
  isDelete: '삭제 여부',
  status: '상태',
  startDate: '게시 시작',
  endDate: '게시 종료',
  requestStatus: '삭제요청 처리',
  word: '금칙어',
  scope: '적용 범위',
  randomNickname: '닉네임 단어',
  code: '약관 코드',
  version: '버전',
  body: '본문',
};
const auditFieldLabel = (k) => AUDIT_FIELD[k] || k;

// 값은 서버가 준 그대로 보여 줍니다. 딱 하나, isDelete 의 'Y'·'N' 은 한 글자로는 뜻을 알 수
// 없어서 풀어 씁니다("Y → 삭제됨"이 아니라 "삭제됨"으로만 적으면 원래 값이 사라지므로 둘 다).
const auditFieldValue = (key, v) => {
  if (v === null || v === undefined || v === '') return '';
  const s = String(v);
  if (key === 'isDelete') return s === 'Y' ? 'Y (삭제됨)' : s === 'N' ? 'N (사용중)' : s;
  return s;
};

// beforeValue·afterValue 는 **JSON 문자열**입니다. 🔴 칼럼이 1000자라 넘치면 잘리고 '…' 가
// 붙어 유효한 JSON 이 아닙니다 — 그때는 파싱을 포기하고 **글자 그대로** 보여 줍니다.
// 지어내거나 빈 칸으로 두지 않습니다.
const parseAuditValue = (raw) => {
  if (raw == null || String(raw).trim() === '') return { kind: 'empty' };
  const text = String(raw);
  try {
    const obj = JSON.parse(text);
    if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
      const keys = Object.keys(obj);
      if (keys.length > 0) return { kind: 'object', obj, keys };
    }
    // 객체가 아니거나 빈 객체면 보여 줄 짝이 없습니다 — 글자로 둡니다.
    return { kind: 'text', text };
  } catch (e) {
    // 잘린 기록입니다(또는 우리가 모르는 모양입니다). 끝에 '…' 가 있으면 잘린 것이 확실합니다.
    return { kind: 'text', text, truncated: /…\s*$/.test(text) };
  }
};

// 한쪽(이전 또는 이후) 한 칸. 두 칸을 나란히 두고 각자의 기록을 그립니다 —
// 칸별로 짝지어 "A → B" 로 그리면, 한쪽에만 있는 칸(삭제 때의 제목·내용 스냅숏)이
// "값이 비워졌다"로 잘못 읽힙니다. 각 쪽은 그 시점의 스냅숏이지 필드별 비교가 아닙니다.
const AuditValueBox = ({ label, raw }) => {
  const v = parseAuditValue(raw);
  return (
    <div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '9px 11px', minWidth: 0 }}>
      <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-3)', marginBottom: 6 }}>{label}</div>
      {v.kind === 'empty' && (
        <div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>기록 없음</div>
      )}
      {v.kind === 'text' && (
        <>
          {/* 잘린 기록은 1000자짜리 덩어리라, 그대로 펼치면 행 하나가 화면을 덮습니다.
              내용을 줄이지 않고 칸 높이만 묶어 둡니다(안에서 스크롤). */}
          <pre className="t-mono" style={{
            margin: 0, fontSize: 12, lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
            color: 'var(--text-2)', maxHeight: 200, overflow: 'auto',
          }}>{v.text}</pre>
          {/* 왜 예쁘게 안 나오는지 말해 줍니다. 안 적으면 운영자가 화면 결함으로 읽습니다. */}
          <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
            {v.truncated
              ? '기록이 1000자를 넘어 서버가 뒤를 잘랐습니다. 잘린 뒤는 남아 있지 않습니다.'
              : '읽을 수 있는 모양이 아니라 서버가 준 글자 그대로 보여 줍니다.'}
          </div>
        </>
      )}
      {v.kind === 'object' && (
        <div style={{ display: 'grid', gap: 5 }}>
          {v.keys.map((k) => {
            const text = auditFieldValue(k, v.obj[k]);
            return (
              <div key={k} style={{ fontSize: 12.5, lineHeight: 1.5 }}>
                <span style={{ color: 'var(--text-3)' }}>{auditFieldLabel(k)}</span>
                <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text)' }}>
                  {text === '' ? <span style={{ color: 'var(--text-3)' }}>(빈 값)</span> : text}
                </div>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
};

// 한 행의 상세(이전·이후). 비는 경우마다 **왜 비었는지**를 적습니다.
const AuditValues = ({ row }) => {
  // 🔴 보관 기한(상세 1년)이 지나 서버가 두 칸을 지운 행입니다. 빈 칸으로 두면 결함으로 읽힙니다.
  if (row.detailPurged) {
    return (
      <Notice>
        보관 기한이 지나 상세를 지웠습니다. 누가·언제·무엇을 했는지는 위에 그대로 남아 있습니다
        (상세는 1년, 이력 자체는 3년 보관).
      </Notice>
    );
  }
  const hasBefore = row.beforeValue != null && String(row.beforeValue).trim() !== '';
  const hasAfter = row.afterValue != null && String(row.afterValue).trim() !== '';
  if (!hasBefore && !hasAfter) {
    // 상세를 아예 남기지 않는 조작입니다(서버 AdminAuditDetailPolicy = NONE).
    // 담을 수 있는 값이 개인정보뿐이라 일부러 비웁니다 — 그렇다고 적습니다.
    if ((AUDIT_ACTION[row.action] || {}).detail === 'none') {
      return (
        <div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.6 }}>
          이 조작은 상세를 남기지 않습니다. 담을 수 있는 값이 개인정보(임시 비밀번호 · 로그인 아이디 ·
          연락처)뿐이라 서버가 일부러 비웁니다. 무엇을 했는지는 위의 한 줄이 전부입니다.
        </div>
      );
    }
    return <div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>상세 기록이 없습니다.</div>;
  }
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
      <AuditValueBox label="이전" raw={row.beforeValue}/>
      <AuditValueBox label="이후" raw={row.afterValue}/>
    </div>
  );
};

const auditActor = (row) => {
  if (row.adminName) return row.adminName;
  // 서버가 admin_info 조인으로 이름을 채웁니다. 비면 지워진 계정이거나 시스템이 남긴 행입니다.
  return row.aid != null ? `관리자 #${row.aid}` : '알 수 없음';
};

/* ------------------------------------------------------------------ */
/* 드로어 안에 넣는 "변경 이력" 한 덩이                                  */
/* ------------------------------------------------------------------ */
// 상세 화면(FAQ 드로어 등)에서 ?targetType=…&targetId=… 로 부릅니다.
// 카드 목록입니다 — 드로어가 좁아 표는 읽히지 않습니다.
//
// `createNote` 는 "등록은 이력에 남지 않는다" 처럼 **그 도메인에서 안 남는 조작**을 적는 자리입니다.
// FAQ·공지는 수정·삭제(·복구)만 남고 **등록은 남지 않습니다**(서버 AdminAuditActionEnum 에
// FAQ_REGISTER 가 없습니다 — 2026-09-30 확인). 안 적으면 "만든 기록이 왜 없지"가 됩니다.
const AuditTrail = ({ targetType, targetId, createNote }) => {
  const enabled = targetType && targetId != null && targetId !== '';
  const list = useLive(
    () => (enabled ? api.live.audit.list({ targetType, targetId, page: 1, size: AUDIT_PAGE_SIZE }) : null),
    [targetType, String(targetId)],
  );
  const rows = listRows(list.data);
  const total = listTotal(list.data);
  const [openId, setOpenId] = React.useState(null);

  return (
    <div>
      <SectionTitle right={list.data && total != null ? `${fmtNum(total)}건` : ''}>변경 이력</SectionTitle>
      <LiveBlock state={list} empty={rows.length === 0} compact
        emptyTitle="남아 있는 변경 이력이 없습니다">
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          {rows.map((r) => {
            const open = openId === r.logId;
            return (
              <div key={r.logId} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '9px 11px' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                  <Badge tone={auditActionTone(r.action)}>{auditActionLabel(r.action)}</Badge>
                  <span style={{ fontSize: 13, color: 'var(--text-2)' }}>{auditActor(r)}</span>
                  <span style={{ marginLeft: 'auto', fontSize: 12.5, color: 'var(--text-3)' }}>{fmtDT(r.regDate)}</span>
                </div>
                <div style={{ fontSize: 13.5, marginTop: 6 }}>{r.summary || '—'}</div>
                <button className="btn btn-ghost btn-sm" style={{ marginTop: 8 }}
                  onClick={() => setOpenId(open ? null : r.logId)}>
                  {open ? '상세 접기' : '상세 보기'}
                </button>
                {open && <div style={{ marginTop: 8 }}><AuditValues row={r}/></div>}
              </div>
            );
          })}
        </div>
      </LiveBlock>
      {/* 비어 있는 것을 "아무 일도 없었다"로 읽지 않게 — 무엇이 안 남는지 먼저 말합니다. */}
      {!list.loading && !list.error && (
        <div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 10, lineHeight: 1.6 }}>
          변경 이력은 {AUDIT_SINCE} 부터 남습니다. 그 전에 있었던 수정·삭제는 기록이 없습니다.
          {createNote ? ` ${createNote}` : ''}
        </div>
      )}
      {/* 한 화면에 20건까지만 싣습니다. 더 있으면 전체 화면으로 보냅니다. */}
      {total != null && total > rows.length && (
        <div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 6 }}>
          최근 {rows.length}건만 보여 줍니다. 전체 {fmtNum(total)}건은 왼쪽 메뉴의 "변경 이력"에서
          대상 종류 {auditTargetTypeLabel(targetType)} · 대상 번호 {String(targetId)} 로 찾을 수 있습니다.
        </div>
      )}
    </div>
  );
};

/* ------------------------------------------------------------------ */
/* 전체 화면 — "이 운영자가 무엇을 했나"                                  */
/* ------------------------------------------------------------------ */
// 다른 화면에서 조건을 들고 넘어오는 통로. 라우팅이 문자열 하나(route)뿐이라
// 조건을 여기 한 번만 맡겨 두고, AuditLog 가 뜰 때 꺼내 씁니다(한 번 꺼내면 비웁니다).
let pendingAuditFilter = null;
const openAuditFor = (filter, setRoute) => {
  pendingAuditFilter = filter || null;
  if (setRoute) setRoute('audit');
};

const EMPTY_AUDIT_FILTER = { aid: '', targetType: '', targetId: '', action: '', fromDate: '', toDate: '' };

const AuditLog = () => {
  const initial = React.useMemo(() => {
    const f = pendingAuditFilter || {};
    pendingAuditFilter = null;
    // 넘어온 값은 문자열로 맞춥니다 — 부르는 쪽이 aid·targetId 를 숫자로 줄 수 있고,
    // 그러면 아래 .trim() 이 터집니다(입력 칸도 문자열만 받습니다).
    const out = { ...EMPTY_AUDIT_FILTER };
    Object.keys(EMPTY_AUDIT_FILTER).forEach((k) => {
      if (f[k] != null) out[k] = String(f[k]);
    });
    return out;
  }, []);
  const [draft, setDraft] = React.useState(initial);
  const [applied, setApplied] = React.useState(initial);
  const [page, setPage] = React.useState(1);
  const [formError, setFormError] = React.useState('');
  const [openId, setOpenId] = React.useState(null);

  const set = (k, v) => setDraft((d) => {
    const next = { ...d, [k]: v };
    // 대상 종류를 바꾸면 그 종류의 조작이 아닌 것은 버립니다 — 서버는 두 조건을 AND 로 걸어
    // 짝이 안 맞으면 늘 0건이고, 그걸 "조작이 없었다"로 읽게 됩니다.
    if (k === 'targetType' && next.action && auditTargetTypeOf(next.action) !== v) next.action = '';
    return next;
  });

  const apply = () => {
    // 🔴 서버는 해석 안 되는 필터를 **오류 없이 버립니다**(오류 코드가 없습니다). 그래서 잘못 적은
    // 조건이 조용히 빠진 목록을 운영자가 "조건에 맞는 것이 없다"로 읽습니다. 여기서 먼저 막습니다.
    if (draft.aid.trim() && !/^\d+$/.test(draft.aid.trim())) {
      setFormError('관리자 번호는 숫자만 입력하세요. 숫자가 아니면 서버가 그 조건을 조용히 무시합니다.'); return;
    }
    if (draft.targetId.trim() && !draft.targetType) {
      setFormError('대상 번호로 찾으려면 대상 종류를 함께 고르세요. 번호만으로는 다른 종류의 같은 번호까지 섞입니다.'); return;
    }
    if (draft.fromDate && draft.toDate && draft.fromDate > draft.toDate) {
      setFormError('시작일이 종료일보다 늦습니다.'); return;
    }
    setFormError('');
    setApplied({ ...draft });
    setPage(1);
  };
  const reset = () => {
    setDraft({ ...EMPTY_AUDIT_FILTER });
    setApplied({ ...EMPTY_AUDIT_FILTER });
    setFormError('');
    setPage(1);
  };

  const query = React.useMemo(() => {
    const q = { page, size: AUDIT_PAGE_SIZE };
    if (applied.aid.trim()) q.aid = applied.aid.trim();
    if (applied.targetType) q.targetType = applied.targetType;
    if (applied.targetId.trim()) q.targetId = applied.targetId.trim();
    if (applied.action) q.action = applied.action;
    if (applied.fromDate) q.fromDate = applied.fromDate;
    if (applied.toDate) q.toDate = applied.toDate;
    return q;
  }, [applied, page]);
  const list = useLive(() => api.live.audit.list(query), [JSON.stringify(query)]);
  const rows = listRows(list.data);
  const total = listTotal(list.data);
  const lastPage = listLastPage(list.data, AUDIT_PAGE_SIZE);
  const hasNext = lastPage == null ? rows.length === AUDIT_PAGE_SIZE : page < lastPage;
  const filtered = Object.keys(EMPTY_AUDIT_FILTER).some((k) => String(applied[k] || '').trim() !== '');
  const actionOptions = auditActionsFor(draft.targetType);

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1>변경 이력</h1>
          <div className="sub">관리자가 무엇을 바꿨는지 남긴 기록입니다. 고치거나 지울 수 없습니다</div>
        </div>
        <div className="actions">
          <button className="btn btn-ghost btn-sm" onClick={list.reload} disabled={list.loading}>
            <Icon name="refresh" size={14}/>새로고침
          </button>
        </div>
      </div>

      {/* 비어 있는 것을 결함으로 읽지 않게 — 무엇이 남고 무엇이 아직 안 남는지 먼저 말합니다. */}
      <div style={{ marginBottom: 12, display: 'grid', gap: 8 }}>
        <Notice>
          {AUDIT_SINCE} 부터 남습니다. 그 전에 있었던 조작은 기록이 없습니다. {AUDIT_UNTRACKED_TEXT}
        </Notice>
        <Notice>{AUDIT_NO_PII_TEXT}</Notice>
      </div>

      <div className="table-wrap" data-loading={list.loading ? 'true' : 'false'}>
        <div className="table-toolbar" style={{ flexWrap: 'wrap' }}>
          <input className="input" style={{ width: 150 }} placeholder="관리자 번호 (숫자)"
            aria-label="관리자 번호" value={draft.aid}
            onChange={(e) => set('aid', e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}/>
          <select className="select" value={draft.targetType} aria-label="대상 종류"
            onChange={(e) => set('targetType', e.target.value)}>
            <option value="">전체 대상</option>
            {AUDIT_TARGET_TYPE_OPTIONS.map((t) => <option key={t} value={t}>{auditTargetTypeLabel(t)}</option>)}
          </select>
          <input className="input" style={{ width: 130 }} placeholder="대상 번호"
            aria-label="대상 번호" value={draft.targetId}
            onChange={(e) => set('targetId', e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}/>
          <select className="select" value={draft.action} aria-label="조작"
            onChange={(e) => set('action', e.target.value)}>
            <option value="">전체 조작</option>
            {actionOptions.map((a) => <option key={a} value={a}>{auditActionLabel(a)}</option>)}
          </select>
          <input className="input" type="date" value={draft.fromDate} aria-label="기간 시작"
            onChange={(e) => set('fromDate', e.target.value)}/>
          <span style={{ color: 'var(--text-3)' }}>~</span>
          <input className="input" type="date" value={draft.toDate} aria-label="기간 끝"
            onChange={(e) => set('toDate', e.target.value)}/>
          <button className="btn btn-primary btn-sm" onClick={apply}><Icon name="search" size={14}/>조회</button>
          <button className="btn btn-ghost btn-sm" onClick={reset}>초기화</button>
        </div>
        {formError && <div style={{ padding: '0 16px 10px' }}><Notice tone="danger">{formError}</Notice></div>}
        {/* 새 조건으로 불러오는 동안 이전 결과를 이전 결과라고 말해 줍니다. */}
        {list.loading && list.data && (
          <div style={{ padding: '0 16px 10px', fontSize: 12.5, color: 'var(--text-3)' }}>
            새 조건으로 불러오는 중… 아래는 이전 결과입니다.
          </div>
        )}

        <LiveBlock state={list} empty={rows.length === 0}
          emptyTitle={page > 1 ? '이 페이지에는 기록이 없습니다' : (filtered ? '조건에 맞는 기록이 없습니다' : '남아 있는 기록이 없습니다')}
          emptyDesc={page > 1 ? undefined : (filtered
            ? `조건을 넓혀 보세요. ${AUDIT_UNTRACKED_TEXT}`
            : `관리자가 무엇을 바꾸면 여기에 쌓입니다. ${AUDIT_SINCE} 부터 남습니다.`)}>
          <table className="table" style={{ opacity: list.loading ? 0.45 : 1 }}>
            <thead><tr>
              <th style={{ width: 150 }}>일시</th>
              <th style={{ width: 150 }}>관리자</th>
              <th style={{ width: 160 }}>조작</th>
              <th>변경 내용</th>
              <th style={{ width: 150 }}>대상</th>
            </tr></thead>
            <tbody>
              {rows.map((r) => {
                const open = openId === r.logId;
                return (
                  <React.Fragment key={r.logId}>
                    <tr onClick={() => setOpenId(open ? null : r.logId)} style={{ cursor: 'pointer' }}>
                      <td style={{ color: 'var(--text-3)', whiteSpace: 'nowrap' }}>{fmtDT(r.regDate)}</td>
                      <td>
                        <div>{auditActor(r)}</div>
                        {r.aid != null && <div className="t-mono" style={{ fontSize: 12, color: 'var(--text-3)' }}>#{r.aid}</div>}
                      </td>
                      <td><Badge tone={auditActionTone(r.action)}>{auditActionLabel(r.action)}</Badge></td>
                      {/* summary 가 주 컬럼입니다 — 사람이 읽는 한 줄은 이것뿐입니다. */}
                      <td style={{ fontWeight: 600 }}>{r.summary || '—'}</td>
                      <td style={{ whiteSpace: 'nowrap' }}>
                        <div style={{ fontSize: 12.5 }}>{auditTargetTypeLabel(r.targetType)}</div>
                        <div className="t-mono" style={{ fontSize: 12, color: 'var(--text-3)' }}>#{r.targetId}</div>
                      </td>
                    </tr>
                    {open && (
                      <tr>
                        <td colSpan={5} style={{ background: 'var(--bg)' }}>
                          <AuditValues row={r}/>
                        </td>
                      </tr>
                    )}
                  </React.Fragment>
                );
              })}
            </tbody>
          </table>
        </LiveBlock>
        {/* 못 불러왔을 때는 건수 줄을 띄우지 않습니다 — "0건"이 "서버가 0 건이라고 답했다"로 읽힙니다. */}
        {!list.error && (
          <div className="table-foot">
            <span style={{ fontSize: 13, color: 'var(--text-3)' }}>
              {total == null
                ? `${page}페이지 · ${rows.length}건`
                : `총 ${fmtNum(total)}건 · ${page}/${lastPage}페이지`}
            </span>
            <div style={{ display: 'flex', gap: 6, marginLeft: 'auto' }}>
              <button className="btn btn-ghost btn-sm" disabled={page <= 1 || list.loading}
                onClick={() => setPage((p) => p - 1)}><Icon name="chevLeft" size={14}/>이전</button>
              <button className="btn btn-ghost btn-sm" disabled={list.loading || !hasNext}
                onClick={() => setPage((p) => p + 1)}>다음<Icon name="chevRight" size={14}/></button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
};

Object.assign(window, { AuditLog, AuditTrail, AuditValues, openAuditFor, AUDIT_PAGE_SIZE });
