package com.kamco.cd.training.train.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kamco.cd.training.common.enums.JobStatusType;
import com.kamco.cd.training.common.enums.TrainStatusType;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService;
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
import com.kamco.cd.training.train.dto.DockerInspectState;
import com.kamco.cd.training.train.dto.ModelTrainJobDto;
import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent;
import com.kamco.cd.training.train.dto.ModelTrainLinkDto;
import com.kamco.cd.training.train.dto.OutputResult;
import com.kamco.cd.training.train.dto.TrainRunRequest;
import com.kamco.cd.training.train.dto.TrainingProgressDto;
import com.kamco.cd.training.train.util.TrainingProgressCalculator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Log4j2
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TrainJobService {

  private final ModelTrainJobCoreService modelTrainJobCoreService;
  private final ModelTrainMngCoreService modelTrainMngCoreService;
  private final ModelTrainMetricsJobService modelTrainMetricsJobService;
  private final ModelTestMetricsJobService modelTestMetricsJobService;
  private final DockerTrainService dockerTrainService;
  private final ObjectMapper objectMapper;
  private final ApplicationEventPublisher eventPublisher;
  private final TmpDatasetService tmpDatasetService;
  private final DataSetCountersService dataSetCounters;
  private final TrainUtilService trainUtilService;

  // 학습 결과가 저장될 호스트 디렉토리
  @Value("${train.docker.response_dir}")
  private String responseDir;

  public Long getModelIdByUuid(UUID uuid) {
    return modelTrainMngCoreService.findModelIdByUuid(uuid);
  }

  /**
   * 실행 예약 (QUEUE 등록)
   *
   * @param modelId
   * @return
   */
  @Transactional
  public Long enqueue(Long modelId) {

    // 폴더 카운트
    dataSetCounters.getCount(modelId);

    // 마스터 존재 확인(없으면 예외)
    modelTrainMngCoreService.findModelById(modelId);

    // 파라미터 조회
    TrainRunRequest trainRunRequest = modelTrainMngCoreService.findTrainRunRequest(modelId);

    if (trainRunRequest == null) {
      throw new IllegalArgumentException("Model not found: " + modelId);
    }

    int nextAttemptNo = modelTrainJobCoreService.findMaxAttemptNo(modelId) + 1;

    @SuppressWarnings("unchecked")
    Map<String, Object> paramsMap = objectMapper.convertValue(trainRunRequest, Map.class);
    paramsMap.put("jobType", "TRAIN");
    paramsMap.put("uuid", trainRunRequest.getUuid());
    paramsMap.put("totalEpoch", trainRunRequest.getEpochs());

    Long jobId =
        modelTrainJobCoreService.createQueuedJob(
            modelId, nextAttemptNo, paramsMap, ZonedDateTime.now());

    modelTrainMngCoreService.clearLastError(modelId);
    modelTrainMngCoreService.markInProgress(modelId, jobId);

    // 커밋 이후 Worker 실행 트리거(리스너에서 AFTER_COMMIT로 받아야 함)
    eventPublisher.publishEvent(new ModelTrainJobQueuedEvent(jobId));
    return jobId;
  }

  /**
   * 재시작
   *
   * <p>- STOPPED / ERROR 상태에서만 가능 (IN_PROGRESS면 예외) - 이전 params_json 재사용 - 새 attempt 생성
   */
  @Transactional
  public Long restart(Long modelId) {
    return createNextAttempt(modelId, ResumeMode.NONE);
  }

  /**
   * 이어하기
   *
   * @param modelId
   * @return
   */
  @Transactional
  public Long resume(Long modelId) {
    return createNextAttempt(modelId, ResumeMode.REQUIRE);
  }

  /**
   * 중단
   *
   * <p>- job 상태 CANCELED - master 상태 STOPPED
   *
   * <p>※ 실제 docker stop은 Worker/Runner가 수행(운영 안정)
   */
  @Transactional
  public void cancel(Long modelId) {

    ModelTrainMngDto.Basic master = modelTrainMngCoreService.findModelById(modelId);

    Long jobId = master.getCurrentAttemptId();
    if (jobId == null) {
      throw new IllegalStateException("실행중인 작업이 없습니다.");
    }

    var job =
        modelTrainJobCoreService
            .findById(jobId)
            .orElseThrow(() -> new IllegalStateException("Job not found"));

    String containerName = job.getContainerName();

    // 1) 컨테이너 강제 종료 + 제거 (없거나 이미 죽었어도 괜찮게)
    if (containerName != null && !containerName.isBlank()) {
      dockerTrainService.killContainer(containerName);
    }

    // 2) 상태 업데이트 (항상 수행)
    modelTrainJobCoreService.markCanceled(jobId);
    modelTrainMngCoreService.markStopped(modelId);
  }

  /**
   * 학습 이어하기
   *
   * @param modelId 모델 id
   * @param mode NONE 새로 시작, REQUIRE 이어하기
   * @return
   */
  private Long createNextAttempt(Long modelId, ResumeMode mode) {

    ModelTrainMngDto.Basic master = modelTrainMngCoreService.findModelById(modelId);

    ModelTrainJobDto lastJob =
        modelTrainJobCoreService
            .findLatestByModelId(modelId)
            .orElseThrow(() -> new IllegalStateException("이전 실행 이력이 없습니다."));

    int nextAttemptNo = modelTrainJobCoreService.findMaxAttemptNo(modelId) + 1;

    // 이전 params_json 재사용 (재현성)
    Map<String, Object> params = lastJob.getParamsJson();
    if (params == null || params.isEmpty()) {
      throw new IllegalStateException("이전 실행 params_json이 없습니다.");
    }

    // mode에 따라 resume 옵션 주입/제거
    Map<String, Object> nextParams = new java.util.LinkedHashMap<>(params);

    if (mode == ResumeMode.NONE) {
      // 이어하기 관련 키가 있다면 제거 (완전 새로 시작 보장)
      nextParams.remove("resumeFrom");
      nextParams.remove("resume");
    } else if (mode == ResumeMode.REQUIRE) {
      // 체크포인트 탐지해서 resumeFrom 세팅
      String resumeFrom = findResumeFromOrNull(nextParams);
      if (resumeFrom == null) {
        throw new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND, "이어하기 체크포인트가 없습니다.");
      }
      nextParams.put("resumeFrom", resumeFrom);
      nextParams.put("resume", true);
    }

    Long jobId =
        modelTrainJobCoreService.createQueuedJob(
            modelId, nextAttemptNo, nextParams, ZonedDateTime.now());

    modelTrainMngCoreService.clearLastError(modelId);
    modelTrainMngCoreService.markInProgress(modelId, jobId);

    eventPublisher.publishEvent(new ModelTrainJobQueuedEvent(jobId));
    return jobId;
  }

  /**
   * 데이터셋 1개일 때, 파일 경로 업데이트
   *
   * @param modelUuid
   * @param datasetList
   */
  public void updateRequestPath(UUID modelUuid, List<Long> datasetList) {
    Long modelId = modelTrainMngCoreService.findModelIdByUuid(modelUuid);
    List<String> datasetUid = modelTrainMngCoreService.findDatasetUid(datasetList);
    modelTrainMngCoreService.updateTrainRequestPath(modelId, datasetUid.getFirst());
  }

  private enum ResumeMode {
    NONE, // 새로 시작
    REQUIRE // 이어하기
  }

  /**
   * 이어하기 체크포인트 탐지해서 resumeFrom 세팅
   *
   * @param paramsJson
   * @return
   */
  public String findResumeFromOrNull(Map<String, Object> paramsJson) {
    if (paramsJson == null) return null;

    Object out = paramsJson.get("outputFolder");
    if (out == null) return null;

    String outputFolder = String.valueOf(out).trim();
    if (outputFolder.isEmpty()) return null;

    Path outDir = Paths.get(responseDir, outputFolder);
    log.info("resume outDir response path: {}", outDir);

    Path last = outDir.resolve("last_checkpoint");
    log.info("resume last response path: {}", last);

    if (!Files.isRegularFile(last)) return null;

    try {
      // last_checkpoint 내용 그대로 읽기
      String containerPath = Files.readString(last).trim();
      log.info("resume containerPath: {}", containerPath);

      if (containerPath.isEmpty()) return null;

      // 호스트 경로로 변환해서 실제 파일 존재 확인
      String hostPathStr = containerPath.replace("/checkpoints", responseDir);
      Path hostPath = Paths.get(hostPathStr);

      log.info("resume hostPath: {}", hostPath);

      if (!Files.isRegularFile(hostPath)) return null;

      // 3컨테이너 경로 그대로 반환
      return containerPath;

    } catch (Exception e) {
      log.error("resume error", e);
      return null;
    }
  }

  /**
   * 학습에 필요한 데이터셋 파일을 임시폴더 하나에 합치기
   *
   * @param modelUuid
   * @return
   */
  @Transactional
  public UUID createTmpFile(UUID modelUuid) {
    UUID tmpUuid = UUID.randomUUID();
    String raw = tmpUuid.toString().toUpperCase().replace("-", "");

    // model id 가져오기
    Long modelId = modelTrainMngCoreService.findModelIdByUuid(modelUuid);
    // model 에 연결된 dataset id 가져오기
    List<Long> datasetIds = modelTrainMngCoreService.findModelDatasetMapp(modelId);
    List<String> uids = modelTrainMngCoreService.findDatasetUid(datasetIds);

    try {
      // 1. 시작 상태 업데이트
      modelTrainMngCoreService.updateTmpFileStatusStart(modelId);

      // 데이터셋 심볼링크 생성
      //      String pathUid = tmpDatasetService.buildTmpDatasetSymlink(raw, uids);
      // train path 모델 클래스별 조회
      List<ModelTrainLinkDto> trainList = modelTrainMngCoreService.findDatasetTrainPath(modelId);
      // validation path 모델 클래스별 조회
      List<ModelTrainLinkDto> valList = modelTrainMngCoreService.findDatasetValPath(modelId);
      // test path 모델 클래스별 조회
      List<ModelTrainLinkDto> testList = modelTrainMngCoreService.findDatasetTestPath(modelId);

      log.info(
          "createTmpFile class list trainList = {} valList = {} testList = {}",
          trainList.size(),
          valList.size(),
          testList.size());

      // train 데이터셋 심볼링크 생성
      tmpDatasetService.buildTmpDatasetHardlink(raw, "train", trainList);
      // val 데이터셋 심볼링크 생성
      tmpDatasetService.buildTmpDatasetHardlink(raw, "val", valList);
      // test 데이터셋 심볼링크 생성
      tmpDatasetService.buildTmpDatasetHardlink(raw, "test", testList);

      ModelTrainMngDto.UpdateReq updateReq = new ModelTrainMngDto.UpdateReq();
      updateReq.setRequestPath(raw);
      updateReq.setTmpFileStatus("COMPLETE");
      updateReq.setTmpFileEndDttm(ZonedDateTime.now());

      // 학습모델을 수정한다.
      modelTrainMngCoreService.updateModelMaster(modelId, updateReq);
    } catch (IOException e) {
      log.error(
          "createTmpFile failed. modelUuid={}, modelId={}, tmpRaw={}, datasetIdsSize={}, uidsSize={}",
          modelUuid,
          modelId,
          raw,
          (datasetIds == null ? null : datasetIds.size()),
          (uids == null ? null : uids.size()),
          e);

      // 3. 실패 처리
      modelTrainMngCoreService.updateTmpFileStatusFail(modelId, e.getMessage());

      // 런타임 예외로 래핑하되, 메시지에 핵심 정보 포함
      throw new CustomApiException(
          "INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "임시 데이터셋 생성에 실패했습니다.");
    }
    return modelUuid;
  }

  /**
   * 동작되고잇는 pid을 확인하고 pid 목록에 있으면 진행중, 없으면 종료(결과 csv, zip 파일 확인하여 완료 여부 체크)
   *
   * @param uuid
   * @param modelId
   */
  public void status(UUID uuid, Long modelId) {

    ModelTrainJobDto job =
        modelTrainJobCoreService
            .findLatestByModelId(modelId)
            .orElseThrow(() -> new NoSuchElementException("job not found"));

    ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(modelId);

    // TODO 실행중 상태인것만 변경해야하면 주석 해제
    //    if(job.getStatusCd().equals(JobStatusType.RUNNING.getId())) {
    //      return;
    //    }

    String containerName = job.getContainerName();

    try {
      // docker inspect로 컨테이너 상태 조회
      DockerInspectState state = trainUtilService.inspectContainer(containerName);

      // 컨테이너가 "없음"
      //    - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음
      //    - 즉 "컨테이너 없음"이 무조건 실패는 아님
      if (!state.exists()) {
        log.warn("container missing. try file-based reconcile. container={}", containerName);

        // 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정"
        OutputResult out = trainUtilService.probeOutputs(job);

        // 산출물이 충분하면 성공 처리
        if (out.completed()) {

          // 테스트 완료인지 zip파일로 확인
          if (trainUtilService.existsZipFile(uuid)) {
            // 테스트 완료일때
            log.info("outputs look completed. mark SUCCESS. jobId={}", job.getId());

            // job 완료처리
            modelTrainJobCoreService.markSuccess(job.getId(), 0);

            // 학습 완료가 아니면 완료 업데이트
            if (!model.getStep1Status().equals(TrainStatusType.COMPLETED.getId())) {

              // model 상태 변경 (학습)
              modelTrainMngCoreService.markStep1Success(job.getModelId());
              // 학습 결과 csv 파일 정보 등록
              modelTrainMetricsJobService.trainValidMetricCsvFile(modelId);
            }

            // 테스트 완료가 아니면 완료 업데이트
            if (!model.getStep2Status().equals(TrainStatusType.COMPLETED.getId())) {
              // model 상태 변경 (테스트)
              modelTrainMngCoreService.markStep2Success(job.getModelId());
              // 테스트 결과 csv 파일 정보 등록
              modelTestMetricsJobService.testValidMetricCsvFiles(modelId);
            }

          } else {
            // 학습 완료일때
            log.info("outputs look completed. mark SUCCESS. jobId={}", job.getId());
            modelTrainJobCoreService.markSuccess(job.getId(), 0);
            // 학습 완료가 아니면 완료 업데이트
            if (!model.getStep1Status().equals(TrainStatusType.COMPLETED.getId())) {

              // model 상태 변경 (학습)
              modelTrainMngCoreService.markStep1Success(job.getModelId());
              // 학습 결과 csv 파일 정보 등록
              modelTrainMetricsJobService.trainValidMetricCsvFile(modelId);
            }
          }

        } else {

          // 산출물이 부족하면 중단처리
          // 산출물이 부족하면 "중단/보류"로 처리
          // 운영자가 재시작 할 수 있게 한다.
          log.warn(
              "outputs incomplete. mark PAUSED/STOP for restart. jobId={} reason={}",
              job.getId(),
              out.reason());

          //  PAUSED/STOP
          modelTrainJobCoreService.markPaused(
              job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");

          // STOP으로 변경
          markStepStopByJobType(
              job, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE: " + out.reason());
        }
      } else {
        // 컨테이너가 있으면 진행중처리
        Map<String, Object> params = job.getParamsJson();
        boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
        if (isEval) {
          // 테스트 진행중 상태로 업데이트
          modelTrainMngCoreService.markStep2InProgress(job.getModelId(), job.getId());
        } else {
          // 학습 진행중 상태로 업데이트
          modelTrainMngCoreService.markStep1InProgress(job.getModelId(), job.getId());
        }
        // job 테이블 진행중으로 업데이트
        modelTrainJobCoreService.updateJobStatus(job.getId(), JobStatusType.RUNNING.getId());
      }
    } catch (Exception e) {
      log.error("container inspect failed. container={}", containerName, e);
    }
  }

  /**
   * jobType에 따라 학습 관리 테이블의 "에러 단계"를 업데이트.
   *
   * <p>예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러
   */
  private void markStepStopByJobType(ModelTrainJobDto job, String msg) {
    Map<String, Object> params = job.getParamsJson();
    boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType")));
    if (isEval) {
      modelTrainMngCoreService.markStep2Stop(job.getModelId(), msg);
    } else {
      modelTrainMngCoreService.markStep1Stop(job.getModelId(), msg);
    }
  }

  /**
   * UUID로 학습 진행률 조회 기존 DB 컬럼만을 활용하여 실시간으로 진행률 계산
   *
   * @param uuid 모델 UUID
   * @return 학습 진행률 정보
   */
  @Transactional(readOnly = true)
  public TrainingProgressDto getTrainingProgress(UUID uuid) {
    // 1. UUID로 모델 조회
    Long modelId = getModelIdByUuid(uuid);
    ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(modelId);

    // 2. Step 상태 확인 (step1Status, step2Status)
    String step1Status = model.getStep1Status(); // READY/IN_PROGRESS/COMPLETED/STOPPED/ERROR
    String step2Status = model.getStep2Status(); // READY/IN_PROGRESS/COMPLETED/STOPPED/ERROR

    // 3. 현재 실행중인 Job ID 확인
    Long jobId = model.getCurrentAttemptId();

    if (jobId == null) {
      // Job이 없는 경우 모델 상태만 반환
      // Step1 완료 여부에 따라 진행률 결정
      double progressPercent = 0.0;
      String currentPhase = "NOT_STARTED";

      if ("COMPLETED".equals(step1Status) && "COMPLETED".equals(step2Status)) {
        progressPercent = 100.0;
        currentPhase = "COMPLETED";
      } else if ("COMPLETED".equals(step1Status)) {
        progressPercent = 50.0;
        currentPhase = "STEP1_COMPLETED";
      }

      return TrainingProgressDto.builder()
          .modelUuid(uuid)
          .statusCd(model.getStatusCd())
          .currentPhase(currentPhase)
          .progressPercent(progressPercent)
          .message(getMessageForPhase(currentPhase, null, null))
          .build();
    }

    // 4. Job 정보 조회 (기존 컬럼만 사용)
    ModelTrainJobDto job =
        modelTrainJobCoreService
            .findById(jobId)
            .orElseThrow(() -> new IllegalStateException("Job을 찾을 수 없습니다: " + jobId));

    // 5. totalEpoch 추출 (DB 컬럼 우선, 없으면 params_json에서 추출)
    Integer totalEpoch = job.getTotalEpoch();
    if (totalEpoch == null && job.getParamsJson() != null) {
      Object totalEpochObj = job.getParamsJson().get("totalEpoch");
      if (totalEpochObj != null) {
        totalEpoch = Integer.valueOf(String.valueOf(totalEpochObj));
      }
    }

    // 6. jobType 추출 (TRAIN/EVAL)
    String jobType = "TRAIN"; // 기본값
    if (job.getParamsJson() != null) {
      Object jobTypeObj = job.getParamsJson().get("jobType");
      if (jobTypeObj != null) {
        jobType = String.valueOf(jobTypeObj);
      }
    }

    // 7. 진행률 계산 (Step 구분하여 계산)
    double progressPercent =
        TrainingProgressCalculator.calculateProgress(
            job.getStatusCd(),
            job.getCurrentEpoch(),
            totalEpoch,
            job.getStartedDttm(),
            job.getFinishedDttm(),
            jobType,
            step1Status,
            step2Status);

    // 8. 현재 Phase 추정
    String currentPhase =
        TrainingProgressCalculator.estimateCurrentPhase(
            job.getStatusCd(), job.getCurrentEpoch(), totalEpoch, job.getStartedDttm(), jobType);

    // 9. 경과 시간 계산
    Long elapsedSeconds = TrainingProgressCalculator.calculateElapsedSeconds(job.getStartedDttm());

    // 10. 예상 남은 시간 계산
    Long estimatedRemaining =
        TrainingProgressCalculator.estimateRemainingSeconds(progressPercent, elapsedSeconds);

    // 11. 상태 메시지 생성
    String message =
        TrainingProgressCalculator.generateProgressMessage(
            currentPhase, job.getCurrentEpoch(), totalEpoch);

    // 12. DTO 생성 및 반환
    return TrainingProgressDto.builder()
        .jobId(job.getId())
        .modelUuid(uuid)
        .statusCd(job.getStatusCd())
        .currentPhase(currentPhase)
        .progressPercent(progressPercent)
        .currentEpoch(job.getCurrentEpoch())
        .totalEpoch(totalEpoch)
        .queuedDttm(job.getQueuedDttm())
        .startedDttm(job.getStartedDttm())
        .finishedDttm(job.getFinishedDttm())
        .elapsedSeconds(elapsedSeconds)
        .estimatedRemainingSeconds(estimatedRemaining)
        .message(message)
        .errorMessage(job.getErrorMessage())
        .build();
  }

  /**
   * Phase에 맞는 메시지 반환 (Job이 없는 경우)
   *
   * @param phase 현재 Phase
   * @param currentEpoch 현재 Epoch
   * @param totalEpoch 전체 Epoch
   * @return 상태 메시지
   */
  private String getMessageForPhase(String phase, Integer currentEpoch, Integer totalEpoch) {
    if ("COMPLETED".equals(phase)) {
      return "모든 학습 및 테스트가 완료되었습니다.";
    } else if ("STEP1_COMPLETED".equals(phase)) {
      return "학습이 완료되었습니다. 테스트를 시작할 수 있습니다.";
    } else {
      return "학습 작업이 시작되지 않았습니다.";
    }
  }
}
