package com.erp.provisioning;

import com.erp.tenant.CompanyResolver;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
 * 회사 프로비저닝 오케스트레이션.
 *
 * 6 단계:
 *   1. REGISTER_META   — COMPANY_MNG 에 db_status='provisioning' 으로 row 선반영 (initiate)
 *   2. CREATE_DATABASE — CREATE DATABASE "{prefix}_invyone"
 *   3. COPY_SCHEMA     — pg_dump --schema-only | psql
 *   4. COPY_DATA       — 선택 + 필수 그룹의 테이블들 JDBC 복사
 *   5. CREATE_ADMIN    — user_info 에 {prefix}_admin INSERT (BCrypt)
 *   6. FINALIZE        — COMPANY_MNG.db_status='active' + CompanyResolver 캐시 무효화
 *
 * 실패 시 보상:
 *   - DROP DATABASE
 *   - COMPANY_MNG.db_status='failed'
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyProvisioningService {

    private final TenantDbSettings tenantDbSettings;
    private final ProvisioningRegistry registry;
    private final DatabaseCreator dbCreator;
    private final SchemaCopier schemaCopier;
    private final DataCopier dataCopier;
    private final AdminAccountCreator adminCreator;
    private final CompanyResolver companyResolver;
    private final SqlSession sqlSession;
    private final CompanyTemplatesService templatesService;
    private final CompanyAuditLogService auditLogService;

    @Value("${spring.datasource.url}")
    private String metaJdbcUrl;

    private String metaDbName() {
        int slash = metaJdbcUrl.lastIndexOf('/');
        if (slash < 0) return "invyone";
        String tail = metaJdbcUrl.substring(slash + 1);
        int q = tail.indexOf('?');
        return q < 0 ? tail : tail.substring(0, q);
    }

    /** Step 1: 메타 DB 에 row 선반영 + 레지스트리 생성. 동기 실행. */
    public ProvisioningStatus initiate(Map<String, Object> req) {
        String companyCode = (String) req.get("company_code");
        String dbPrefix = (String) req.get("db_prefix");
        String dbName = dbPrefix + "_invyone";
        String subdomain = (String) req.get("subdomain");

        // 템플릿 그룹 수 집계: 필수(3) + 선택된 optional
        @SuppressWarnings("unchecked")
        List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
        int templatesCount = (int) java.util.Arrays.stream(TableGroup.values())
                .filter(g -> g.isRequired() || selectedGroupIds.contains(g.name()))
                .count();

        Map<String, Object> insertParams = new HashMap<>(req);
        insertParams.put("db_name", dbName);
        insertParams.put("db_host", tenantDbSettings.host());
        insertParams.put("db_status", "provisioning");
        insertParams.put("templates_count", templatesCount);
        insertParams.putIfAbsent("status", "active");
        insertParams.putIfAbsent("writer", "SUPER_ADMIN");
        sqlSession.insert("provisioning.insertCompanyWithTenant", insertParams);

        ProvisioningStatus s = new ProvisioningStatus();
        s.setId(UUID.randomUUID().toString());
        s.setCompanyCode(companyCode);
        s.setDbName(dbName);
        s.setSubdomain(subdomain);
        s.setCurrentStep(ProvisioningStatus.Step.REGISTER_META.name());
        s.setProgress(1);
        s.setTotalSteps(6);
        s.setStatus("in_progress");
        s.setStartedAt(Instant.now());
        registry.register(s);
        return s;
    }

    /** Step 2~6: 비동기 실행. Controller 가 accepted(202) 리턴 후 백그라운드 진행. */
    @Async
    public void provisionAsync(String jobId, Map<String, Object> req) {
        // @Async 스레드는 새로 생성되어 ThreadLocal 을 상속받지 않음.
        // sqlSession 이 tenant 라우팅을 타므로 메타 DB 를 명시적으로 고정.
        DbContextHolder.setMeta();
        try {
            doProvision(jobId, req);
        } finally {
            DbContextHolder.clear();
        }
    }

    private void doProvision(String jobId, Map<String, Object> req) {
        ProvisioningStatus status = registry.get(jobId);
        if (status == null) {
            log.error("[Provisioning] jobId not found: {}", jobId);
            return;
        }
        String dbName = status.getDbName();
        String subdomain = status.getSubdomain();
        String companyCode = status.getCompanyCode();
        String dbPrefix = (String) req.get("db_prefix");
        String initialPassword = (String) req.get("initial_password");
        String companyName = (String) req.get("company_name");
        // force_password_change: 기본 true (첫 로그인 시 비밀번호 변경 강제). 프론트에서 명시적으로 false 주면 그대로 반영.
        Object fpc = req.get("force_password_change");
        boolean forcePasswordChange = fpc == null || Boolean.parseBoolean(fpc.toString());

        @SuppressWarnings("unchecked")
        List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
        List<String> tables = gatherTables(selectedGroupIds);

        try {
            // Step 2: CREATE DATABASE
            advance(jobId, ProvisioningStatus.Step.CREATE_DATABASE, 2);
            dbCreator.createDatabase(dbName);

            // Step 3: Copy schema
            advance(jobId, ProvisioningStatus.Step.COPY_SCHEMA, 3);
            schemaCopier.copySchema(metaDbName(), dbName);

            // Step 4 & 5: Copy data + admin (같은 dst Connection 재사용)
            advance(jobId, ProvisioningStatus.Step.COPY_DATA, 4);
            try (Connection src = DriverManager.getConnection(
                    tenantDbSettings.buildJdbcUrl(metaDbName()),
                    tenantDbSettings.username(), tenantDbSettings.password());
                 Connection dst = DriverManager.getConnection(
                    tenantDbSettings.buildJdbcUrl(dbName),
                    tenantDbSettings.username(), tenantDbSettings.password())) {

                src.setAutoCommit(true);
                dst.setAutoCommit(false);
                int totalRows = 0;
                try {
                    // 테이블 하나라도 실패하면 catch 에서 rollback 후 전체 실패 처리.
                    // (Codex 리뷰 #3 원자성 보장, 2026-04-24).
                    for (String t : tables) {
                        totalRows += dataCopier.copyTable(src, dst, t);
                    }
                    dst.commit();
                } catch (Exception copyErr) {
                    try { dst.rollback(); } catch (Exception rb) { log.warn("rollback failed: {}", rb.getMessage()); }
                    throw copyErr;
                }
                log.info("[Provisioning] DATA total {} rows across {} tables", totalRows, tables.size());

                // 시퀀스 재설정은 반드시 autoCommit=true 로. 개별 setval 실패가 전체 트랜잭션을
                // abort 시켜 이후 setval 이 "current transaction is aborted" 로 전부 실패하는 것 방지.
                dst.setAutoCommit(true);
                dataCopier.resetSequences(dst);

                // Step 5: Admin account
                advance(jobId, ProvisioningStatus.Step.CREATE_ADMIN, 5);
                String adminId = dbPrefix + "_admin";
                String adminName = (companyName == null ? companyCode : companyName) + " 관리자";
                dst.setAutoCommit(true);
                adminCreator.createCompanyAdmin(dst, companyCode, adminId, initialPassword, adminName, forcePasswordChange);
            }

            // Step 6: Finalize
            advance(jobId, ProvisioningStatus.Step.FINALIZE, 6);
            Map<String, Object> upd = new HashMap<>();
            upd.put("company_code", companyCode);
            upd.put("db_status", "active");
            sqlSession.update("provisioning.updateDbStatus", upd);

            // installed_groups 메타 저장
            templatesService.saveInstalledGroups(companyCode, selectedGroupIds);

            companyResolver.invalidate(subdomain);

            registry.update(jobId, s -> {
                s.setStatus("completed");
                s.setCompletedAt(Instant.now());
            });
            log.info("[Provisioning] ✅ completed: company={} db={} subdomain={}", companyCode, dbName, subdomain);

            // 감사 로그 — 성공
            Map<String, Object> auditDetails = new HashMap<>();
            auditDetails.put("db_name", dbName);
            auditDetails.put("subdomain", subdomain);
            auditDetails.put("selected_groups", selectedGroupIds);
            auditLogService.log(companyCode, null, CompanyAuditLogService.ACTION_CREATE, dbName, auditDetails);

        } catch (Exception e) {
            String failedStep = registry.get(jobId).getCurrentStep();
            log.error("[Provisioning] ❌ FAILED at {} for {} ({})", failedStep, dbName, e.getMessage(), e);
            registry.update(jobId, s -> {
                s.setStatus("failed");
                s.setFailedStep(failedStep);
                s.setErrorMessage(e.getMessage());
                s.setCompletedAt(Instant.now());
            });
            // 보상
            dbCreator.dropDatabase(dbName);
            try {
                Map<String, Object> upd = new HashMap<>();
                upd.put("company_code", companyCode);
                upd.put("db_status", "failed");
                sqlSession.update("provisioning.updateDbStatus", upd);
            } catch (Exception ex) {
                log.error("[Provisioning] failed marking db_status=failed: {}", ex.getMessage());
            }
            // 감사 로그 — 실패
            Map<String, Object> auditDetails = new HashMap<>();
            auditDetails.put("db_name", dbName);
            auditDetails.put("subdomain", subdomain);
            auditDetails.put("failed_step", failedStep);
            auditLogService.logFailure(companyCode, null, CompanyAuditLogService.ACTION_CREATE_FAILED,
                    dbName, auditDetails, e.getMessage());
        }
    }

    private void advance(String jobId, ProvisioningStatus.Step step, int progress) {
        registry.update(jobId, s -> {
            s.setCurrentStep(step.name());
            s.setProgress(progress);
        });
    }

    private List<String> gatherTables(List<String> selectedGroupIds) {
        List<String> result = new ArrayList<>();
        for (TableGroup g : TableGroup.values()) {
            if (g.isRequired() || selectedGroupIds.contains(g.name())) {
                result.addAll(g.getTables());
            }
        }
        return result;
    }
}
