package com.atlassian.bitbucket.internal.integration.jira;

import com.atlassian.bitbucket.Product;
import com.atlassian.bitbucket.commit.*;
import com.atlassian.bitbucket.idx.CommitIndex;
import com.atlassian.bitbucket.idx.IndexedCommit;
import com.atlassian.bitbucket.integration.jira.JiraConstants;
import com.atlassian.bitbucket.integration.jira.JiraIssue;
import com.atlassian.bitbucket.internal.integration.jira.idx.JiraCommitPropertyConfiguration;
import com.atlassian.bitbucket.property.PropertyMap;
import com.atlassian.bitbucket.pull.PullRequest;
import com.atlassian.bitbucket.pull.PullRequestService;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.integration.jira.JiraIssueUrlsRequest;
import com.atlassian.integration.jira.JiraKeyScanner;
import com.atlassian.integration.jira.JiraService;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.*;
import java.util.function.Supplier;

public class DefaultJiraIssueService implements InternalJiraIssueService {

    private static final String PROP_MAX_COMMITS = "plugin.jira-integration.pullrequest.attribute.commits.max";
    private static final int MAX_MAX_COMMITS = 1000;
    private static final int MIN_MAX_COMMITS = 50;
    private static final int DEFAULT_MAX_COMMITS = 100;

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

    private final CommitIndex commitIndex;
    private final CommitService commitService;
    private final JiraKeyScanner jiraKeyScanner;
    private final JiraService jiraService;
    private final PullRequestService pullRequestService;
    private final int maxCommits;

    @SuppressWarnings("UnusedDeclaration") // Used for constructor injection
    public DefaultJiraIssueService(CommitIndex commitIndex, CommitService commitService, JiraKeyScanner jiraKeyScanner,
                                   JiraService jiraService, ApplicationPropertiesService propertiesService,
                                   PullRequestService pullRequestService) {
        this(commitIndex, commitService, jiraKeyScanner, jiraService, pullRequestService,
                computeMaxCommits(propertiesService));
    }

    @VisibleForTesting
    DefaultJiraIssueService(CommitIndex commitIndex, CommitService commitService,
                            JiraKeyScanner jiraKeyScanner, JiraService jiraService,
                            PullRequestService pullRequestService, int maxCommits) {
        this.commitIndex = commitIndex;
        this.commitService = commitService;
        this.jiraKeyScanner = jiraKeyScanner;
        this.jiraService = jiraService;
        this.pullRequestService = pullRequestService;
        this.maxCommits = maxCommits;
    }

    @Nonnull
    @Override
    public Page<Changeset> getChangesetsForIssue(@Nonnull String issueKey, int maxChanges,
                                                 @Nonnull PageRequest pageRequest) {
        // this page is ordered by commit date (descending)
        // the values are all uppercase, so we can be case sensitive and hit the index
        Page<IndexedCommit> commits = commitIndex.findByProperty(
                JiraConstants.IDX_PROP_ISSUE_KEYS, issueKey.toUpperCase(Locale.US), true, pageRequest);
        if (commits.getSize() == 0) {
            return PageUtils.createEmptyPage(pageRequest);
        }

        // fetch all details from the commitService
        Map<String, Changeset> changesetsById = new HashMap<>();
        getCommitsToQueryPerRepository(commits).entrySet().stream()
                .map(entry -> commitService.getChangesets(
                        new ChangesetsRequest.Builder(entry.getKey())
                                .commitIds(entry.getValue())
                                .maxChangesPerCommit(maxChanges)
                                .ignoreMissing(true) // Ignore missing commits
                                .build(),
                        PageUtils.newRequest(0, entry.getValue().size())))
                .flatMap(Page::stream)
                .forEach(changeset -> changesetsById.put(changeset.getToCommit().getId(), changeset));

        // now we have all the results in changesetsById, but not in the right order
        List<Changeset> changesets = new ArrayList<>(commits.getSize());
        commits.stream()
                .map(IndexedCommit::getId)
                .map(changesetsById::get)
                .filter(Objects::nonNull)
                .forEach(changesets::add);

        return PageUtils.createPage(changesets, commits.getIsLastPage(), pageRequest);
    }

    @Nonnull
    @Override
    public Set<JiraIssue> getIssuesForCommits(Set<String> commitIds) {
        return getIssuesIfLinked(() -> {
            Iterable<String> ids = commitIds;

            int commitCount = commitIds.size();
            if (commitCount > maxCommits) {
                log.warn("Limiting the first {} commits out of {}", maxCommits, commitCount);
                ids = Iterables.limit(ids, maxCommits);
            }

            return findIssuesForCommits(ids);
        });
    }

    @Override
    @Nonnull
    public Set<JiraIssue> getIssuesForPullRequest(int repositoryId, long pullRequestId) {
        return getIssuesIfLinked(new Supplier<Iterable<String>>() {

            @Override
            public Iterable<String> get() {
                // It's unfortunate we can't share this with streamCommits() above
                PullRequest pullRequest = pullRequestService.getById(repositoryId, pullRequestId);
                return Iterables.concat(
                        findIssuesForCommits(getCommits()),
                        scanIssuesForPullRequest(pullRequest)
                );
            }

            private Set<String> getCommits() {
                Set<String> commitIds = new LinkedHashSet<>();
                pullRequestService.streamCommits(repositoryId, pullRequestId, new AbstractCommitCallback() {

                    @Override
                    public boolean onCommit(@Nonnull Commit commit) throws IOException {
                        commitIds.add(commit.getId());

                        // Stop when we reach a limit
                        return commitIds.size() < maxCommits;
                    }
                });
                return commitIds;
            }

            private Iterable<String> scanIssuesForPullRequest(PullRequest pullRequest) {
                // Scanning each time should be almost free - the bigger cost is looking up the PR twice (and Crowd).
                // Longer term we should join this logic with bitbucket-jira-development-integration to avoid duplication.
                return Iterables.concat(
                        jiraKeyScanner.findAll(pullRequest.getTitle()),
                        jiraKeyScanner.findAll(pullRequest.getFromRef().getId())
                );
            }
        });
    }

    @Override
    public List<JiraIssue> findIssuesByKey(Set<String> issueKeys) {
        return addSimpleIssues(issueKeys, new ArrayList<>());
    }

    private Map<Repository, Set<String>> getCommitsToQueryPerRepository(Page<IndexedCommit> page) {
        Map<Repository, Set<String>> commitsByRepository = new LinkedHashMap<>();
        Set<String> processedCommits = new HashSet<>();

        // first pass: determine which repositories need to be queried regardless
        page.stream()
                .filter(commit -> commit.getRepositories().size() == 1)
                .forEach(commit -> {
                    Repository repository = Iterables.getFirst(commit.getRepositories(), null);

                    commitsByRepository.computeIfAbsent(repository, r -> new HashSet<>())
                            .add(commit.getId());
                    processedCommits.add(commit.getId());
                });

        // second pass: for each commit, if we're already querying one of the repositories, use that repo
        page.stream()
                .filter(commit -> !processedCommits.contains(commit.getId()))
                .forEach(commit -> {
                    Optional<Set<String>> commitIds = commit.getRepositories().stream()
                            .map(commitsByRepository::get)
                            .filter(Objects::nonNull) // already querying the repo in question
                            .findFirst(); // only add the commit to the first already-queried repository
                    if (commitIds.isPresent()) {
                        // we're already querying this repository due to another commit, so query it for
                        // this commit as well
                        commitIds.get().add(commit.getId());
                    } else {
                        // we're not querying any of the repositories that the commit is in. Add it
                        commitsByRepository.put(Iterables.getFirst(commit.getRepositories(), null),
                                Sets.newHashSet(commit.getId()));
                    }
                    processedCommits.add(commit.getId());
                });

        return commitsByRepository;
    }

    private Set<JiraIssue> getIssuesIfLinked(Supplier<? extends Iterable<String>> keys) {
        if (!jiraService.isLinked()) {
            log.debug("{} has not been linked to JIRA", Product.NAME);
            return Collections.emptySet();
        }

        return addSimpleIssues(keys.get(), new LinkedHashSet<>());
    }

    @SuppressWarnings("unchecked")
    private Set<String> findIssuesForCommits(Iterable<String> commitIds) {
        Set<String> issueKeys = new TreeSet<>();
        for (PropertyMap properties : commitIndex.getProperties(commitIds, JiraCommitPropertyConfiguration.ISSUE_KEYS).values()) {
            issueKeys.addAll((Set) properties.get(JiraConstants.IDX_PROP_ISSUE_KEYS));
        }
        return issueKeys;
    }

    // Unfortunately the public APIs force us to support both Set/List :(
    private <T extends Collection<JiraIssue>> T addSimpleIssues(Iterable<String> issueKeys, T issues) {
        if (issueKeys == null || Iterables.isEmpty(issueKeys)) {
            return issues;
        }

        Map<String, String> urls = jiraService.getIssueUrls(new JiraIssueUrlsRequest.Builder()
                .issueKeys(issueKeys)
                .build());

        for (String issueKey : issueKeys) {
            String url = urls.get(issueKey);
            if (url != null) {
                SimpleJiraIssue issue = SimpleJiraIssue.maybeCreate(issueKey, url);
                if (issue == null) {
                    log.warn("Could not instantiate a JIRA issue with key '{}' and url '{}'; ignoring", issueKey, url);
                } else {
                    issues.add(issue);
                }
            }
        }

        return issues;
    }

    private static int computeMaxCommits(ApplicationPropertiesService propertiesService) {
        int maxCommits = propertiesService.getPluginProperty(PROP_MAX_COMMITS, DEFAULT_MAX_COMMITS);
        if (maxCommits < MIN_MAX_COMMITS) {
            maxCommits = MIN_MAX_COMMITS;
        } else if (maxCommits > MAX_MAX_COMMITS) {
            maxCommits = MAX_MAX_COMMITS;
        }
        return maxCommits;
    }
}
