package kr.itn.itnhub.mattermost;

import com.fasterxml.jackson.databind.JsonNode;
import kr.itn.itnhub.config.MattermostProperties;
import kr.itn.itnhub.feed.FileRef;
import kr.itn.itnhub.feed.FileView;
import kr.itn.itnhub.feed.PostView;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.InvalidMimeTypeException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class MattermostRestClient implements MattermostClient {

    private final RestClient rest;
    private final String teamId;

    /**
     * 사용자 id → 표시용 이름(닉네임 우선, 없으면 아이디) 캐시. 게시물/자료 목록 조회 시
     * 같은 몇 명이 계속 반복해서 등장하므로, 매 게시글마다 {@code /users/{id}}를 다시
     * 부르지 않고 여기서 재사용한다. 조회 실패도 원본 id로 캐시해 전체 목록 조회가
     * 실패하지 않게 한다.
     */
    private final Map<String, String> userNameCache = new ConcurrentHashMap<>();

    @Autowired
    public MattermostRestClient(MattermostProperties props) {
        this(props, RestClient.builder());
    }

    /**
     * 전송 계층(transport)을 직접 지정할 수 있는 생성자. 운영 코드에서는 사용하지 않으며,
     * 테스트 등 별도의 {@link RestClient.Builder} 설정이 필요한 호출자를 위한 확장 지점이다.
     */
    MattermostRestClient(MattermostProperties props, RestClient.Builder builder) {
        this.teamId = props.teamId();
        this.rest = builder
                .baseUrl(props.baseUrl())
                .defaultHeader("Authorization", "Bearer " + props.token())
                .build();
    }

    @Override
    public Optional<String> findChannelIdByInternalName(String internalName) {
        try {
            JsonNode body = rest.get()
                    .uri("/api/v4/teams/{teamId}/channels/name/{name}", teamId, internalName)
                    .retrieve()
                    .onStatus(status -> status.value() == 404, (req, res) -> {
                        throw new NotFound();
                    })
                    .body(JsonNode.class);

            return idOf(body);
        } catch (NotFound e) {
            return Optional.empty();
        } catch (RestClientException e) {
            throw new MattermostException("채널 조회 실패: " + internalName, e);
        }
    }

    /** 팀 채널 목록 조회 시 한 페이지에 요청하는 최대 개수. */
    private static final int DISPLAY_NAME_PAGE_SIZE = 200;

    /**
     * 무한 루프 방지용 페이지 상한. VBA 원본의 안전장치를 그대로 옮긴 것으로,
     * 정상적인 팀이라면 도달할 일이 없다.
     */
    private static final int DISPLAY_NAME_MAX_PAGES = 100;

    /**
     * 표시명으로 채널을 찾는다. {@code /users/me/teams/{teamId}/channels}는 토큰 사용자가
     * "가입한" 채널만 돌려주므로 쓰지 않는다 - 이 조회는 레거시 채널(현재 내부명 규칙 이전에
     * 만들어져 내부명으로는 못 찾는 채널)을 복구하기 위한 것인데, 토큰 사용자가 그 채널에
     * 가입해 있지 않으면 조회가 누락되어 중복 채널이 생성된다. 대신 팀 전체 채널을
     * {@code /teams/{teamId}/channels}로 페이지네이션하며 훑는다 - 이 엔드포인트는 비공개
     * 채널까지 보려면 PAT가 system-admin 권한이어야 한다(README 참고).
     */
    @Override
    public Optional<String> findChannelIdByDisplayName(String displayName) {
        try {
            for (int page = 0; page < DISPLAY_NAME_MAX_PAGES; page++) {
                int currentPage = page;
                JsonNode channels = rest.get()
                        .uri(uri -> uri.path("/api/v4/teams/{teamId}/channels")
                                .queryParam("page", currentPage)
                                .queryParam("per_page", DISPLAY_NAME_PAGE_SIZE)
                                .build(teamId))
                        .retrieve()
                        .body(JsonNode.class);

                if (channels == null || !channels.isArray()) {
                    return Optional.empty();
                }

                for (JsonNode channel : channels) {
                    if (displayName.equals(channel.path("display_name").asText())) {
                        return idOf(channel);
                    }
                }

                if (channels.size() < DISPLAY_NAME_PAGE_SIZE) {
                    return Optional.empty();
                }
            }
            throw new MattermostException(
                    "채널 목록 조회 페이지 한도(" + DISPLAY_NAME_MAX_PAGES + ") 초과: " + displayName);
        } catch (RestClientException e) {
            throw new MattermostException("채널 목록 조회 실패", e);
        }
    }

    @Override
    public String createPrivateChannel(String internalName, String displayName) {
        Map<String, String> body = new LinkedHashMap<>();
        body.put("team_id", teamId);
        body.put("name", internalName);
        body.put("display_name", displayName);
        body.put("type", "P");
        body.put("purpose", "");
        body.put("header", "");

        try {
            JsonNode created = rest.post()
                    .uri("/api/v4/channels")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .body(JsonNode.class);

            return idOf(created).orElseThrow(() ->
                    new MattermostException("채널 생성 응답에 id가 없습니다: " + displayName));
        } catch (RestClientException e) {
            throw new MattermostException("채널 생성 실패: " + displayName, e);
        }
    }

    /** {@code collectChannelFiles} 페이지네이션에 쓰는 페이지당 게시글 수. */
    private static final int FILES_PAGE_SIZE = 200;

    /** {@code findChannelIdByDisplayName}과 같은 이유의 무한 루프 방지 상한. */
    private static final int FILES_MAX_PAGES = 100;

    @Override
    public List<PostView> getRecentPosts(String channelId, int perPage) {
        try {
            JsonNode body = rest.get()
                    .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts")
                            .queryParam("per_page", perPage)
                            .build(channelId))
                    .retrieve()
                    .body(JsonNode.class);

            if (body == null) {
                return List.of();
            }

            List<PostView> result = new ArrayList<>();
            JsonNode posts = body.path("posts");
            for (JsonNode idNode : body.path("order")) {
                result.add(toPostView(posts.path(idNode.asText())));
            }
            // Mattermost는 newest-first로 내려주지만 화면은 오래된순으로 보여준다.
            Collections.reverse(result);
            return result;
        } catch (RestClientException e) {
            throw new MattermostException("게시글 조회 실패: " + channelId, e);
        }
    }

    @Override
    public List<FileView> collectChannelFiles(String channelId) {
        List<FileView> files = new ArrayList<>();
        try {
            for (int page = 0; page < FILES_MAX_PAGES; page++) {
                int currentPage = page;
                JsonNode body = rest.get()
                        .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts")
                                .queryParam("per_page", FILES_PAGE_SIZE)
                                .queryParam("page", currentPage)
                                .build(channelId))
                        .retrieve()
                        .body(JsonNode.class);

                if (body == null) {
                    return files;
                }

                JsonNode order = body.path("order");
                JsonNode posts = body.path("posts");
                int count = 0;
                for (JsonNode idNode : order) {
                    count++;
                    JsonNode post = posts.path(idNode.asText());
                    long createAt = post.path("create_at").asLong();
                    String uploader = resolveUserName(post.path("user_id").asText());
                    for (JsonNode f : post.path("metadata").path("files")) {
                        files.add(new FileView(
                                f.path("id").asText(),
                                f.path("name").asText(),
                                f.path("size").asLong(),
                                f.path("mime_type").asText(""),
                                createAt,
                                uploader));
                    }
                }

                if (count < FILES_PAGE_SIZE) {
                    return files;
                }
            }
            throw new MattermostException(
                    "채널 파일 목록 조회 페이지 한도(" + FILES_MAX_PAGES + ") 초과: " + channelId);
        } catch (RestClientException e) {
            throw new MattermostException("채널 파일 목록 조회 실패: " + channelId, e);
        }
    }

    @Override
    public byte[] downloadFile(String fileId) {
        try {
            byte[] body = rest.get()
                    .uri("/api/v4/files/{id}", fileId)
                    .retrieve()
                    .body(byte[].class);
            return body == null ? new byte[0] : body;
        } catch (RestClientException e) {
            throw new MattermostException("파일 다운로드 실패: " + fileId, e);
        }
    }

    @Override
    public FileRef fileInfo(String fileId) {
        try {
            JsonNode body = rest.get()
                    .uri("/api/v4/files/{id}/info", fileId)
                    .retrieve()
                    .body(JsonNode.class);

            if (body == null) {
                throw new MattermostException("파일 정보 조회 실패: " + fileId);
            }
            return new FileRef(fileId,
                    body.path("name").asText(""),
                    body.path("size").asLong(),
                    body.path("mime_type").asText(""));
        } catch (RestClientException e) {
            throw new MattermostException("파일 정보 조회 실패: " + fileId, e);
        }
    }

    private static final String MULTIPART_CRLF = "\r\n";

    /**
     * multipart/form-data 본문을 직접 바이트로 구성해서 보낸다. {@code MultipartBodyBuilder}는
     * 편리하지만 이 프로젝트의 클래스패스에는 없는 {@code org.reactivestreams.Publisher}를
     * 내부적으로 필요로 해(RestClient의 multipart 기록 경로가 파트마다 Publisher인지 검사한다)
     * NoClassDefFoundError가 난다 - 새 의존성을 추가하는 대신 바이트를 직접 조립한다.
     * 한글 파일명도 헤더 문자열 인코딩 제약(ISO-8859-1 등) 없이 UTF-8 바이트로 그대로 실린다.
     */
    @Override
    public String uploadFile(String channelId, String filename, byte[] data, String contentType) {
        String boundary = "----ItnHubBoundary" + UUID.randomUUID();

        try {
            byte[] body = buildMultipartBody(boundary, channelId, filename, data, contentType);

            JsonNode responseBody = rest.post()
                    .uri("/api/v4/files")
                    .header("Content-Type", "multipart/form-data; boundary=" + boundary)
                    .body(body)
                    .retrieve()
                    .body(JsonNode.class);

            if (responseBody == null) {
                throw new MattermostException("파일 업로드 응답이 없습니다: " + filename);
            }
            JsonNode fileInfos = responseBody.path("file_infos");
            if (!fileInfos.isArray() || fileInfos.isEmpty()) {
                throw new MattermostException("파일 업로드 응답에 file_infos가 없습니다: " + filename);
            }
            return idOf(fileInfos.get(0)).orElseThrow(() ->
                    new MattermostException("파일 업로드 응답에 id가 없습니다: " + filename));
        } catch (RestClientException e) {
            throw new MattermostException("파일 업로드 실패: " + filename, e);
        }
    }

    private byte[] buildMultipartBody(String boundary, String channelId, String filename,
                                       byte[] data, String contentType) {
        try {
            java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();

            writeAscii(out, "--" + boundary + MULTIPART_CRLF);
            writeAscii(out, "Content-Disposition: form-data; name=\"channel_id\"" + MULTIPART_CRLF + MULTIPART_CRLF);
            out.write(channelId.getBytes(java.nio.charset.StandardCharsets.UTF_8));
            writeAscii(out, MULTIPART_CRLF);

            writeAscii(out, "--" + boundary + MULTIPART_CRLF);
            writeAscii(out, "Content-Disposition: form-data; name=\"files\"; filename=\""
                    + filename + "\"" + MULTIPART_CRLF);
            writeAscii(out, "Content-Type: " + resolveContentType(contentType) + MULTIPART_CRLF + MULTIPART_CRLF);
            out.write(data);
            writeAscii(out, MULTIPART_CRLF);

            writeAscii(out, "--" + boundary + "--" + MULTIPART_CRLF);
            return out.toByteArray();
        } catch (java.io.IOException e) {
            throw new MattermostException("파일 업로드 본문 구성 실패: " + filename, e);
        }
    }

    private void writeAscii(java.io.ByteArrayOutputStream out, String text) throws java.io.IOException {
        out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
    }

    private String resolveContentType(String contentType) {
        if (contentType == null || contentType.isBlank()) {
            return MediaType.APPLICATION_OCTET_STREAM_VALUE;
        }
        try {
            return MediaType.parseMediaType(contentType).toString();
        } catch (InvalidMimeTypeException e) {
            return MediaType.APPLICATION_OCTET_STREAM_VALUE;
        }
    }

    @Override
    public String createPost(String channelId, String message, List<String> fileIds) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("channel_id", channelId);
        body.put("message", message == null ? "" : message);
        if (fileIds != null && !fileIds.isEmpty()) {
            body.put("file_ids", fileIds);
        }

        try {
            JsonNode created = rest.post()
                    .uri("/api/v4/posts")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .body(JsonNode.class);

            return idOf(created).orElseThrow(() ->
                    new MattermostException("게시글 생성 응답에 id가 없습니다: " + channelId));
        } catch (RestClientException e) {
            throw new MattermostException("게시글 생성 실패: " + channelId, e);
        }
    }

    @Override
    public void pinPost(String postId) {
        try {
            rest.post()
                    .uri("/api/v4/posts/{postId}/pin", postId)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Map.of())
                    .retrieve()
                    .toBodilessEntity();
        } catch (RestClientException e) {
            throw new MattermostException("게시글 고정 실패: " + postId, e);
        }
    }

    @Override
    public void unpinPost(String postId) {
        try {
            rest.post()
                    .uri("/api/v4/posts/{postId}/unpin", postId)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Map.of())
                    .retrieve()
                    .toBodilessEntity();
        } catch (RestClientException e) {
            throw new MattermostException("게시글 고정 해제 실패: " + postId, e);
        }
    }

    @Override
    public void archiveChannel(String channelId) {
        try {
            rest.delete()
                    .uri("/api/v4/channels/{channelId}", channelId)
                    .retrieve()
                    .toBodilessEntity();
        } catch (RestClientException e) {
            throw new MattermostException("채널 보관 실패: " + channelId, e);
        }
    }

    @Override
    public List<PostView> getPinnedPosts(String channelId) {
        try {
            JsonNode body = rest.get()
                    .uri("/api/v4/channels/{channelId}/pinned", channelId)
                    .retrieve()
                    .body(JsonNode.class);

            if (body == null) {
                return List.of();
            }

            List<PostView> result = new ArrayList<>();
            JsonNode posts = body.path("posts");
            // Mattermost가 이미 newest-first(order)로 내려주므로 뒤집지 않는다.
            for (JsonNode idNode : body.path("order")) {
                result.add(toPostView(posts.path(idNode.asText())));
            }
            return result;
        } catch (RestClientException e) {
            throw new MattermostException("고정 게시글 조회 실패: " + channelId, e);
        }
    }

    @Override
    public void updateChannelHeader(String channelId, String header) {
        Map<String, String> body = new LinkedHashMap<>();
        body.put("header", header == null ? "" : header);

        try {
            rest.put()
                    .uri("/api/v4/channels/{channelId}/patch", channelId)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .toBodilessEntity();
        } catch (RestClientException e) {
            throw new MattermostException("채널 헤더 변경 실패: " + channelId, e);
        }
    }

    private PostView toPostView(JsonNode post) {
        String id = post.path("id").asText();
        String message = post.path("message").asText("");
        long createAt = post.path("create_at").asLong();
        boolean system = post.path("type").asText("").startsWith("system_");

        List<FileRef> files = new ArrayList<>();
        for (JsonNode f : post.path("metadata").path("files")) {
            files.add(new FileRef(
                    f.path("id").asText(),
                    f.path("name").asText(),
                    f.path("size").asLong(),
                    f.path("mime_type").asText("")));
        }

        return new PostView(id, resolveUserName(post.path("user_id").asText()), message,
                createAt, system, files);
    }

    /**
     * 표시 이름 우선순위는 Mattermost 화면과 같게 맞춘다:
     * 닉네임 → 성명(first+last) → 계정명(username) → id.
     * (계정명만 쓰면 화면에 itnadmin처럼 나와 실제 Mattermost 표시와 어긋난다.)
     * 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다.
     */
    private String resolveUserName(String userId) {
        return userNameCache.computeIfAbsent(userId, id -> {
            try {
                JsonNode user = rest.get()
                        .uri("/api/v4/users/{id}", id)
                        .retrieve()
                        .body(JsonNode.class);
                if (user == null) {
                    return id;
                }
                String nickname = user.path("nickname").asText("");
                if (!nickname.isBlank()) {
                    return nickname;
                }
                String fullName = (user.path("first_name").asText("") + " "
                        + user.path("last_name").asText("")).trim();
                if (!fullName.isBlank()) {
                    return fullName;
                }
                String username = user.path("username").asText("");
                return username.isBlank() ? id : username;
            } catch (RestClientException e) {
                return id;
            }
        });
    }

    @Override
    public Optional<String> findUserIdByEmail(String email) {
        try {
            JsonNode body = rest.get()
                    .uri("/api/v4/users/email/{email}", email)
                    .retrieve()
                    .onStatus(status -> status.value() == 404, (req, res) -> {
                        throw new NotFound();
                    })
                    .body(JsonNode.class);

            return idOf(body);
        } catch (NotFound e) {
            return Optional.empty();
        } catch (RestClientException e) {
            throw new MattermostException("사용자 조회 실패: " + email, e);
        }
    }

    @Override
    public String createUser(String email, String username, String password) {
        Map<String, String> body = new LinkedHashMap<>();
        body.put("email", email);
        body.put("username", username);
        body.put("password", password);

        try {
            JsonNode created = rest.post()
                    .uri("/api/v4/users")
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .body(JsonNode.class);

            return idOf(created).orElseThrow(() ->
                    new MattermostException("사용자 생성 응답에 id가 없습니다: " + email));
        } catch (RestClientException e) {
            throw new MattermostException("사용자 생성 실패: " + email + " - " + errorBody(e), e);
        }
    }

    /**
     * 에러 응답 본문을 그대로 메시지에 담는다. 사용자 생성 실패 시 호출자(ChannelProvisionService)가
     * {@code username_exists} 등 Mattermost 오류 코드로 재시도 여부를 판단해야 하는데,
     * 기본 RestClientException 메시지에는 응답 본문이 실리지 않기 때문이다.
     */
    private String errorBody(RestClientException e) {
        if (e instanceof org.springframework.web.client.RestClientResponseException rse) {
            String bodyText = rse.getResponseBodyAsString();
            if (bodyText != null && !bodyText.isBlank()) {
                return bodyText;
            }
        }
        return e.getMessage();
    }

    @Override
    public void addUserToTeam(String userId) {
        Map<String, String> body = new LinkedHashMap<>();
        body.put("team_id", teamId);
        body.put("user_id", userId);

        try {
            rest.post()
                    .uri("/api/v4/teams/{teamId}/members", teamId)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .toBodilessEntity();
        } catch (RestClientException e) {
            throw new MattermostException("팀 추가 실패: " + userId, e);
        }
    }

    @Override
    public void addUserToChannel(String channelId, String userId) {
        Map<String, String> body = new LinkedHashMap<>();
        body.put("user_id", userId);

        try {
            rest.post()
                    .uri("/api/v4/channels/{channelId}/members", channelId)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .toBodilessEntity();
        } catch (RestClientException e) {
            throw new MattermostException("채널 멤버 추가 실패: " + channelId, e);
        }
    }

    private Optional<String> idOf(JsonNode node) {
        if (node == null) {
            return Optional.empty();
        }
        String id = node.path("id").asText("");
        return id.isBlank() ? Optional.empty() : Optional.of(id);
    }

    /** 404를 정상 흐름으로 되돌리기 위한 내부 신호. 밖으로 새지 않는다. */
    private static final class NotFound extends RuntimeException {
    }
}
