package kr.itn.itnhub.provision;

import kr.itn.itnhub.config.MattermostProperties;
import kr.itn.itnhub.contact.Contact;
import kr.itn.itnhub.mattermost.MattermostClient;
import kr.itn.itnhub.org.OrgNotFoundException;
import kr.itn.itnhub.org.Organization;
import kr.itn.itnhub.org.OrganizationMapper;
import kr.itn.itnhub.stage.StageHistoryMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * 기관의 채널 2개(문정원/법률검토)를 보장한다. VBA M07의 이식.
 *
 * 판정 순서 (한쪽 채널당):
 *   1. DB에 Channel ID가 있으면 그대로 둔다        → CACHED
 *   2. 서버에 내부명으로 채널이 있으면 ID를 가져온다 → RECOVERED
 *   3. 표시명으로 있으면 ID를 가져온다              → RECOVERED
 *   4. 없을 때만 새로 만든다                       → CREATED
 *
 * 트랜잭션을 쓰지 않는다. 외부 API 호출이라 롤백이 불가능하고,
 * 한쪽이 실패해도 성공한 쪽의 ID는 반드시 남아야 재시도가 안전해지기 때문이다.
 * 절대 @Transactional을 붙이지 말 것.
 */
@Service
public class ChannelProvisionService {

    private static final Logger log = LoggerFactory.getLogger(ChannelProvisionService.class);

    private final MattermostClient mattermost;
    private final OrganizationMapper mapper;
    private final MattermostProperties props;
    private final StageHistoryMapper stageHistoryMapper;

    public ChannelProvisionService(MattermostClient mattermost,
                                   OrganizationMapper mapper,
                                   MattermostProperties props,
                                   StageHistoryMapper stageHistoryMapper) {
        this.mattermost = mattermost;
        this.mapper = mapper;
        this.props = props;
        this.stageHistoryMapper = stageHistoryMapper;
    }

    public ProvisionResult provision(Long orgId) {
        Organization org = mapper.findById(orgId);
        if (org == null) {
            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
        }

        if (!org.hasApplicantAssigned()) {
            return new ProvisionResult(
                    ProvisionOutcome.FAILED,
                    ProvisionOutcome.FAILED,
                    "신청기관 담당자가 지정되지 않아 채널을 만들 수 없습니다.");
        }

        StringBuilder messages = new StringBuilder();

        ProvisionOutcome mj = ensure(org, ChannelKind.MJ,
                org.getChannelIdMj(), props.channelNameMj(), messages);

        ProvisionOutcome law = ensure(org, ChannelKind.LAW,
                org.getChannelIdLaw(), props.channelNameLaw(), messages);

        maybeStartStage(org.getId());

        ensureMembers(org.getId(), messages);

        return new ProvisionResult(mj, law, messages.toString().trim());
    }

    /**
     * 채널에 배정된 담당자들의 Mattermost 계정을 보장하고 알맞은 채널에 추가한다. VBA에는
     * 없던 신규 기능으로, 채널 존재 여부와 무관하게 (CACHED 포함) 매번 실행되어 멤버십
     * 드리프트를 치유한다 - findUserIdByEmail이 계정 존재를 먼저 확인하고 채널 추가도
     * 멱등이라 재실행해도 안전하다.
     *
     * <p>이 훅도 postIntro와 같은 원칙을 따른다: 담당자 한 명(또는 채널 추가 한 건)의 실패가
     * 나머지 처리를 막지 않고, provision() 결과(CREATED/RECOVERED/CACHED/FAILED)도 뒤집지
     * 않는다 - 실패는 messages에만 남는다.</p>
     */
    private void ensureMembers(Long orgId, StringBuilder messages) {
        Organization org = mapper.findById(orgId);
        if (org == null) {
            return;
        }

        Map<String, List<Contact>> channelContacts = new LinkedHashMap<>();
        addChannelContacts(channelContacts, org.getChannelIdMj(),
                org.getApplicant(), org.getMj(), org.getItn());
        addChannelContacts(channelContacts, org.getChannelIdLaw(),
                org.getLawyer(), org.getMj(), org.getItn());

        // 이메일(대소문자 무시) 기준으로 담당자를 중복 없이 모은다 - 같은 사람이 양쪽
        // 채널(예: 문정원 담당자)에 동시에 배정될 수 있으므로 계정 조회/생성은 한 번만 한다.
        Map<String, Contact> distinctContacts = new LinkedHashMap<>();
        for (List<Contact> contacts : channelContacts.values()) {
            for (Contact contact : contacts) {
                distinctContacts.putIfAbsent(contact.getEmail().toLowerCase(), contact);
            }
        }

        Map<String, String> resolvedUserIds = new LinkedHashMap<>();
        for (Contact contact : distinctContacts.values()) {
            try {
                resolvedUserIds.put(contact.getEmail().toLowerCase(), resolveUser(contact));
            } catch (RuntimeException e) {
                log.warn("멤버 계정 준비 실패 org={} contact={}", org.getOrgName(), contact.getEmail(), e);
                appendMemberFailure(messages, contact, e);
            }
        }

        for (Map.Entry<String, List<Contact>> entry : channelContacts.entrySet()) {
            String channelId = entry.getKey();
            for (Contact contact : entry.getValue()) {
                String userId = resolvedUserIds.get(contact.getEmail().toLowerCase());
                if (userId == null) {
                    // 계정 준비 단계에서 이미 실패해 메시지에 남겼다 - 채널 추가는 시도하지 않는다.
                    continue;
                }
                try {
                    mattermost.addUserToChannel(channelId, userId);
                } catch (RuntimeException e) {
                    log.warn("채널 멤버 추가 실패 org={} channel={} contact={}",
                            org.getOrgName(), channelId, contact.getEmail(), e);
                    appendMemberFailure(messages, contact, e);
                }
            }
        }
    }

    /** null 계약(계약자가 없거나 이메일이 비어 있으면)는 후보에서 제외하고, 채널 ID가 빈칸이면 그 채널 자체를 건너뛴다. */
    private void addChannelContacts(Map<String, List<Contact>> channelContacts,
                                    String channelId, Contact... candidates) {
        if (!filled(channelId)) {
            return;
        }
        List<Contact> contacts = new ArrayList<>();
        for (Contact candidate : candidates) {
            if (candidate != null && filled(candidate.getEmail())) {
                contacts.add(candidate);
            }
        }
        channelContacts.put(channelId, contacts);
    }

    /**
     * 이메일로 기존 계정을 찾고, 없으면 새로 만든 뒤 팀에 추가한다. 계정명이 이미 쓰이고
     * 있으면({@code username_exists}) 로컬파트에 도메인 첫 라벨을 덧붙여 한 번만 재시도한다.
     */
    private String resolveUser(Contact contact) {
        String email = contact.getEmail();

        String userId = mattermost.findUserIdByEmail(email).orElse(null);
        if (userId == null) {
            String username = usernameFromEmail(email);
            try {
                userId = mattermost.createUser(email, username, props.defaultUserPassword());
            } catch (RuntimeException e) {
                if (e.getMessage() != null && e.getMessage().contains("username_exists")) {
                    userId = mattermost.createUser(email, retryUsernameFromEmail(email),
                            props.defaultUserPassword());
                } else {
                    throw e;
                }
            }
        }

        mattermost.addUserToTeam(userId);
        return userId;
    }

    private void appendMemberFailure(StringBuilder messages, Contact contact, RuntimeException e) {
        messages.append("[멤버 초대] ").append(orDash(contact.getName())).append(": ")
                .append(e.getMessage()).append(System.lineSeparator());
    }

    private static String localPart(String email) {
        int at = email.indexOf('@');
        return at >= 0 ? email.substring(0, at) : email;
    }

    private static String domainPart(String email) {
        int at = email.indexOf('@');
        return at >= 0 ? email.substring(at + 1) : "";
    }

    /** [a-z0-9._-] 밖의 문자는 전부 '-'로 치환한다. */
    private static String sanitizeUsernameSegment(String raw) {
        return raw.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
    }

    private static String usernameFromEmail(String email) {
        return sanitizeUsernameSegment(localPart(email));
    }

    private static String retryUsernameFromEmail(String email) {
        String domain = domainPart(email);
        int dot = domain.indexOf('.');
        String firstLabel = dot >= 0 ? domain.substring(0, dot) : domain;
        return usernameFromEmail(email) + "." + sanitizeUsernameSegment(firstLabel);
    }

    /**
     * 채널 2개가 이번 호출로 처음 모두 갖춰지고 아직 어떤 단계도 시작되지 않았다면
     * 진행단계를 1(신청)로 자동 시작한다. 이미 단계가 있는 기관은 건드리지 않는다
     * (재실행 안전성 - 사람이 수동으로 바꿔둔 단계를 채널 재확인이 되돌리면 안 된다).
     *
     * <p>이 훅은 채널 생성이 이미 끝난 뒤에 실행되므로, 실패해도 provision() 결과에는
     * 영향을 주지 않고 경고 로그만 남긴다 - stage_history 문제로 채널 생성 자체를
     * 실패로 보고하면 재시도만 반복하게 만든다.</p>
     */
    private void maybeStartStage(Long orgId) {
        try {
            Organization current = mapper.findById(orgId);
            if (current == null || current.getStage() != null) {
                return;
            }
            if (filled(current.getChannelIdMj()) && filled(current.getChannelIdLaw())) {
                mapper.updateStage(orgId, 1);
                // 이것도 프로그램이 저절로 넣는 단계다(요구사항 [19]: 이력에 자동/수동 구분).
                stageHistoryMapper.insert(orgId, 1, true, "채널 생성");
            }
        } catch (RuntimeException e) {
            log.warn("진행단계 자동 시작 실패 org={}", orgId, e);
        }
    }

    private static boolean filled(String value) {
        return value != null && !value.isBlank();
    }

    private ProvisionOutcome ensure(Organization org,
                                    ChannelKind kind,
                                    String storedChannelId,
                                    String label,
                                    StringBuilder messages) {

        if (storedChannelId != null && !storedChannelId.isBlank()) {
            return ProvisionOutcome.CACHED;
        }

        String internalName = ChannelNaming.internalName(
                org.getOrgNo(), org.getOrgName(), kind);
        String displayName = ChannelNaming.displayName(
                org.getOrgNo(), org.getOrgName(), label);

        try {
            Optional<String> recovered = mattermost.findChannelIdByInternalName(internalName);
            if (recovered.isEmpty()) {
                recovered = mattermost.findChannelIdByDisplayName(displayName);
            }

            if (recovered.isPresent()) {
                applyChannelId(org.getId(), kind, recovered.get());
                return ProvisionOutcome.RECOVERED;
            }

            String created = mattermost.createPrivateChannel(internalName, displayName);
            applyChannelId(org.getId(), kind, created);
            postIntro(org, kind, label, created, messages);
            return ProvisionOutcome.CREATED;

        } catch (RuntimeException e) {
            // MattermostException(서버 API 실패)뿐 아니라, 채널 생성/복구 직후 ID를 저장하는
            // mapper 호출이 던지는 DataAccessException 등도 여기서 잡는다. Error나 checked
            // exception은 의도적으로 넓히지 않는다 - 이 두 경우는 채널 하나만 FAILED로
            // 강등시키고 메시지에 남긴 뒤, 나머지 채널은 계속 시도되게 해야 한다.
            log.warn("채널 처리 실패 org={} channel={}", org.getOrgName(), displayName, e);
            messages.append("[").append(label).append("] ")
                    .append(e.getMessage()).append(System.lineSeparator());
            return ProvisionOutcome.FAILED;
        }
    }

    /**
     * 새로 생성된 채널에만 담당자 안내(최초 게시글)를 올리고 핀 고정한다. VBA M10의 이식.
     * CACHED/RECOVERED 채널에는 올리지 않는다 - 재실행할 때마다 안내글이 쌓이면 안 되고,
     * 기존 채널에는 이미 안내글이 있을 수 있기 때문이다.
     *
     * <p>실패해도 채널 생성 결과(CREATED)를 뒤집지 않는다 - 채널은 이미 만들어졌으므로
     * 안내글만 나중에 수동으로 올리면 되는 문제다. 메시지에 경고만 남긴다.</p>
     */
    private void postIntro(Organization org, ChannelKind kind, String label,
                           String channelId, StringBuilder messages) {
        try {
            String postId = mattermost.createPost(channelId, introMessage(org, kind), List.of());
            if (postId != null && !postId.isBlank()) {
                mattermost.pinPost(postId);
            }
        } catch (RuntimeException e) {
            log.warn("최초 게시글 등록 실패 org={} channel={}", org.getOrgName(), channelId, e);
            messages.append("[").append(label).append("] 채널은 생성됐지만 안내글 등록에 실패했습니다: ")
                    .append(e.getMessage()).append(System.lineSeparator());
        }
    }

    private String introMessage(Organization org, ChannelKind kind) {
        StringBuilder sb = new StringBuilder();
        sb.append("안녕하세요.\n");
        sb.append(org.getOrgName())
                .append(kind == ChannelKind.MJ ? " 업무협의 채널입니다.\n" : " 법률검토 채널입니다.\n");

        appendContactBlock(sb, "신청기관 담당자", "부서", org.getApplicant());
        appendContactBlock(sb, "문정원 담당자", "부서", org.getMj());
        appendContactBlock(sb, "㈜아이티앤 담당자", "부서", org.getItn());
        appendContactBlock(sb, "담당 변호사", "소속", org.getLawyer());

        return sb.toString().stripTrailing();
    }

    /** 배정 안 된 역할은 블록 자체를 생략한다. 빈 항목은 '-'로 채운다. */
    private void appendContactBlock(StringBuilder sb, String title, String orgLabel, Contact contact) {
        if (contact == null) {
            return;
        }
        String orgValue = "소속".equals(orgLabel)
                ? orDash(contact.getAffiliation())
                : orDash(contact.getDeptName());
        sb.append('\n')
                .append("■ ").append(title).append('\n')
                .append(orgLabel).append(" : ").append(orgValue).append('\n')
                .append("담당자 : ").append(orDash(contact.getName())).append('\n')
                .append("직위 : ").append(orDash(contact.getTitle())).append('\n')
                .append("연락처 : ").append(orDash(contact.getPhone())).append('\n')
                .append("이메일 : ").append(orDash(contact.getEmail())).append('\n');
    }

    private static String orDash(String value) {
        return (value == null || value.isBlank()) ? "-" : value;
    }

    /**
     * Channel ID를 저장할 컬럼(mj/law)을 결정하는 유일한 지점.
     * switch 식(expression)으로 작성해 ChannelKind에 값이 추가되고도 분기를
     * 채우지 않으면 컴파일이 실패하도록 강제한다 - if/else였다면 새 값이
     * 조용히 else(법률검토) 분기로 흘러 들어가는 것을 막지 못했을 것이다.
     */
    private void applyChannelId(Long orgId, ChannelKind kind, String channelId) {
        // 반환값(영향받은 행 수)은 쓰지 않지만, switch를 식으로 유지해야
        // 컴파일러가 exhaustiveness를 검사한다 - 문(statement) 형태의
        // switch/if-else는 이 검사를 강제하지 않는다.
        var ignored = switch (kind) {
            case MJ -> mapper.updateChannelIdMj(orgId, channelId);
            case LAW -> mapper.updateChannelIdLaw(orgId, channelId);
        };
    }
}
