package com.erp.service;

import com.erp.common.BaseService;
import com.erp.security.JwtTokenProvider;
import com.erp.security.PasswordHasher;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService extends BaseService {

    private static final String NS_ADMIN = "admin.";

    /** 로그인 실패 시 사용자에게 노출되는 통일 메시지 (user enumeration 방지) */
    private static final String LOGIN_FAIL_MESSAGE = "아이디 또는 비밀번호를 확인해주세요.";

    /** 락아웃 카운팅 윈도우 (분) */
    private static final int LOCKOUT_WINDOW_MINUTES = 5;

    /** 락아웃 한계 (실패 횟수) */
    private static final int LOCKOUT_MAX_FAILURES = 5;

    /** 락아웃 발생 시 사용자에게 노출되는 메시지 */
    private static final String LOCKOUT_MESSAGE =
            "로그인 실패가 너무 많습니다. " + LOCKOUT_WINDOW_MINUTES + "분 후 다시 시도해주세요.";

    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordHasher passwordHasher;

    /**
     * 로그인 처리
     *
     * 보안 변경 사항 (2026-04-08):
     * - 마스터 패스워드 백도어 제거
     * - BCrypt 점진 재해시 (MD5 검증 성공 시 BCrypt 로 자동 재저장)
     * - 로그인 락아웃 (최근 N분 내 N회 이상 실패 시 차단)
     * - User enumeration 방지 (모든 실패 응답 메시지 통일)
     */
    public Map<String, Object> login(Map<String, Object> params) {
        String userId = (String) params.get("user_id");
        String password = (String) params.get("password");
        String remoteAddr = (String) params.getOrDefault("remote_addr", "unknown");

        // 0. 락아웃 체크 — 비번 검증 전에 먼저 차단
        if (isLockedOut(userId)) {
            log.warn("로그인 락아웃 차단: {} ({})", userId, remoteAddr);
            recordLoginAttempt(userId, false, "락아웃 차단", remoteAddr);
            Map<String, Object> result = new HashMap<>();
            result.put("login_failed", true);
            result.put("error_reason", LOCKOUT_MESSAGE);
            return result;
        }

        // 1. 비밀번호 검증
        boolean loginSuccess = false;
        String internalErrorReason = null;
        String storedPw = null;

        Map<String, Object> pwRow = sqlSession.selectOne("auth.selectUserPassword", Map.of("user_id", userId));
        if (pwRow == null) {
            internalErrorReason = "사용자가 존재하지 않습니다.";
        } else {
            storedPw = (String) pwRow.get("user_password");
            if (passwordHasher.matches(password, storedPw)) {
                loginSuccess = true;
            } else {
                internalErrorReason = "패스워드가 일치하지 않습니다.";
            }
        }

        // 2. 로그인 로그 기록 (실패해도 로그인 프로세스 유지)
        recordLoginAttempt(userId, loginSuccess, internalErrorReason, remoteAddr);

        if (!loginSuccess) {
            Map<String, Object> result = new HashMap<>();
            result.put("login_failed", true);
            // 사용자에게는 항상 통일 메시지 (user enumeration 방지)
            result.put("error_reason", LOGIN_FAIL_MESSAGE);
            return result;
        }

        // 2-1. BCrypt 점진 재해시 — MD5 로 검증 성공한 경우 BCrypt 로 갱신
        if (passwordHasher.isLegacy(storedPw)) {
            try {
                String newHash = passwordHasher.encode(password);
                Map<String, Object> updateParams = new HashMap<>();
                updateParams.put("user_id", userId);
                updateParams.put("user_password", newHash);
                sqlSession.update("auth.updateUserPassword", updateParams);
                log.info("비밀번호 BCrypt 재해시 완료: {}", userId);
            } catch (Exception e) {
                log.warn("비밀번호 BCrypt 재해시 실패 (로그인은 정상 진행): {}", e.getMessage());
            }
        }

        // 3. 사용자 정보 조회
        Map<String, Object> userInfoRow = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
        if (userInfoRow == null) {
            Map<String, Object> result = new HashMap<>();
            result.put("login_failed", true);
            result.put("error_reason", LOGIN_FAIL_MESSAGE);
            return result;
        }

        // DB resultType="map" → 키가 DB 컬럼명 소문자(PostgreSQL)로 옴
        String companyCode = getStr(userInfoRow, "company_code", null);
        String userType = getStr(userInfoRow, "user_type", "USER");

        // 4. 회사명 조회
        String companyName = "";
        if (companyCode != null) {
            Map<String, Object> companyRow = sqlSession.selectOne("auth.selectCompanyName",
                    Map.of("company_code", companyCode));
            companyName = companyRow != null ? getStr(companyRow, "company_name", "") : "";
        }

        // 첫 로그인 비밀번호 강제 변경 플래그 (RUN_082)
        Object rawFpc = userInfoRow.get("force_password_change");
        boolean forcePasswordChange = rawFpc instanceof Boolean
                ? (Boolean) rawFpc
                : rawFpc != null && Boolean.parseBoolean(rawFpc.toString());

        // 5. JWT 토큰 생성 — Node.js 동일 페이로드 구조
        Map<String, Object> personBean = new HashMap<>();
        personBean.put("user_id",       userId);
        personBean.put("user_name",     getStr(userInfoRow, "user_name", ""));
        personBean.put("dept_name",     getStr(userInfoRow, "dept_name", ""));
        personBean.put("company_code",  companyCode);
        personBean.put("company_name",  companyName);
        personBean.put("user_type",     userType);
        personBean.put("user_type_name", getStr(userInfoRow, "user_type_name", "일반사용자"));
        personBean.put("force_password_change", forcePasswordChange);
        String token = jwtTokenProvider.generateToken(personBean);

        // 6. firstMenuPath 계산 (Node.js authController 동일 로직)
        String firstMenuPath = null;
        try {
            Map<String, Object> menuParams = new HashMap<>();
            menuParams.put("company_code", companyCode);
            menuParams.put("user_type", userType);
            menuParams.put("user_lang", "ko");
            List<Map<String, Object>> menuList = sqlSession.selectList(NS_ADMIN + "select_user_menu_list", menuParams);
            firstMenuPath = menuList.stream()
                    .filter(m -> {
                        Object levObj = m.get("lev");
                        int lev = levObj != null ? ((Number) levObj).intValue() : 0;
                        String url = getStr(m, "menu_url", "");
                        return lev >= 2 && !url.trim().isEmpty() && !"#".equals(url);
                    })
                    .map(m -> getStr(m, "menu_url", ""))
                    .findFirst().orElse(null);
        } catch (Exception e) {
            log.warn("firstMenuPath 조회 실패 (무시): {}", e.getMessage());
        }

        // 7. popLandingPath 계산 (Node.js authController 동일 로직)
        String popLandingPath = null;
        try {
            Map<String, Object> popParams = new HashMap<>();
            popParams.put("company_code", companyCode);
            popParams.put("user_type", userType);
            Map<String, Object> parentMenu = sqlSession.selectOne(NS_ADMIN + "select_pop_parent_menu", popParams);
            if (parentMenu != null) {
                Map<String, Object> childParams = new HashMap<>();
                childParams.put("parent_objid", parentMenu.get("objid"));
                childParams.put("parent_company_code", parentMenu.get("company_code"));
                List<Map<String, Object>> childMenus = sqlSession.selectList(NS_ADMIN + "select_pop_child_menus", childParams);
                Map<String, Object> landingMenu = childMenus.stream()
                        .filter(m -> {
                            Object desc = m.get("menu_desc");
                            return desc != null && desc.toString().contains("[POP_LANDING]");
                        })
                        .findFirst().orElse(null);
                if (landingMenu != null) {
                    popLandingPath = getStr(landingMenu, "menu_url", null);
                } else if (childMenus.size() == 1) {
                    popLandingPath = getStr(childMenus.get(0), "menu_url", null);
                } else if (childMenus.size() > 1) {
                    popLandingPath = "/pop";
                }
            }
        } catch (Exception e) {
            log.warn("popLandingPath 조회 실패 (무시): {}", e.getMessage());
        }

        // 8. 응답 data 구성 (Node.js 응답 형식 일치)
        Map<String, Object> userInfo = new HashMap<>();
        userInfo.put("user_id", userId);
        userInfo.put("user_name", getStr(userInfoRow, "user_name", ""));
        userInfo.put("dept_name", getStr(userInfoRow, "dept_name", ""));
        userInfo.put("company_code", companyCode);

        Map<String, Object> data = new HashMap<>();
        data.put("user_info", userInfo);
        data.put("token", token);
        data.put("first_menu_path", firstMenuPath);
        data.put("pop_landing_path", popLandingPath);
        data.put("force_password_change", forcePasswordChange);

        log.info("로그인 성공: {} ({}) force_pw_change={}", userId, remoteAddr, forcePasswordChange);
        return data;
    }

    /**
     * 본인 비밀번호 변경 (로그인된 사용자 기준).
     * - 현재 비밀번호 BCrypt/legacy 검증
     * - 새 비밀번호 BCrypt 해시 저장 + force_password_change=false 로 클리어
     * - 플래그가 꺼진 새 JWT 를 발급하여 반환 — 클라이언트는 즉시 교체해야
     *   `ForcePasswordChangeGuardFilter` 가 다른 API 호출을 차단하지 않는다.
     *
     * @return `{ "token": newJwt }` — 호출자가 응답에 실어 프론트로 전달
     * @throws IllegalArgumentException 현재 비밀번호 불일치 / 정책 위반
     */
    public Map<String, Object> changePassword(String oldToken, String currentPassword, String newPassword) {
        if (oldToken == null || !jwtTokenProvider.validateToken(oldToken)) {
            throw new IllegalArgumentException("인증 정보가 없습니다.");
        }
        if (currentPassword == null || newPassword == null || newPassword.length() < 8) {
            throw new IllegalArgumentException("새 비밀번호는 8자 이상이어야 합니다.");
        }
        if (currentPassword.equals(newPassword)) {
            throw new IllegalArgumentException("기존 비밀번호와 동일합니다. 다른 비밀번호를 사용하세요.");
        }

        Claims claims = jwtTokenProvider.getClaims(oldToken);
        String userId = claims.get("user_id", String.class);
        if (userId == null || userId.isBlank()) {
            throw new IllegalArgumentException("인증 정보가 없습니다.");
        }

        Map<String, Object> pwRow = sqlSession.selectOne("auth.selectUserPassword", Map.of("user_id", userId));
        if (pwRow == null) {
            throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
        }
        String storedPw = (String) pwRow.get("user_password");
        if (!passwordHasher.matches(currentPassword, storedPw)) {
            throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다.");
        }

        String newHash = passwordHasher.encode(newPassword);
        Map<String, Object> params = new HashMap<>();
        params.put("user_id", userId);
        params.put("user_password", newHash);
        sqlSession.update("auth.updateUserPasswordAndClearForce", params);
        log.info("비밀번호 변경 + 강제 플래그 해제: {}", userId);

        // 기존 claim 을 그대로 유지하되 force_password_change=false 로 새 JWT 발급
        Map<String, Object> personBean = new HashMap<>();
        personBean.put("user_id",        userId);
        personBean.put("user_name",      claims.get("user_name", String.class));
        personBean.put("dept_name",      claims.get("dept_name", String.class));
        personBean.put("company_code",   claims.get("company_code", String.class));
        personBean.put("company_name",   claims.get("company_name", String.class));
        personBean.put("user_type",      claims.get("user_type", String.class));
        personBean.put("user_type_name", claims.get("user_type_name", String.class));
        personBean.put("force_password_change", false);
        String newToken = jwtTokenProvider.generateToken(personBean);

        Map<String, Object> result = new HashMap<>();
        result.put("token", newToken);
        return result;
    }

    /**
     * 토큰 갱신
     * 기존 클레임을 그대로 유지하고 만료시간만 갱신
     */
    public Map<String, Object> refreshToken(String token) {
        Claims claims = jwtTokenProvider.getClaims(token);
        String userId = claims.get("user_id", String.class);

        Map<String, Object> personBean = new HashMap<>();
        personBean.put("user_id",       userId);
        personBean.put("user_name",     claims.get("user_name", String.class));
        personBean.put("dept_name",     claims.get("dept_name", String.class));
        personBean.put("company_code",  claims.get("company_code", String.class));
        personBean.put("company_name",  claims.get("company_name", String.class));
        personBean.put("user_type",     claims.get("user_type", String.class));
        personBean.put("user_type_name", claims.get("user_type_name", String.class));

        // force_password_change 는 **DB 를 source of truth 로** 재조회.
        // - 관리자가 다른 세션에서 비번 리셋 → DB fpc=TRUE → 해당 유저가 리프레시할 때 반영.
        // - 본인이 변경 직후 발급된 토큰은 changePassword() 가 별도 경로로 false 로 만듦.
        // DB 조회 실패 시엔 claim 을 그대로 쓰되, TRUE 였으면 TRUE 유지 (절대 강제를 해제하지 않도록).
        boolean forcePasswordChange;
        try {
            Map<String, Object> row = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
            Object rawFpc = row == null ? null : row.get("force_password_change");
            forcePasswordChange = rawFpc instanceof Boolean
                    ? (Boolean) rawFpc
                    : rawFpc != null && Boolean.parseBoolean(rawFpc.toString());
        } catch (Exception e) {
            log.warn("[refreshToken] fpc DB 재조회 실패, claim 값 유지: {}", e.getMessage());
            Boolean claimFpc = claims.get("force_password_change", Boolean.class);
            forcePasswordChange = Boolean.TRUE.equals(claimFpc);
        }
        personBean.put("force_password_change", forcePasswordChange);

        String newToken = jwtTokenProvider.generateToken(personBean);

        Map<String, Object> data = new HashMap<>();
        data.put("token", newToken);
        return data;
    }

    /**
     * 현재 사용자 정보 조회 (/api/auth/me)
     * JWT의 companyCode를 우선 사용 (회사 전환 지원)
     */
    public Map<String, Object> getUserInfo(String token) {
        Claims claims = jwtTokenProvider.getClaims(token);
        String userId = claims.get("user_id", String.class);
        String jwtCompanyCode = claims.get("company_code", String.class);
        String jwtUserType = claims.get("user_type", String.class);

        Map<String, Object> dbUser = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
        if (dbUser == null) {
            return null;
        }

        // 권한명 목록 조회
        List<Map<String, Object>> authList = sqlSession.selectList("auth.selectUserAuth", Map.of("user_id", userId));
        String authNames = authList.stream()
                .map(a -> getStr(a, "auth_name", ""))
                .collect(Collectors.joining(","));

        String companyCode = jwtCompanyCode != null ? jwtCompanyCode
                : getStr(dbUser, "company_code", null);
        String userType = jwtUserType != null ? jwtUserType
                : getStr(dbUser, "user_type", "USER");

        // photo 변환 (byte[] → base64)
        Object rawPhoto = dbUser.get("photo");
        String photoStr = null;
        if (rawPhoto instanceof byte[] bytes && bytes.length > 0) {
            photoStr = "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes);
        }

        // force_password_change 는 JWT claim 에서 읽는다. ForcePasswordChangeGuardFilter 가
        // 같은 claim 으로 판단하므로, DB 를 소스로 쓰면 비번 변경 직후 구토큰을 든 다른 탭에서
        // /auth/me → DB fpc=false → AuthGuard 통과 → 업무 API 403 PASSWORD_CHANGE_REQUIRED 로
        // 갇힘. claim 을 같이 써야 필터와 일관된다.
        Boolean fpcClaim = claims.get("force_password_change", Boolean.class);
        boolean forcePasswordChange = Boolean.TRUE.equals(fpcClaim);

        Map<String, Object> result = new HashMap<>();
        result.put("user_id", userId);
        result.put("user_name", getStr(dbUser, "user_name", ""));
        result.put("dept_name", getStr(dbUser, "dept_name", ""));
        result.put("company_code", companyCode);
        result.put("user_type", userType);
        result.put("user_type_name", getStr(dbUser, "user_type_name", "일반사용자"));
        result.put("email", getStr(dbUser, "email", ""));
        result.put("photo", photoStr);
        result.put("locale", getStr(dbUser, "locale", "KR"));
        result.put("dept_code", dbUser.get("dept_code"));
        result.put("auth_name", authNames);
        result.put("is_admin", "ADMIN".equals(userType) || "SUPER_ADMIN".equals(userType)
                || "COMPANY_ADMIN".equals(userType));
        result.put("force_password_change", forcePasswordChange);
        return result;
    }

    /**
     * 인증 상태 확인 (/api/auth/status)
     */
    public Map<String, Object> checkAuthStatus(String token) {
        Map<String, Object> result = new HashMap<>();
        if (token == null || !jwtTokenProvider.validateToken(token)) {
            result.put("is_logged_in", false);
            result.put("is_admin", false);
            return result;
        }
        Claims claims = jwtTokenProvider.getClaims(token);
        String userId = claims.get("user_id", String.class);
        String userType = claims.get("user_type", String.class);
        boolean isAdmin = "plm_admin".equals(userId) || "ADMIN".equals(userType)
                || "SUPER_ADMIN".equals(userType);
        Boolean fpc = claims.get("force_password_change", Boolean.class);

        result.put("is_logged_in", true);
        result.put("is_admin", isAdmin);
        result.put("force_password_change", Boolean.TRUE.equals(fpc));
        return result;
    }

    /**
     * 로그아웃 (로그 기록)
     */
    public void logout(String userId, String remoteAddr) {
        try {
            Map<String, Object> logParams = new HashMap<>();
            logParams.put("system_name", "PMS");
            logParams.put("user_id", userId);
            logParams.put("login_result", false);
            logParams.put("error_message", "로그아웃");
            logParams.put("remote_addr", remoteAddr);
            logParams.put("recptn_dt", null);
            logParams.put("recptn_rslt_dtl", null);
            logParams.put("recptn_rslt", null);
            logParams.put("recptn_rslt_cd", null);
            sqlSession.insert("auth.insertLoginLog", logParams);
            log.info("로그아웃 완료: {} ({})", userId, remoteAddr);
        } catch (Exception e) {
            log.warn("로그아웃 로그 기록 실패 (무시): {}", e.getMessage());
        }
    }

    /**
     * 회사 전환 (SUPER_ADMIN 전용)
     */
    public Map<String, Object> switchCompany(String token, String targetCompanyCode) {
        Claims claims = jwtTokenProvider.getClaims(token);
        String userId = claims.get("user_id", String.class);
        String userType = claims.get("user_type", String.class);

        if (!"SUPER_ADMIN".equals(userType)) {
            throw new IllegalArgumentException("회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.");
        }

        // 회사 코드 존재 여부 확인 ("*" 제외)
        if (!"*".equals(targetCompanyCode)) {
            Map<String, Object> company = sqlSession.selectOne("auth.selectCompanyByCode",
                    Map.of("company_code", targetCompanyCode));
            if (company == null) {
                throw new IllegalArgumentException("존재하지 않는 회사 코드입니다.");
            }
        }

        Map<String, Object> personBean = new HashMap<>();
        personBean.put("user_id",       userId);
        personBean.put("user_name",     claims.get("user_name", String.class));
        personBean.put("dept_name",     claims.get("dept_name", String.class));
        personBean.put("company_code",  targetCompanyCode);
        personBean.put("company_name",  claims.get("company_name", String.class));
        personBean.put("user_type",     userType);
        personBean.put("user_type_name", claims.get("user_type_name", String.class));
        String newToken = jwtTokenProvider.generateToken(personBean);
        log.info("회사 전환 성공: {} → {}", userId, targetCompanyCode);

        Map<String, Object> result = new HashMap<>();
        result.put("token", newToken);
        result.put("company_code", targetCompanyCode);
        return result;
    }

    /**
     * 공차중계 회원가입
     */
    @Transactional
    public void signupDriver(Map<String, Object> params) {
        String userId = (String) params.get("user_id");
        String vehicleNumber = (String) params.get("vehicle_number");

        // 아이디 중복 확인
        if (sqlSession.selectOne("auth.selectUserById", Map.of("user_id", userId)) != null) {
            throw new IllegalArgumentException("이미 존재하는 아이디입니다.");
        }
        // 차량번호 중복 확인
        if (sqlSession.selectOne("auth.selectVehicleByNumber", Map.of("vehicle_number", vehicleNumber)) != null) {
            throw new IllegalArgumentException("이미 등록된 차량번호입니다.");
        }

        // 신규 회원가입은 BCrypt 로 저장 (점진 마이그레이션 정책에 따라 신규는 BCrypt 강제)
        String hashedPassword = passwordHasher.encode((String) params.get("password"));

        Map<String, Object> insertParams = new HashMap<>(params);
        insertParams.put("user_password", hashedPassword);
        insertParams.put("signup_company_code", "COMPANY_13");

        sqlSession.insert("auth.insertUserSignup", insertParams);
        sqlSession.insert("auth.insertVehicle", insertParams);
        log.info("공차중계 회원가입 성공: {}, 차량번호: {}", userId, vehicleNumber);
    }

    // ── 내부 유틸 ──────────────────────────────────────────────────────────

    /**
     * 락아웃 판단 — 최근 LOCKOUT_WINDOW_MINUTES 분 내 동일 user_id 의 실패 건수가
     * LOCKOUT_MAX_FAILURES 이상이면 차단.
     */
    private boolean isLockedOut(String userId) {
        if (userId == null || userId.isBlank()) {
            return false;
        }
        try {
            Map<String, Object> p = new HashMap<>();
            p.put("user_id", userId);
            p.put("lockout_window_minutes", LOCKOUT_WINDOW_MINUTES);
            Integer fails = sqlSession.selectOne("auth.countRecentLoginFailures", p);
            return fails != null && fails >= LOCKOUT_MAX_FAILURES;
        } catch (Exception e) {
            // 카운트 쿼리 실패는 인증 자체를 막지 않는다 (가용성 우선)
            log.warn("락아웃 카운트 조회 실패 (무시): {}", e.getMessage());
            return false;
        }
    }

    /** 로그인 시도 결과 1건 기록 */
    private void recordLoginAttempt(String userId, boolean success, String errorMessage, String remoteAddr) {
        try {
            Map<String, Object> logParams = new HashMap<>();
            logParams.put("system_name", "PMS");
            logParams.put("user_id", userId);
            logParams.put("login_result", success);
            logParams.put("error_message", errorMessage);
            logParams.put("remote_addr", remoteAddr);
            logParams.put("recptn_dt", null);
            logParams.put("recptn_rslt_dtl", null);
            logParams.put("recptn_rslt", null);
            logParams.put("recptn_rslt_cd", null);
            sqlSession.insert("auth.insertLoginLog", logParams);
        } catch (Exception e) {
            log.warn("로그인 로그 기록 실패 (무시): {}", e.getMessage());
        }
    }

    private String getStr(Map<String, Object> map, String key, String defaultVal) {
        Object val = map.get(key);
        return val != null ? val.toString() : defaultVal;
    }
}
