package egovframework.util;

import com.google.errorprone.annotations.CanIgnoreReturnValue;
import egovframework.configProperties.FileUploadProperties;
import go.kr.project.common.model.FileVO;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 파일 업로드/다운로드 공통 유틸리티 클래스
 */
@Component
public class FileUtil {

    /** 파일 업로드 설정 */
    private final FileUploadProperties fileUploadProperties;

    /** 날짜 유틸리티 */
    private final DateUtil dateUtil;

    /** 파일 저장 기본 경로 */
    private String uploadPath;

    /** 최대 파일 크기 (단일 파일) - 기본값 10MB */
    private long maxFileSize;

    /** 최대 총 파일 크기 - 기본값 50MB */
    private long maxTotalSize;

    /** 허용된 파일 확장자 */
    private String allowedExtensions;

    /** 허용된 파일 확장자 목록 */
    private List<String> allowedExtensionList;

    /** 최대 파일 개수 - 기본값 10개 */
    private int maxFiles;

    /** 실제 파일 삭제 여부 - 기본값 true */
    private boolean realFileDelete;

    /** 하위 디렉토리 설정 */
    private Map<String, String> subDirs;

    /**
     * 생성자
     * @param fileUploadProperties 파일 업로드 설정
     * @param dateUtil 날짜 유틸리티
     */
    public FileUtil(FileUploadProperties fileUploadProperties, DateUtil dateUtil) {
        this.fileUploadProperties = fileUploadProperties;
        this.dateUtil = dateUtil;
    }

    /**
     * 초기화 메서드
     * FileUploadProperties에서 설정 값을 가져와 초기화
     */
    @PostConstruct
    public void init() {
        // 설정 값 초기화
        this.uploadPath = fileUploadProperties.getPath();
        this.maxFileSize = fileUploadProperties.getMaxSize();
        this.maxTotalSize = fileUploadProperties.getMaxTotalSize();
        this.allowedExtensions = fileUploadProperties.getAllowedExtensions();
        this.maxFiles = fileUploadProperties.getMaxFiles();
        this.realFileDelete = fileUploadProperties.isRealFileDelete();
        this.subDirs = fileUploadProperties.getSubDirs();

        // 허용된 파일 확장자 목록 초기화
        allowedExtensionList = Arrays.stream(allowedExtensions.split(","))
                .map(String::trim)
                .map(String::toLowerCase)
                .collect(Collectors.toList());
    }

    /**
     * <h3>경로 순회 문자열 제거 메소드</h3>
     * 참고: return 값의 파일 경로 구분자는 항상 "/" 가 적용된다.
     * @see #cleanPath(String, boolean)
     */
    public static String cleanPath(String filePath) throws IllegalArgumentException {
        // Java 는 파일 경로 구분자가 "\" 든 "/" 든 똑같이 대응하도록 되어 있다.
        // 그러므로 useOsFileSeparatorOnResult=false 로 세팅한다.
        return cleanPath(filePath, false);
    }

    /**
     * <h3>경로 순회 문자열 제거 메소드</h3>
     * @param filePath 경로 순회 문자열을 제거하고자 하는 파일경로 문자열
     * @param useOsFileSeparatorOnResult 최종 return 문자열의 파일 경로 구분자를 OS 기반에 맞게 변경할지 여부이며 true 면 적용된다.
     *  true 설정하면 window 의 경우 "C:\sdf\some.txt" 처럼 반환, false 면 "C:/sdf/some.txt" 로 반환
     * @return 경로 순회 문자열이 없는 문자열
     * @throws IllegalArgumentException 경로 순회 문자열 제거 후, 루트 경로 혹은 WEB Root 와 관련된 문자열이 발견되면 예외를 던진다.
     */
    public static String cleanPath(String filePath, boolean useOsFileSeparatorOnResult) throws IllegalArgumentException {
        if(filePath == null || filePath.trim().isEmpty()) {
            return filePath;
        }

        filePath = filePath.trim();

        /*
        윈도우, 리눅스 등 어떤 운영체제를 사용하던 간에 똑같이 경로 구분자를 "/" 로 통일한다.
        이는 똑같은 로직을 태우기 위함이다.
        만약에 최종 경로 String 값의 경로구분자가 OS 기반에 맞게 하고 싶다면 [useOsFileSeparatorOnResult=true] 로 설정한다.
        */
        String sanitizedPath = filePath.replaceAll("\\\\+", "/");

        // "../" 경로 제거
        sanitizedPath = sanitizedPath.replaceAll("\\.\\.", "");

        // "/./" 경로 제거
        sanitizedPath = sanitizedPath.replaceAll("/\\./", "/");

        // "&" 제거
        sanitizedPath = sanitizedPath.replaceAll("&", "");

        // Replace multiple consecutive slashes with a single slash
        sanitizedPath = sanitizedPath.replaceAll("/{2,}", "/");

        // 루트 경로 사용 검사 ("/" or "C:/"). 발견되면 예외를 던진다.
        checkRootPathUsage(filePath, sanitizedPath);

        // URL 경로 체크(= Web root 사용여부 검사). 발견되면 예외를 던진다.
        checkUrlLikePathUsage(filePath, sanitizedPath);

        // 최종적으로 운영체제에 맞게 문자열을 반환할지 분기처리하여 return 한다.
        return useOsFileSeparatorOnResult ?
                changeFileSeparatorDependOnOs(sanitizedPath) : sanitizedPath;
    }

    /**
     * 루트 경로 사용 여부를 체크한다.
     */
    private static void checkRootPathUsage(String filePath, String sanitizedPath) {
        if (sanitizedPath.equals("/") || sanitizedPath.matches("[A-Za-z]:/$")) {
            throw new IllegalArgumentException("Invalid path: " + filePath);
        }
    }

    /**
     * URL Path 사용 여부를 체크한다. Web Root 접근 방어용이다.
     */
    private static void checkUrlLikePathUsage(String filePath, String sanitizedPath) throws IllegalArgumentException {
        Path path = Paths.get(sanitizedPath);
        // Check if the path is a URL-like path (potentially a web root)
        URI uri = path.toUri();
        if (uri.getScheme() != null && uri.getHost() != null) {
            throw new IllegalArgumentException("Path resembles a URL-like path. Error Occurred Path => " + filePath);
        }
    }

    /**
     * 파일 경로 구분자를 운영체제에 맞게 변경한다.
     */
    public static String changeFileSeparatorDependOnOs(String path) {
        String osName = System.getProperty("os.name").toLowerCase();
        return osName.toLowerCase().contains("win") ? path.replace("/", File.separator) : path;
    }

    /**
     * File 인스턴스의 경로에서 경로 순회 문자열을 제거하고 다시 File 을 생성한다.
     */
    public static File cleanPath(File file) {
        String path = file.getPath();
        return new File(cleanPath(path));
    }

    /**
     * Path 인스턴스의 경로에서 경로 순회 문자열을 제거하고 다시 File 을 생성한다.
     */
    public static Path cleanPath(Path file) {
        String path = file.toFile().getPath();
        return Paths.get(cleanPath(path));
    }

    /**
     * 경로 유효성 검증
     * 경로 탐색(Path Traversal) 공격 방지
     * @param path 검증할 경로
     * @param isFileName 파일명 여부 (true: 파일명, false: 디렉토리명)
     * @throws IOException 잘못된 경로일 경우 예외 발생
     */
    private void validatePath(String path, boolean isFileName) throws IOException {
        if (path == null) {
            throw new IOException("경로가 null입니다.");
        }

        // 경로 정규화 시도
        try {
            Path normalizedPath = Paths.get(cleanPath(path)).normalize();
            String normalizedPathStr = normalizedPath.toString();

            // 정규화 후에도 '..'가 남아있는지 확인
            if (normalizedPathStr.contains("..")) {
                String type = isFileName ? "파일명" : "디렉토리 경로";
                throw new IOException("잘못된 " + type + "입니다: " + path);
            }

            // 추가 경로 검증 로직
            if (isFileName) {
                // 파일명에는 경로 구분자가 없어야 함
                if (normalizedPathStr.contains("/") || normalizedPathStr.contains("\\")) {
                    throw new IOException("파일명에 경로 구분자가 포함되어 있습니다: " + path);
                }
            } else {
                // 디렉토리 경로 검증 로직 (필요에 따라 추가)
            }

            // 허용된 문자만 포함되어 있는지 검증 (정규식 사용)
            //if (!normalizedPathStr.matches("[a-zA-Z0-9_\\-\\.가-힣]+")) {
            //    String type = isFileName ? "파일명" : "디렉토리 경로";
            //    throw new IOException("허용되지 않은 문자가 포함된 " + type + "입니다: " + path);
            //}
        } catch (InvalidPathException e) {
            throw new IOException("유효하지 않은 경로입니다: " + path, e);
        }
    }


    /**
     * 파일 확장자 유효성 검증
     * @param fileExt 검증할 파일 확장자
     * @return 허용된 확장자인지 여부
     */
    private boolean isAllowedExtension(String fileExt) {
        return allowedExtensionList.contains(fileExt.toLowerCase());
    }

    /**
     * 설정된 하위 디렉토리 경로 조회
     * @param key 하위 디렉토리 키 (예: bbs-notice, html-editor)
     * @return 설정된 하위 디렉토리 경로
     * @throws IOException 설정된 경로가 없을 경우 예외 발생
     */
    public String getSubDir(String key) throws IOException {
        if (!subDirs.containsKey(key)) {
            throw new IOException("설정된 하위 디렉토리가 없습니다: " + key);
        }
        return subDirs.get(key);
    }

    /**
     * 브라우저별 파일명 인코딩 처리
     * @param fileName 원본 파일명
     * @param userAgent 브라우저 User-Agent
     * @return 인코딩된 파일명
     * @throws IOException 인코딩 처리 중 오류 발생 시
     */
    private String getEncodedFilename(String fileName, String userAgent) throws IOException {
        if (userAgent.contains("MSIE") || userAgent.contains("Trident") || userAgent.contains("Chrome")) {
            // IE, Chrome
            return URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
        } else if (userAgent.contains("Firefox")) {
            // Firefox
            return "\"" + new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"";
        } else {
            // 기타 브라우저
            return URLEncoder.encode(fileName, "UTF-8");
        }
    }

    /**
     * FileVO 객체 생성
     * @param originalFilename 원본 파일명
     * @param storedFilename 저장 파일명
     * @param subDir 저장 디렉토리
     * @param fileSize 파일 크기
     * @param fileExt 파일 확장자
     * @param contentType 컨텐츠 타입
     * @return 생성된 FileVO 객체
     */
    private FileVO createFileVO(String originalFilename, String storedFilename, String subDir,
                               long fileSize, String fileExt, String contentType) {
        FileVO fileVO = new FileVO();
        fileVO.setOriginalFileNm(originalFilename);
        fileVO.setStoredFileNm(storedFilename);
        fileVO.setFilePath(subDir);
        fileVO.setFileSize(fileSize);
        fileVO.setFileExt(fileExt);
        fileVO.setContentType(contentType);
        return fileVO;
    }


    /**
     * 파일 업로드 처리
     * @param files 업로드할 파일 목록
     * @param subDir 저장할 하위 디렉토리 (notice, biz, qna 등)
     * @return 업로드된 파일 정보 목록
     * @throws IOException 파일 처리 중 오류 발생 시
     */
    public List<FileVO> uploadFiles(List<MultipartFile> files, String subDir) throws IOException {
        // 파일 유효성 검증
        validateFiles(files);

        // 디렉토리 경로 검증
        validatePath(subDir, false);

        // 년월 정보 추출
        String yearMonth = dateUtil.getCurrentYearMonth();

        // 년월 디렉토리를 포함한 경로 생성
        String yearMonthPath = subDir + File.separator + yearMonth;

        // 디렉토리 경로 검증
        validatePath(yearMonthPath, false);

        // 디렉토리 생성
        String uploadDir = uploadPath + File.separator + yearMonthPath;
        createDirectoryIfNotExists(uploadDir);

        // 파일 업로드 처리
        return processFileUploads(files, yearMonthPath, uploadDir);
    }

    /**
     * 파일 목록 유효성 검증
     * @param files 검증할 파일 목록
     * @throws IOException 유효성 검증 실패 시
     */
    private void validateFiles(List<MultipartFile> files) throws IOException {
        // 파일 개수 검증
        int validFileCount = (int) files.stream()
                .filter(file -> !file.isEmpty())
                .count();

        if (validFileCount > maxFiles) {
            throw new IOException("파일 개수가 제한을 초과했습니다. 최대 " + maxFiles + "개까지 가능합니다.");
        }
    }

    /**
     * 파일 업로드 처리
     * @param files 업로드할 파일 목록
     * @param subDir 저장할 하위 디렉토리
     * @param uploadDir 업로드 디렉토리 전체 경로
     * @return 업로드된 파일 정보 목록
     * @throws IOException 파일 처리 중 오류 발생 시
     */
    private List<FileVO> processFileUploads(List<MultipartFile> files, String subDir, String uploadDir) throws IOException {
        List<FileVO> uploadedFiles = new ArrayList<>();
        long totalSize = 0;

        for (MultipartFile file : files) {
            if (file.isEmpty()) continue;

            // 파일 크기 검증
            validateFileSize(file, totalSize);
            totalSize += file.getSize();

            // 파일 저장 및 정보 생성
            FileVO fileVO = saveFile(file, subDir, uploadDir);
            uploadedFiles.add(fileVO);
        }

        return uploadedFiles;
    }

    /**
     * 파일 크기 검증
     * @param file 검증할 파일
     * @param currentTotalSize 현재까지의 총 파일 크기
     * @throws IOException 파일 크기 제한 초과 시
     */
    private void validateFileSize(MultipartFile file, long currentTotalSize) throws IOException {
        // 단일 파일 크기 검증
        if (file.getSize() > maxFileSize * 1024 * 1024) {
            throw new IOException("파일 크기가 제한을 초과했습니다. 최대 " + maxFileSize + "MB까지 가능합니다.");
        }

        // 총 파일 크기 검증
        if (currentTotalSize + file.getSize() > maxTotalSize * 1024 * 1024) {
            throw new IOException("총 파일 크기가 제한을 초과했습니다. 최대 " + maxTotalSize + "MB까지 가능합니다.");
        }
    }

    /**
     * 파일 저장 및 정보 생성
     * @param file 저장할 파일
     * @param subDir 저장할 하위 디렉토리
     * @param uploadDir 업로드 디렉토리 전체 경로
     * @return 생성된 파일 정보
     * @throws IOException 파일 저장 중 오류 발생 시
     */
    private FileVO saveFile(MultipartFile file, String subDir, String uploadDir) throws IOException {
        // 원본 파일명 및 확장자 추출
        String originalFilename = file.getOriginalFilename();
        String fileExt = getFileExtension(originalFilename);

        // 확장자 검증
        if (!isAllowedExtension(fileExt)) {
            throw new IOException("허용되지 않은 파일 형식입니다: " + fileExt);
        }

        // UUID를 이용한 저장 파일명 생성
        String storedFilename = UUID.randomUUID() + "." + fileExt;

        // 파일명 검증
        validatePath(storedFilename, true);

        // 파일 저장 경로 생성 및 검증
        Path filePath = createAndValidateFilePath(uploadDir, storedFilename);

        // 파일 저장
        Files.write(filePath, file.getBytes());

        // 파일 정보 생성 및 반환
        return createFileVO(originalFilename, storedFilename, subDir, 
                           file.getSize(), fileExt, file.getContentType());
    }

    /**
     * 파일 저장 경로 생성 및 검증
     * @param uploadDir 업로드 디렉토리 전체 경로
     * @param storedFilename 저장 파일명
     * @return 검증된 파일 경로
     * @throws IOException 경로 검증 실패 시
     */
    private Path createAndValidateFilePath(String uploadDir, String storedFilename) throws IOException {
        Path filePath = Paths.get(uploadDir).normalize().resolve(storedFilename).normalize();

        // 생성된 경로가 업로드 디렉토리 내에 있는지 확인
        File targetFile = filePath.toFile();
        String canonicalUploadDir = new File(uploadDir).getCanonicalPath();
        String canonicalTargetPath = targetFile.getCanonicalPath();

        if (!canonicalTargetPath.startsWith(canonicalUploadDir)) {
            throw new IOException("잘못된 파일 경로입니다.");
        }

        return filePath;
    }

    /**
     * 파일 다운로드 처리
     * @param fileVO 다운로드할 파일 정보
     * @param request HTTP 요청
     * @param response HTTP 응답
     * @throws IOException 파일 처리 중 오류 발생 시
     */
    public void downloadFile(FileVO fileVO, HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 파일 정보 검증 및 파일 객체 생성
        File file = validateAndGetFile(fileVO);

        // 파일 확장자 검증
        validateFileExtension(fileVO.getOriginalFileNm());

        // 브라우저별 인코딩된 파일명 생성
        String userAgent = request.getHeader("User-Agent");
        String encodedFilename = getEncodedFilename(fileVO.getOriginalFileNm(), userAgent);

        // 응답 헤더 설정
        setResponseHeaders(response, fileVO, file, encodedFilename);

        // 파일 전송
        sendFile(file, response);
    }

    /**
     * 파일 정보 검증 및 파일 객체 생성
     * @param fileVO 파일 정보
     * @return 검증된 파일 객체
     * @throws IOException 파일 검증 실패 시
     */
    private File validateAndGetFile(FileVO fileVO) throws IOException {
        // 파일 경로 생성
        String filePath = uploadPath + File.separator + fileVO.getFilePath() + File.separator + fileVO.getStoredFileNm();
        File file = new File(filePath);

        // 파일 존재 여부 확인
        if (!file.exists()) {
            throw new IOException("파일을 찾을 수 없습니다: " + filePath);
        }

        // 보안 검사: 경로 검증 (경로 탐색 공격 방지)
        String canonicalPath = file.getCanonicalPath();
        if (!canonicalPath.startsWith(new File(uploadPath).getCanonicalPath())) {
            throw new IOException("잘못된 파일 경로입니다.");
        }

        return file;
    }

    /**
     * 파일 확장자 검증
     * @param filename 파일명
     * @throws IOException 허용되지 않은 확장자일 경우
     */
    private void validateFileExtension(String filename) throws IOException {
        String fileExt = getFileExtension(filename);
        if (!isAllowedExtension(fileExt)) {
            throw new IOException("허용되지 않은 파일 형식입니다: " + fileExt);
        }
    }

    /**
     * 응답 헤더 설정
     * @param response HTTP 응답
     * @param fileVO 파일 정보
     * @param file 파일 객체
     * @param encodedFilename 인코딩된 파일명
     */
    private void setResponseHeaders(HttpServletResponse response, FileVO fileVO, File file, String encodedFilename) {
        // 컨텐츠 타입 및 길이 설정
        response.setContentType(fileVO.getContentType());
        response.setContentLength((int) file.length());

        // 다운로드 관련 헤더 설정
        response.setHeader("Content-Disposition", "attachment; filename=" + encodedFilename);
        response.setHeader("Content-Transfer-Encoding", "binary");

        // 캐시 관련 헤더 설정
        response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        response.setHeader("Pragma", "no-cache");
        response.setHeader("Expires", "0");

        // 보안 관련 헤더 설정
        response.setHeader("X-Content-Type-Options", "nosniff");
    }

    /**
     * 파일 전송
     * @param file 전송할 파일
     * @param response HTTP 응답
     * @throws IOException 파일 전송 중 오류 발생 시
     */
    private void sendFile(File file, HttpServletResponse response) throws IOException {
        try (FileInputStream fis = new FileInputStream(file);
             OutputStream os = response.getOutputStream()) {

            byte[] buffer = new byte[4096];
            int bytesRead;

            while ((bytesRead = fis.read(buffer)) != -1) {
                os.write(buffer, 0, bytesRead);
            }
            os.flush();
        }
    }

    /**
     * 파일 삭제 처리
     * @param fileVO 삭제할 파일 정보
     * @return 삭제 성공 여부
     */
    public boolean deleteFile(FileVO fileVO) {
        // real-file-delete 설정이 false인 경우 실제 파일 삭제하지 않고 true 반환
        if (!realFileDelete) {
            return true;
        }

        try {
            // 파일 경로 검증 및 파일 객체 생성
            File file = getValidatedFileForDelete(fileVO);

            // 파일 존재 여부 확인 및 삭제
            return file.exists() && file.delete();
        } catch (IOException e) {
            // 로그 기록 등의 처리를 추가할 수 있음
            return false;
        }
    }

    /**
     * 삭제를 위한 파일 객체 생성 및 검증
     * @param fileVO 파일 정보
     * @return 검증된 파일 객체
     * @throws IOException 파일 경로 검증 실패 시
     */
    private File getValidatedFileForDelete(FileVO fileVO) throws IOException {
        // 파일 경로 및 파일명 검증
        String subDir = fileVO.getFilePath();
        String storedFilename = fileVO.getStoredFileNm();

        // 경로 검증
        validatePath(subDir, false);
        validatePath(storedFilename, true);

        // 파일 경로 생성
        String filePath = uploadPath + File.separator + subDir + File.separator + storedFilename;
        File file = new File(filePath);

        // 보안 검사: 경로 검증 (경로 탐색 공격 방지)
        String canonicalPath = file.getCanonicalPath();
        if (!canonicalPath.startsWith(new File(uploadPath).getCanonicalPath())) {
            throw new IOException("잘못된 파일 경로입니다.");
        }

        return file;
    }

    /**
     * 디렉토리 생성
     * @param directory 생성할 디렉토리 경로
     */
    @CanIgnoreReturnValue
    private void createDirectoryIfNotExists(String directory) {
        File dir = new File(directory);
        if (!dir.exists()) {
            dir.mkdirs();
        }
    }

    /**
     * 파일 확장자 추출
     * @param filename 파일명
     * @return 파일 확장자 (소문자로 변환됨)
     */
    private String getFileExtension(String filename) {
        if (filename == null || filename.isEmpty()) {
            return "";
        }

        int lastDotIndex = filename.lastIndexOf(".");
        if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) {
            return "";
        }

        return filename.substring(lastDotIndex + 1).toLowerCase();
    }

    /**
     * 파일을 새로운 경로로 복사합니다.
     * 원본 파일과 동일한 디렉토리 구조를 유지하며 복사합니다.
     *
     * @param sourcePath 원본 파일 경로
     * @param targetPath 대상 파일 경로
     * @return 복사 성공 여부
     * @throws IOException 파일 복사 실패 시 예외 발생
     */
    public boolean copyFile(String sourcePath, String uploadDir, String targetPath) throws IOException {
        if (sourcePath == null || targetPath == null || uploadDir == null) {
            throw new IOException("원본 또는 대상 파일 경로가 null입니다.");
        }

        File sourceFile = new File(uploadPath + File.separator + sourcePath);
        File targetFile = new File(uploadPath + File.separator + targetPath);

        // 원본 파일 존재 여부 확인
        if (!sourceFile.exists()) {
            throw new IOException("원본 파일이 존재하지 않습니다: " + uploadPath + File.separator + sourcePath);
        }

        // 원본 파일이 일반 파일인지 확인
        if (!sourceFile.isFile()) {
            throw new IOException("원본이 일반 파일이 아닙니다: " + uploadPath + File.separator + sourcePath);
        }

        createDirectoryIfNotExists( uploadPath + File.separator + uploadDir);

        // 대상 디렉토리 생성
        File targetDir = targetFile.getParentFile();
        if (targetDir != null && !targetDir.exists()) {
            if (!targetDir.mkdirs()) {
                throw new IOException("대상 디렉토리 생성 실패: " + targetDir.getAbsolutePath());
            }
        }

        // 파일 복사
        try {
            Files.copy(sourceFile.toPath(), targetFile.toPath(),
                      java.nio.file.StandardCopyOption.REPLACE_EXISTING);
            return true;
        } catch (IOException e) {
            throw new IOException("파일 복사 실패: " + sourcePath + " -> " + targetPath, e);
        }
    }

}
