import { Injectable, Logger } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import {
  CertifyResponseDto,
  Treatment,
  TreatmentMultipleResponseDto,
} from '@lib/codef/src/codef.dto';
import { TreatmentMultipleItemDto } from '@lib/codef/src/codef.interface';
import { TreatRepository } from '@api/modules/treat/treat.repository';
import { TreatProviderRepository } from '@api/modules/treat/provider/treat-provider.repository';
import { CooconAuthService } from './coocon-auth.service';
import {
  mapCooconCarToTreatment,
  mapCooconMedicalToTreatment,
} from './coocon.mapper';
import {
  CooconSession,
  LOGIN_LEVEL_TO_COOCON,
  TELECOM_TO_COOCON,
  TreatScraper,
  decodeCooconSession,
  encodeCooconSession,
} from './coocon.interface';

/**
 * 쿠콘 진료내역 공급자 — CODEF(헥토) 대체.
 * CodefService와 동일한 2개 메서드(fetchCertifyMultiple / fetchTreatmentMultiple)를 제공하여
 * TreatService가 provider 무관하게 호출한다.
 *
 * 인증 2종 모두 지원 (loginType 분기):
 *  - loginType='5' 간편인증: 본인인증(인증수단=간편사) → OTP → 내진료정보열람
 *  - loginType='2' 휴대폰(문자)인증: 본인인증(인증수단=MOBILE, 보안문자자동제출=Y)
 *      → 휴대폰인증_2(휴대폰_인증번호=SMS) → 내진료정보열람
 *      (보안문자 자동제출 실패 시 휴대폰인증_1(보안문자) 폴백)
 *
 * 범위: 건강보험(보험종별=1) + 자동차보험(보험종별=2, inquiryType='1' 요청 시). 약(③)은 미처리.
 * 2-way 세션은 CODEF의 jti 자리에 base64 인코딩하여 클라이언트 계약(certify/treat) 무변경으로 왕복.
 */
@Injectable()
export class CooconService implements TreatScraper {
  private logger = new Logger(CooconService.name);

  constructor(
    private auth: CooconAuthService,
    private treatRepository: TreatRepository,
    private providerRepo: TreatProviderRepository,
  ) {}

  /** 1차: 쿠콘 본인인증 요청 → CODEF CertifyResponseDto 형태로 정규화 */
  async fetchCertifyMultiple(
    items: TreatmentMultipleItemDto[],
  ): Promise<CertifyResponseDto> {
    const base = items?.[0];
    if (!base) throw new Error('COOCON certify: 빈 요청');

    const isMobile = base.loginType === '2';
    const input: Record<string, any> = isMobile
      ? {
          //* 휴대폰(문자) 본인인증
          인증수단: 'MOBILE',
          성명: base.userName,
          주민등록번호: base.identity,
          휴대폰번호: base.phoneNo,
          휴대폰인증_통신사: base.telecom
            ? (TELECOM_TO_COOCON[base.telecom] ?? '')
            : '',
          보안문자자동제출: 'Y', //* 캡차 자동제출(실패 시 2차에서 휴대폰인증_1 폴백)
        }
      : {
          //* 간편인증
          인증수단: LOGIN_LEVEL_TO_COOCON[base.loginTypeLevel] ?? 'KAKAO',
          성명: base.userName,
          주민등록번호: base.identity,
          휴대폰번호: base.phoneNo,
        };

    const res = await this.auth.call('본인인증', input, null, 'S');
    if (res.errorCode !== '00000000') {
      return this.failCertify(
        res.errorCode || 'CF-99999',
        res.errorMessage,
        res.session?.trSeq,
      );
    }
    const session: CooconSession = {
      ...res.session,
      otpReq: res.otpReq,
      authType: isMobile ? 'mobile' : 'simple',
      callBackFunc: res.callBackFunc,
    };

    //* 문자(MOBILE): 본인인증만으론 SMS가 발송되지 않음(확인됨). '휴대폰인증_1' 호출 시점에 SMS 발송.
    //* 보안문자자동제출=Y라 보안문자는 빈값("")으로 제출. (간편/카카오는 본인인증에서 바로 푸시 발송돼 불필요)
    if (isMobile) {
      const cap = await this.auth.call(
        '휴대폰인증_1',
        { 보안문자: '' },
        session,
        'S',
      );
      if (cap.errorCode !== '00000000') {
        return this.failCertify(
          cap.errorCode || 'CF-99999',
          cap.errorMessage,
          session.trSeq,
        );
      }
      //* SMS 발송 트리거 완료. 세션은 본인인증 세션(SAS_SessionID) 유지.
    }

    //* 본인인증(+문자는 휴대폰인증_1) 성공 → CODEF 추가인증 대기(CF-03002)로 매핑
    return plainToClass(CertifyResponseDto, {
      result: {
        code: 'CF-03002',
        message: res.errorMessage ?? '',
        extraMessage: '',
        transactionId: session.trSeq ?? '',
      },
      data: {
        continue2Way: 1,
        method: isMobile ? 'sms' : 'simpleAuth',
        jobIndex: 0,
        threadIndex: 0,
        //* 세션 핸들을 jti에 인코딩 → 2차(treat)에서 복원
        jti: encodeCooconSession(session),
        //* 프론트 카운트다운(targetTime = new Date(twoWayTimestamp) + 170s)의 기준시각.
        //* 반드시 number(epoch-ms)여야 함 — 문자열이면 new Date("17805...")가 Invalid Date→00:00 즉시만료.
        twoWayTimestamp: Date.now(),
        extraInfo: {
          reqSecureNo: '',
          reqSecureNoRefresh: '',
          commSimpleAuth: res.callBackFunc ?? '',
        },
      },
    });
  }

  private failCertify(
    code: string,
    message?: string,
    trSeq?: string,
  ): CertifyResponseDto {
    return plainToClass(CertifyResponseDto, {
      result: {
        code,
        message: message ?? '',
        extraMessage: '',
        transactionId: trSeq ?? '',
      },
      data: undefined,
    });
  }

  /** 2차: (OTP|휴대폰인증) 확정 → 내진료정보열람 → TreatmentMultipleResponseDto(data=[Treatment]) */
  async fetchTreatmentMultiple(
    items: TreatmentMultipleItemDto[],
    oddId: number,
  ): Promise<TreatmentMultipleResponseDto> {
    const base = items?.[0];
    if (!base) throw new Error('COOCON treat: 빈 요청');

    //* 1차에서 jti에 인코딩한 세션 복원 (SAS_SessionID 등은 전 단계 동일 세션을 계속 사용)
    const session = decodeCooconSession(base.twoWayInfo?.jti);
    if (!session) {
      this.logger.error('COOCON treat: 세션(jti) 복원 실패');
      return this.failResult('CF-12000', '세션 복원 실패');
    }

    //* 인증확정~조회 거래는 세션이 살아있는 동안 진행하고, 끝나면 finally에서 반드시 로그아웃(세션 종료).
    //* 벤더 요청: 내진료정보열람 후 마지막 거래로 '로그아웃'을 하지 않으면 세션이 잔존해 문제가 발생.
    try {
      //* 2-1. 본인인증 확정 단계 (인증유형별 분기)
      if (session.authType === 'mobile') {
        //* 휴대폰인증_2: 사용자가 받은 SMS 인증번호 제출. (휴대폰인증_1은 certify 단계에서 이미 수행 → SMS 발송됨)
        const sms = await this.auth.call(
          '휴대폰인증_2',
          { 휴대폰_인증번호: base.smsAuthNo ?? '' },
          session,
          'S',
        );
        if (sms.errorCode !== '00000000') {
          return this.failResult(
            sms.errorCode || 'CF-99999',
            sms.errorMessage,
            session.trSeq,
          );
        }
      } else {
        //* 간편인증: OTP 확정 (폰 승인 완료 후)
        const otp = await this.auth.call('OTP', { res: '' }, session, 'S');
        if (otp.errorCode !== '00000000') {
          return this.failResult(
            otp.errorCode || 'CF-99999',
            otp.errorMessage,
            session.trSeq,
          );
        }
      }

      //* 2-2. 내진료정보열람 (보험종별=1 건강보험만 / 상병·민감상병 표시). 세션 유지(S) — 종료는 finally 로그아웃에서.
      //*   5년치가 무거우면 300초 timeout으로 끊기므로, 공급자 설정(tpr_split_count)에 따라 조회기간을
      //*   N등분해 같은 세션으로 순차 호출 후 병합한다. 1(기본)이면 분할 없이 1회(기존 동작).
      //*   ※ 고정 분할(처음부터)만 사용 — 동적 재분할(타임아웃 후 쪼개기)은 세션이 죽어 불가(실증 확인).
      //*   ※ 5년 완전성이 깨지면 안 되므로 한 구간이라도 실패하면 전체 실패(부분 저장 안 함).
      const splitCount = await this.providerRepo
        .findSplitCount('coocon')
        .catch(() => 1);
      const ranges = this.splitRanges(base.startDate, base.endDate, splitCount);
      if (ranges.length > 1) {
        this.logger.log(
          `coocon 내진료정보열람 ${ranges.length}분할 순차 수집: ${ranges
            .map(([s, e]) => `[${s}~${e}]`)
            .join(' ')}`,
        );
      }
      const baseInput: Record<string, any> = {
        상병항목표시유무: 'Y',
        민감상병표시유무: base.type === '1' ? 'Y' : 'N',
        보험종별: '1', //* 건강 호출 기본값. 자동차(2)는 아래 자동차 블록에서 덮어써 별도 호출.
      };
      //* 수집(내진료정보열람) 소요 측정 시작 — 인증확정 이후. 분할이면 구간별 소요를 따로 기록해
      //*   '어느 구간이 몇 초/타임아웃인지'를 실패·성공 메시지로 노출한다(분석로그에서 확인).
      const collectStart = Date.now();
      const lbl = (d: string) => `${d.slice(2, 4)}.${d.slice(4, 6)}`; //* 20231210 → 23.12
      const segDetails: string[] = [];
      let medResult: any = null;
      for (const [s, e] of ranges) {
        const segStart = Date.now();
        let med: any;
        try {
          med = await this.auth.call(
            '내진료정보열람',
            { ...baseInput, 조회시작일: s, 조회종료일: e },
            session,
            'S',
          );
        } catch (err: any) {
          //* axios 타임아웃 등 — 구간별 소요 + 소요/타임아웃여부를 예외에 실어 상위(handler)가 기록.
          segDetails.push(
            `[${lbl(s)}~${lbl(e)}]${Math.round((Date.now() - segStart) / 1000)}초 타임아웃`,
          );
          err.elapsedMs = Date.now() - collectStart;
          err.isTimeout = this.isCooconQueryTimeout(err);
          err.codefMessage = `내진료정보열람 ${segDetails.join(' / ')}`;
          throw err;
        }
        const segSec = Math.round((Date.now() - segStart) / 1000);
        //* 42110000 = "조회결과 없음(거래내역 없음)" — 스크래핑은 성공, 내역이 0건이라는 의미.
        //*   기관 무응답(8000F101=거래취소)과 달리 정상 거래다. 실패로 떨구지 않고
        //*   CODEF와 동일하게 '빈 자료'로 취급하고 다음 구간을 계속 수집한다.
        //*   (분할이면 이 구간만 0건이고 다른 구간엔 내역이 있을 수 있으므로 전체 실패 금지.
        //*    전 구간 0건이면 medResult=null → 매퍼가 빈 Treatment 반환 → 빈 분석 성공.)
        if (med.errorCode === '42110000') {
          segDetails.push(`[${lbl(s)}~${lbl(e)}]${segSec}초·내역없음`);
          continue;
        }
        if (med.errorCode !== '00000000') {
          //* 한 구간 실패 = 전체 실패. 세션은 finally 로그아웃에서 정리.
          segDetails.push(`[${lbl(s)}~${lbl(e)}]${segSec}초 실패`);
          return this.failResult(
            med.errorCode || 'CF-99999',
            `${med.errorMessage ?? ''} ${segDetails.join(' / ')}`.trim(),
            session.trSeq,
            {
              elapsedMs: Date.now() - collectStart,
              isTimeout: med.errorCode === '8000F101',
            },
          );
        }
        segDetails.push(`[${lbl(s)}~${lbl(e)}]${segSec}초`);
        medResult = medResult
          ? this.mergeMedResult(medResult, med.result)
          : med.result;
      }
      const collectElapsedMs = Date.now() - collectStart;
      const collectDetail = segDetails.join(' / ');

      const treatment = mapCooconMedicalToTreatment(medResult);

      //* 원본 적재 (CODEF와 동일 의미: BASIC).
      this.treatRepository
        .saveTest(
          oddId,
          JSON.stringify({ result: { code: 'CF-00000' }, data: medResult }),
          'BASIC',
        )
        ?.catch?.((e: any) =>
          this.logger.error(`saveTest 실패(BASIC/coocon): oddId=${oddId} ${e}`),
        );

      const data: Treatment[] = [treatment];

      //* 자동차보험(보험종별=2) — 요청에 포함되면 같은 세션으로 1회 더 조회(자동차는 데이터가 적어 분할 불필요).
      //* 자동차 실패는 건강 성공을 뒤집지 않는 best-effort 정책이다.
      if (items.some((i) => i.inquiryType === '1')) {
        try {
          const carMed = await this.auth.call(
            '내진료정보열람',
            {
              ...baseInput,
              보험종별: '2',
              조회시작일: base.startDate,
              조회종료일: base.endDate,
            },
            session,
            'S',
          );
          if (carMed.errorCode === '42110000') {
            //* 자동차 자료없음 = 정상. data[1] 미추가 → TreatService 가 자동차 미저장으로 처리.
          } else if (carMed.errorCode !== '00000000') {
            this.logger.warn(
              `coocon 자동차(보험종별=2) 수집 실패(건강 성공 유지): oddId=${oddId} code=${carMed.errorCode}`,
            );
          } else {
            data[1] = mapCooconCarToTreatment(carMed.result);
            this.treatRepository
              .saveTest(
                oddId,
                JSON.stringify({
                  result: { code: 'CF-00000' },
                  data: carMed.result,
                }),
                'CAR',
              )
              ?.catch?.((e: any) =>
                this.logger.error(
                  `saveTest 실패(CAR/coocon): oddId=${oddId} ${e}`,
                ),
              );
          }
        } catch (e: any) {
          this.logger.warn(
            `coocon 자동차(보험종별=2) 호출 실패(건강 성공 유지): oddId=${oddId} ${e?.message || e}`,
          );
        }
      }

      return plainToClass(TreatmentMultipleResponseDto, {
        result: {
          code: 'CF-00000',
          message: `정상처리 ${collectDetail}`, //* 구간별 소요 노출 (분석로그에서 확인)
          extraMessage: '',
          transactionId: session.trSeq ?? '',
          elapsedMs: collectElapsedMs, //* 수집 소요(분할 합산) — 로그 적재용
          isTimeout: false,
        },
        data,
      });
    } finally {
      //* 마지막 거래로 반드시 로그아웃(세션 종료). 성공·실패 무관 수행, 실패해도 본 응답에 영향 없게 best-effort.
      await this.auth
        .call('로그아웃', {}, session, 'S')
        .catch((e) => this.logger.warn(`coocon 로그아웃 실패(무시): ${e}`));
    }
  }

  //* ── 고정 분할 헬퍼 ──────────────────────────────────────────────
  //* 조회기간을 n등분한 [시작,끝] YYYYMMDD 배열. 경계 겹침/누락 0 (구간 끝+1일 = 다음 시작).
  //*   기간이 분할수보다 짧으면 분할하지 않는다(1구간).
  private splitRanges(
    start: string,
    end: string,
    n: number,
  ): [string, string][] {
    if (n <= 1) return [[start, end]];
    const s = this.parseYmd(start);
    const e = this.parseYmd(end);
    const totalDays = Math.round((e.getTime() - s.getTime()) / 86_400_000);
    if (totalDays < n) return [[start, end]];
    const ranges: [string, string][] = [];
    let segStart = new Date(s);
    for (let i = 0; i < n; i++) {
      let segEnd: Date;
      if (i === n - 1) {
        segEnd = new Date(e); //* 마지막 구간은 정확히 종료일까지
      } else {
        const cut = Math.floor((totalDays * (i + 1)) / n); //* 균등 분할 경계
        segEnd = new Date(s);
        segEnd.setDate(segEnd.getDate() + cut - 1);
      }
      ranges.push([this.fmtYmd(segStart), this.fmtYmd(segEnd)]);
      segStart = new Date(segEnd);
      segStart.setDate(segStart.getDate() + 1);
    }
    return ranges;
  }

  //* 두 기간 결과 병합 — 진료내역 4개 배열 concat. 기간이 안 겹치게 분할하므로 중복 없음.
  private mergeMedResult(a: any, b: any): any {
    const cat = (k: string) => [...this.asArr(a?.[k]), ...this.asArr(b?.[k])];
    return {
      개인진료정보요약: cat('개인진료정보요약'),
      기본진료내역: cat('기본진료내역'),
      세부진료정보: cat('세부진료정보'),
      처방조제정보: cat('처방조제정보'),
    };
  }

  private asArr(v: any): any[] {
    if (v == null) return [];
    return Array.isArray(v) ? v : [v];
  }

  private parseYmd(s: string): Date {
    return new Date(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8));
  }

  private fmtYmd(d: Date): string {
    const m = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    return `${d.getFullYear()}${m}${day}`;
  }

  //* 쿠콘 "조회 타임아웃"으로 소켓이 끊긴 경우만 true (axios timeout/연결끊김).
  //*   coocon-auth가 예외를 wrap하며 보존한 origCode/origMessage를 함께 검사한다.
  private isCooconQueryTimeout(err: any): boolean {
    const code = err?.code ?? err?.origCode;
    const msg = String(err?.origMessage ?? err?.message ?? '');
    return (
      code === 'ECONNABORTED' ||
      code === 'ETIMEDOUT' ||
      /socket hang up|timeout/i.test(msg)
    );
  }

  private failResult(
    code: string,
    message?: string,
    trSeq?: string,
    opts?: { elapsedMs?: number; isTimeout?: boolean },
  ): TreatmentMultipleResponseDto {
    return plainToClass(TreatmentMultipleResponseDto, {
      result: {
        code,
        message: message ?? '',
        extraMessage: '',
        //* 실패건도 쿠콘 거래일련번호(trSeq)를 남겨 CS가 쿠콘 콘솔에서 추적 가능하게 한다.
        transactionId: trSeq ?? '',
        elapsedMs: opts?.elapsedMs, //* 수집 소요(실패까지) — 로그 적재용
        isTimeout: opts?.isTimeout ?? false,
      },
      data: [],
    });
  }
}
