import { WebClientService } from '@lib/webclient/src';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { plainToClass } from 'class-transformer';
import {
  CertifyResponseDto,
  TreatmentMultipleResponseDto,
} from '@lib/codef/src/codef.dto';
import { TreatmentMultipleItemDto } from '@lib/codef/src/codef.interface';
import { TreatScraper } from '@lib/coocon/src/coocon.interface';
import {
  InhouseCertifyResult,
  InhouseEnvelope,
  InhouseSession,
  InhouseTreatResult,
  INH_CERTIFY_OK,
  INQUIRY_TO_DATAKEY,
  INQUIRY_TO_SERVICE,
  FALLBACK_CATEGORIES,
  decodeInhouseSession,
  encodeInhouseSession,
  extractAlbCookie,
} from './inhouse.types';

/**
 * 자체 스크래퍼 엔진(scraper-engine) 공급자 — CODEF/쿠콘/옥토버와 동일 TreatScraper 구현.
 * 외부 벤더 API 대신 우리 엔진의 HTTP API를 호출하는 어댑터다(코드는 그냥 5번째 벤더).
 *
 *  1차 fetchCertifyMultiple → 엔진 POST /api/certify (브라우저 인증, sessionId 발급)
 *  2차 fetchTreatmentMultiple → 엔진 POST /api/treat (SMS 확정 + .do HTTP 수집)
 *
 * 독립성: 엔진 측 인프라/용량/기관 오류(BUSY·SYSTEM·VENDOR·NETWORK)는 throw하여
 *   디스패처가 기존 벤더로 폴백하게 한다 → inhouse가 죽어도 진료조회는 안 죽는다(블렌드).
 * 기본 OFF: 공급자 풀(tn_treat_provider/TREAT_POOL)에 'inhouse'가 없으면 호출되지 않는다.
 */
@Injectable()
export class InhouseService implements TreatScraper {
  private logger = new Logger(InhouseService.name);

  constructor(
    private config: ConfigService,
    private http: WebClientService,
  ) {}

  /** 1차: 엔진 본인인증 → CODEF CertifyResponseDto(CF-03002) 정규화. */
  async fetchCertifyMultiple(
    items: TreatmentMultipleItemDto[],
  ): Promise<CertifyResponseDto> {
    const base = items?.[0];
    if (!base) throw new Error('INHOUSE certify: 빈 요청');

    const authType: 'simple' | 'mobile' =
      base.loginType === '2' ? 'mobile' : 'simple';

    const trace = base.traceId ?? '-';
    //* certify는 응답 헤더(Set-Cookie)에서 ALB sticky 쿠키를 거둬야 하므로 헤더포함 호출을 쓴다.
    const { data: resp, headers } =
      await this.postWithResponse<InhouseCertifyResult>('/api/certify', {
        authMethod: authType,
        loginTypeLevel: base.loginTypeLevel,
        name: base.userName,
        identity: base.identity,
        phone: base.phoneNo,
        telecom: base.telecom, // TODO: insuniverse telecom('0'~'5') ↔ 엔진 형식 매핑 — 카나리 실인증에서 확정
        traceId: base.traceId, // 주문 상관키 — 엔진 로그에 trace=로 찍혀 sessionId↔주문 조인
      });

    const sessionId = resp.result?.sessionId;
    //* 조인키 1줄: 이 주문(trace)이 엔진의 어느 sessionId로 갔는지 + 1차 결과. 시각 추론 없이 박스로그와 결합.
    this.logger.log(
      `[inhouse] certify← trace=${trace} code=${resp.code} cat=${resp.category ?? '-'}`,
    );
    if (resp.code === INH_CERTIFY_OK && sessionId) {
      //* ALB 뒤 멀티박스: certify가 만든 박스로 treat을 보내려고 AWSALB* 쿠키를 세션에 실어 jti로 왕복.
      //*   단일박스/스티키 미사용이면 헤더가 없어 undefined → 평소대로 동작(하위호환). 쿠키 값은 로깅 X.
      const albCookie = extractAlbCookie(headers?.['set-cookie']);
      const session: InhouseSession = { sessionId, authType, albCookie };
      return plainToClass(CertifyResponseDto, {
        result: {
          code: 'CF-03002',
          message: '',
          extraMessage: '',
          transactionId: '',
        },
        data: {
          continue2Way: 1,
          method: authType === 'mobile' ? 'sms' : 'simpleAuth',
          jobIndex: 0,
          threadIndex: 0,
          jti: encodeInhouseSession(session),
          twoWayTimestamp: Date.now(),
          extraInfo: {
            reqSecureNo: '',
            reqSecureNoRefresh: '',
            commSimpleAuth: '',
          },
        },
      });
    }

    // 인프라/용량/기관 오류 → throw로 디스패처 폴백(블렌드). 유저측 오류 → 그대로 반환.
    if (FALLBACK_CATEGORIES.has(resp.category)) {
      throw new Error(`INHOUSE certify 폴백: ${resp.code} ${resp.message}`);
    }
    return this.failCertify(resp.code, resp.message);
  }

  /** 2차: SMS 확정 + 수집 → TreatmentMultipleResponseDto(data=[Treatment...]). */
  async fetchTreatmentMultiple(
    items: TreatmentMultipleItemDto[],
    oddId: number,
  ): Promise<TreatmentMultipleResponseDto> {
    const base = items?.[0];
    if (!base) throw new Error('INHOUSE treat: 빈 요청');

    const trace = base.traceId ?? '-';
    const session = decodeInhouseSession(base.twoWayInfo?.jti);
    if (!session) {
      this.logger.error(
        `[inhouse] treat 세션(jti) 복원 실패 trace=${trace} oddId=${oddId}`,
      );
      return this.failResult('INH-2001', '세션 복원 실패');
    }

    const services = Array.from(
      new Set(items.map((it) => INQUIRY_TO_SERVICE[it.inquiryType] ?? 'diag')),
    );

    //* certify가 거둔 ALB sticky 쿠키가 있으면 같은 박스로 라우팅되도록 Cookie 헤더로 복원해 보낸다.
    //*   없으면(단일박스/스티키 미사용) 헤더 없이 평소대로 호출(하위호환). 쿠키 값은 로깅 X.
    const treatHeaders = session.albCookie
      ? { Cookie: session.albCookie }
      : undefined;

    const resp = await this.post<InhouseTreatResult>(
      '/api/treat',
      {
        sessionId: session.sessionId,
        smsAuthNo: base.smsAuthNo,
        simpleAuth: base.simpleAuth,
        services,
        startDate: base.startDate,
        endDate: base.endDate,
        type: base.type,
        showSick: true,
        showSensitive: base.type === '1',
        logout: true,
        traceId: base.traceId, // 주문 상관키 — 엔진 [treat] 로그에 trace=로
      },
      treatHeaders,
    );

    const tr = resp.result;
    //* 2차 결과 조인키: 이 주문(trace/oddId)이 어느 session으로 무엇을 수집했는지 + 결과코드.
    this.logger.log(
      `[inhouse] treat← trace=${trace} oddId=${oddId} services=[${services.join(',')}] code=${resp.code} ok=${tr?.ok ?? false}`,
    );
    // 자료없음(INH-3001 NO_DATA)은 '실패'가 아니라 '빈 분석 성공'으로 처리한다 —
    //   octover/coocon 등 다른 벤더 정책과 동일(환불 X). 실패로 떨어뜨리면 안 됨.
    const isNoData = resp.code === 'INH-3001' || tr?.code === 'INH-3001';
    if (!isNoData && (!tr?.ok || !tr.data)) {
      if (FALLBACK_CATEGORIES.has(resp.category)) {
        throw new Error(`INHOUSE treat 폴백: ${resp.code} ${resp.message}`);
      }
      return this.failResult(
        resp.code || 'INH-9999',
        resp.message || tr?.note || '수집 실패',
      );
    }

    // 성공 또는 자료없음 → CF-00000. 각 inquiryType 산출물을 순서대로(CODEF byte-호환).
    //   자료없음이면 엔진 data가 비거나 undefined일 수 있어 빈 Treatment로 채운다(빈 분석 성공).
    const bag = (tr?.data ?? {}) as Record<string, unknown>;
    const emptyFor = (q: string): unknown =>
      q === '2'
        ? { resMedicineSummaryList: [] }
        : {
            resBasicTreatList: [],
            resDetailTreatList: [],
            resPrescribeDrugList: [],
          };
    //* 정규화 필수: 보유 treatRepository.create()는 각 필드에 .trim()/.replace()를
    //*   무가드 호출한다. 엔진 raw 레코드에 null/undefined/숫자가 하나라도 섞이면
    //*   INSERT 전체가 throw → .catch에서 주문이 ODD004(인증실패)로 떨어진다.
    //*   octover(mapOctoverToTreatment)·codef와 동일하게 어댑터에서 안전 문자열로 강제한다.
    const data = items.map((it) =>
      this.normalizeTreatment(
        bag[INQUIRY_TO_DATAKEY[it.inquiryType] ?? 'treatment'] ??
          emptyFor(it.inquiryType),
        it.inquiryType === '1', //* 자동차만 자동차 전용 alias 적용(3벤더 octover/hyphen/coocon과 동일 패턴)
      ),
    );

    return plainToClass(TreatmentMultipleResponseDto, {
      result: {
        code: 'CF-00000',
        message: '정상처리되었습니다.',
        extraMessage: '',
        transactionId: '',
      },
      data,
    });
  }

  //* ───── 내부 ─────

  /**
   * 엔진 raw 진료 데이터를 보유 create() 계약(전 필드 문자열)으로 정규화한다.
   *   - null/undefined/숫자 → 안전 문자열(빈 진료 레코드 1건도 INSERT를 깨지 않게).
   *   - 키 철자는 create()가 읽는 그대로 맞춘다(특히 resTotalDosingdays 소문자 d).
   *   - 약(medicine 등) 다른 형태의 산출물은 건드리지 않고 그대로 통과.
   */
  private normalizeTreatment(t: unknown, isAuto = false): unknown {
    const o = t as Record<string, unknown> | null;
    if (
      !o ||
      (!('resBasicTreatList' in o) &&
        !('resDetailTreatList' in o) &&
        !('resPrescribeDrugList' in o))
    ) {
      return t; // 진료 형태가 아니면(약 요약 등) 그대로
    }
    const s = (v: unknown): string =>
      v == null ? '' : typeof v === 'string' ? v : String(v);
    const rows = (v: unknown): Record<string, unknown>[] =>
      Array.isArray(v) ? (v as Record<string, unknown>[]) : [];
    //* 키 철자 흔들림(대문자 D) 방어 — create()는 소문자 d만 읽는다.
    const dosing = (r: Record<string, unknown>): string =>
      s(r.resTotalDosingdays ?? r.resTotalDosingDays);
    return {
      resBasicTreatList: rows(o.resBasicTreatList).map((r) => ({
        resTreatStartDate: s(r.resTreatStartDate),
        resHospitalName: s(r.resHospitalName),
        resDepartment: s(r.resDepartment),
        resTreatType: s(r.resTreatType),
        //* '$'/'해당없음' 더미값은 빈값으로(octover 매퍼와 동일).
        resDiseaseCode: s(r.resDiseaseCode) === '$' ? '' : s(r.resDiseaseCode),
        resDiseaseName:
          s(r.resDiseaseName) === '해당없음' ? '' : s(r.resDiseaseName),
        resVisitDays: s(r.resVisitDays),
        resTotalAmount: s(r.resTotalAmount),
        resPublicCharge: s(r.resPublicCharge),
        resDeductibleAmt: s(r.resDeductibleAmt),
        resHospitalCode: s(r.resHospitalCode),
        //* 자동차 전용 alias(3벤더 octover/hyphen/coocon과 동일 표준) — createCarTreat는 진료일수=resTreatDate,
        //*   자동차보험총진료비=resMedicalFee로 읽는다(treat.repository.ts:368·370). 엔진은 그 둘을
        //*   resVisitDays/resPublicCharge로 내보내므로 별칭으로 메운다. 건강(isAuto=false)엔 넣지 않아 청결.
        ...(isAuto
          ? {
              resTreatDate: s(r.resTreatDate ?? r.resVisitDays),
              resMedicalFee: s(r.resMedicalFee ?? r.resPublicCharge),
            }
          : {}),
      })),
      resDetailTreatList: rows(o.resDetailTreatList).map((r) => ({
        resTreatStartDate: s(r.resTreatStartDate),
        resHospitalName: s(r.resHospitalName),
        resTreatType: s(r.resTreatType),
        resCodeName: s(r.resCodeName),
        resOneDose: s(r.resOneDose),
        resDailyDosesNumber: s(r.resDailyDosesNumber),
        resTotalDosingdays: dosing(r),
      })),
      resPrescribeDrugList: rows(o.resPrescribeDrugList).map((r) => ({
        resTreatStartDate: s(r.resTreatStartDate),
        resHospitalName: s(r.resHospitalName),
        resTreatType: s(r.resTreatType),
        resDrugName: s(r.resDrugName),
        resIngredients: s(r.resIngredients),
        resOneDose: s(r.resOneDose),
        resDailyDosesNumber: s(r.resDailyDosesNumber),
        resTotalDosingdays: dosing(r),
      })),
    };
  }

  private baseUrl(): string {
    const u = this.config.get<string>('inhouse.baseUrl');
    if (!u) throw new Error('inhouse.baseUrl 미설정 (INHOUSE_BASE_URL)');
    return u.replace(/\/+$/, '');
  }

  //* 공통 요청 헤더(Content-Type + 선택적 x-api-key)에 호출별 extraHeaders(예: Cookie)를 합친다.
  private buildHeaders(extra?: Record<string, string>): Record<string, string> {
    const apiKey = this.config.get<string>('inhouse.apiKey');
    return {
      'Content-Type': 'application/json',
      ...(apiKey ? { 'x-api-key': apiKey } : {}),
      ...(extra ?? {}),
    };
  }

  private async post<R>(
    path: string,
    body: unknown,
    extraHeaders?: Record<string, string>,
  ): Promise<InhouseEnvelope<R>> {
    return this.http
      .post<InhouseEnvelope<R>>(this.baseUrl() + path, body, {
        timeout: 300000,
        headers: this.buildHeaders(extraHeaders),
      })
      .catch((e) => {
        this.logger.error(
          `inhouse 호출 실패 code=${e?.code ?? 'UNKNOWN'} status=${e?.response?.status ?? '-'}`,
        );
        throw e; // 연결 실패 = 디스패처 폴백
      });
  }

  //* 클라 이탈 비콘을 엔진 대시보드로 포워드(B, 2026-07-01). 엔진이 이 sessionId 세션 타임라인에
  //*   '클라 이탈: sms_wait 3초' 를 찍는다. best-effort — 실패해도 무시(이탈은 이미 tn_treat_dropout에 적재됨).
  async reportClientDropout(
    sessionId: string,
    stage: string,
    msOnStage?: number,
    event?: string,
  ): Promise<void> {
    const url = this.baseUrl() + '/api/client-dropout';
    try {
      await this.http.post(
        url,
        { sessionId, stage, msOnStage, event },
        {
          timeout: 5000,
          headers: this.buildHeaders(),
        },
      );
      //* 관측(2026-07-01 B 디버깅) — 포워드 도달 확인(axios는 non-2xx면 throw이므로 resolve=성공).
      this.logger.log(`[dropout→engine] ok stage=${stage}`);
    } catch {
      //* best-effort이며 세션/URL/원문 오류는 토큰·PII 가능성이 있어 남기지 않는다.
      this.logger.warn(`[dropout→engine] 실패 stage=${stage}`);
    }
  }

  async cancelCertify(jti?: string): Promise<void> {
    const session = decodeInhouseSession(jti);
    if (!session?.sessionId) return;
    await this.http
      .post(
        this.baseUrl() + '/api/cancel',
        { sessionId: session.sessionId },
        {
          timeout: 5000,
          headers: this.buildHeaders(
            session.albCookie ? { Cookie: session.albCookie } : undefined,
          ),
        },
      )
      .catch(() => {
        this.logger.warn('inhouse 인증세션 취소 실패');
      });
  }

  /** 스테이징 국세청 의료비 화면용 엔진 중계. 엔진 키는 브라우저에 노출하지 않는다. */
  async requestNts<R>(
    action: 'start' | 'complete' | 'medical' | 'cancel',
    body: unknown,
  ): Promise<InhouseEnvelope<R>> {
    try {
      return await this.post(`/api/nts/${action}`, body);
    } catch (error) {
      const data = (error as { response?: { data?: unknown } })?.response?.data;
      if (
        data &&
        typeof data === 'object' &&
        typeof (data as { ok?: unknown }).ok === 'boolean' &&
        typeof (data as { code?: unknown }).code === 'string'
      ) {
        return data as InhouseEnvelope<R>;
      }
      throw error;
    }
  }

  //* post와 동일하되 응답 본문+헤더를 함께 받는다(certify의 Set-Cookie 캡처용).
  private async postWithResponse<R>(
    path: string,
    body: unknown,
    extraHeaders?: Record<string, string>,
  ): Promise<{ data: InhouseEnvelope<R>; headers: Record<string, any> }> {
    return this.http
      .postWithResponse<InhouseEnvelope<R>>(this.baseUrl() + path, body, {
        timeout: 300000,
        headers: this.buildHeaders(extraHeaders),
      })
      .catch((e) => {
        this.logger.error(
          `inhouse 호출 실패 code=${e?.code ?? 'UNKNOWN'} status=${e?.response?.status ?? '-'}`,
        );
        throw e; // 연결 실패 = 디스패처 폴백
      });
  }

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

  private failResult(
    code: string,
    message?: string,
  ): TreatmentMultipleResponseDto {
    return plainToClass(TreatmentMultipleResponseDto, {
      result: {
        code,
        message: message ?? '',
        extraMessage: '',
        transactionId: '',
      },
      data: [],
    });
  }
}
