package com.atlassian.stash.internal.web.markup;

import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.idx.CommitIndex;
import com.atlassian.bitbucket.idx.IndexSearchRequest;
import com.atlassian.bitbucket.idx.IndexedCommit;
import com.atlassian.bitbucket.nav.NavBuilder;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.markup.renderer.MarkupRendererComponent;
import com.atlassian.markup.renderer.RenderContext;
import com.atlassian.markup.renderer.RenderTransform;
import com.atlassian.stash.internal.spring.SpringTransactionUtils;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;

import javax.annotation.Nonnull;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;

import static com.atlassian.bitbucket.util.MarkupUtils.LINKS;
import static com.atlassian.bitbucket.util.MarkupUtils.findMatches;
import static org.apache.commons.lang3.StringEscapeUtils.escapeHtml4;

/**
 * Replaces commit IDs in text with links.
 */
@Component("commitMarkupRenderer")
public class CommitMarkupRenderer implements MarkupRendererComponent {

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

    /** Pattern the commit ID has to match. */
    private static final Pattern COMMIT_ID_PATTERN = Pattern.compile("\\b[0-9a-fA-F]{7,40}\\b");
    /** Pattern the commit ID must not be in: urls or sub-strings of the form [foo](bar). */
    private static final Pattern EXCLUDE_PATTERN = LINKS;
    /** Transaction properties for the common transaction used for the retrieval of all the commits. */
    private static final TransactionDefinition READ_ONLY_TRANSACTION =
            SpringTransactionUtils.definitionFor(TransactionDefinition.PROPAGATION_REQUIRED, true);

    private final CommitIndex commitIndex;
    private final I18nService i18nService;
    private final NavBuilder navBuilder;
    private final TransactionTemplate transactionTemplate;

    @Value("${markup.render.commit.lookup.timeout}")
    private int lookupTimeout;

    @Autowired
    public CommitMarkupRenderer(CommitIndex commitIndex, I18nService i18nService, NavBuilder navBuilder,
                                PlatformTransactionManager transactionManager) {
        this(commitIndex, i18nService, navBuilder, new TransactionTemplate(transactionManager, READ_ONLY_TRANSACTION));
    }

    protected CommitMarkupRenderer(CommitIndex commitIndex, I18nService i18nService, NavBuilder navBuilder,
                                   TransactionTemplate transactionTemplate) {
        this.commitIndex = commitIndex;
        this.i18nService = i18nService;
        this.navBuilder = navBuilder;
        this.transactionTemplate = transactionTemplate;
    }

    @Nonnull
    @Override
    public RenderTransform process(@Nonnull CharSequence text, @Nonnull RenderContext context) {
        RenderTransform.TransformBuilder builder = RenderTransform.builder();

        Map<String, Collection<MatchResult>> matches = findMatches(text, COMMIT_ID_PATTERN, EXCLUDE_PATTERN);
        if (matches.isEmpty()) {
            return builder.build();
        }

        Map<String, Repository> repositories;
        if (context.getData().get("repositoryId") instanceof Integer) {
            repositories  = findRepositories(matches.keySet(), ((Integer) context.getData().get("repositoryId")).intValue());
        } else {
            repositories  = findRepositories(matches.keySet());
        }

        for (Map.Entry<String, Repository> entry : repositories.entrySet()) {
            String commitId = entry.getKey();
            Repository repository = entry.getValue();

            String link = createLink(commitId, repository, context);
            Collection<MatchResult> matchResults = matches.get(commitId);
            for (MatchResult match : matchResults) {
                builder.add(match.start(), match.end(), link);
            }
        }

        return builder.build();
    }

    private String createLink(String commitId, Repository repository, RenderContext context) {
        String url = context.createHostUrl(navBuilder.repo(repository).commit(commitId).buildRelNoContext());
        String title = i18nService.getMessage("bitbucket.web.markup.commit.tooltip",
                                           repository.getName(), repository.getProject().getName());
        return "<a href=\"" + escapeHtml4(url) + "\" class=\"commitid\" title=\"" + escapeHtml4(title) + "\">" +
                escapeHtml4(commitId) + "</a>";
    }

    private Map<String, Repository> findRepositories(Set<String> commitIds) {
        return findRepositories(commitIds, -1);
    }

    private Map<String, Repository> findRepositories(Set<String> commitIds, int sourceRepositoryId) {
        return transactionTemplate.execute(status -> {
            Map<String, Repository> m = Maps.newHashMap();

            // When there are a lot of commits in the system, and the changeset table grows large, then the commit
            // lookups below get slow. For example, testing against a system with over 2 million changesets the
            // lookup takes over 250ms. This can affect customers where comments are added that contain many strings
            // that match the commit pattern that all need to be looked up, even to the point where a pull request page
            // will not render due to timeout.
            //
            // We cap the amount of time spent looking up commits to approximately lookupTimeout (approximately because
            // we evaluate after the lookup, so we can exceed the timeout).  As this timeout applies to an individual
            // piece of markdown, this works on the assumption that if there are comments with commit-like strings there
            // aren't many of them in a single page of activity.
            long startTime = System.currentTimeMillis();
            for (String commitId : commitIds) {
                Page<IndexedCommit> commits = commitIndex.search(
                        new IndexSearchRequest.Builder().prefix(commitId).build(),
                        PageUtils.newRequest(0, 2));

                // only consider IDs that are non ambiguous i.e. that match _only one_ commit; in that case, pick
                // the given sourceRepositoryId if contained in the repository membership for the commit, otherwise return
                // the first repository membership for the commit as memberships are already sorted by increasing
                // repository ID (see the description of 1a5485bc for more details)
                if (commits.getSize() == 1) {
                    IndexedCommit commit = Iterables.getOnlyElement(commits.getValues());
                    Set<Repository> repositories = commit.getRepositories();
                    repositories.stream()
                            .filter(repository -> sourceRepositoryId < 0 || repository.getId() == sourceRepositoryId)
                            .findFirst()
                            .ifPresent(repository -> m.put(commitId, repository));
                }
                long runTime = System.currentTimeMillis() - startTime;
                if (runTime > lookupTimeout) {
                    log.debug("{}ms timeout exceeded - spent {}ms looking up commits", lookupTimeout, runTime);
                    break;
                }
            }
            return m;
        });
    }
}
