package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.git.exception.*;
import com.atlassian.bitbucket.mesh.git.ref.Branch;
import com.atlassian.bitbucket.mesh.git.ref.Ref;
import com.atlassian.bitbucket.mesh.git.ref.Tag;
import com.atlassian.bitbucket.mesh.io.IoFunction;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.io.UnderLock;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import io.atlassian.fugue.Either;
import org.apache.commons.lang3.mutable.MutableBoolean;
import org.apache.commons.lang3.mutable.MutableInt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.*;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;

import static com.atlassian.bitbucket.mesh.git.GitConstants.*;
import static com.atlassian.bitbucket.mesh.git.GitRefPattern.HEADS;
import static com.atlassian.bitbucket.mesh.rpc.v1.RpcInvalidRefName.Reason.INVALID;
import static com.google.common.base.MoreObjects.firstNonNull;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardOpenOption.APPEND;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.util.Objects.requireNonNull;

/**
 * An implementation of {@link GitAgent} which relies on manipulation of the raw files in the {@link Repository} to
 * perform operations, instead of executing {@code git} processes.
 * <p>
 * There are some safety concerns to this approach, in terms of how changes to {@code git}'s internals may result in
 * changes to the on-disk layout, rendering the approaches used in this agent invalid. As newer versions of git are
 * released, it will be necessary to ensure they are included in a testing matrix to test compatibility. In the long
 * term, this class will likely need to become {@code git} and {@link Repository}-version aware.
 */
public class RawGitAgent implements GitAgent {

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

    private static final Path PATH_FULL_ALTERNATES = Paths.get(PATH_OBJECTS, PATH_INFO, PATH_ALTERNATES);
    private static final Duration REF_LOCK_TIMEOUT = Duration.of(25, ChronoUnit.MILLIS);
    private static final String SYMBOLIC_REF = "ref: ";

    @Override
    public void addAlternate(@Nonnull Path repositoryDir, @Nonnull String alternate) {
        requireNonNull(repositoryDir, "repositoryDir");
        requireNonNull(alternate, "alternate");

        //Verify that the repository contains an objects/info directory, or create it
        Path alternates = repositoryDir.resolve(PATH_FULL_ALTERNATES);
        Path info = alternates.getParent();
        try {
            MoreFiles.mkdir(info);
        } catch (IllegalStateException e) {
            throw new InternalFailureException("Can'find path: " + info.getFileName(), e);
        }

        //Append the new objects directory to the alternates file, allowing its objects to be "borrowed" on all
        //subsequent git commands until the alternate is removed
        try (BufferedWriter writer = Files.newBufferedWriter(alternates, UTF_8, APPEND, CREATE)) {
            writer.write(alternate);
            writer.write('\n');
        } catch (IOException e) {
            throw new InternalFailureException("Failed to write alternates", e);
        }
    }

    @Override
    public void createRef(@Nonnull Repository repository, @Nonnull String refName, @Nonnull String hash) {
        requireNonNull(repository, "repository");
        requireNonNull(refName, "refName");
        requireNonNull(hash, "hash");

        createLooseRef(repository.getPath(), refName, hash);
    }

    @Override
    public void createSymbolicRef(@Nonnull Repository repository, @Nonnull String refName, @Nonnull String targetRef) {
        requireNonNull(repository, "repository");
        requireNonNull(refName, "refName");
        requireNonNull(targetRef, "targetRef");

        //There's really no difference between a symbolic ref and a normal ref, beyond that one references a hash
        //and the other starts with ref: and then a fully-qualified ref, so we reuse the createRef method here.
        createRef(repository, refName, SYMBOLIC_REF + targetRef);
    }

    @Override
    public boolean deleteLooseRef(@Nonnull Repository repository, @Nonnull String refName) {
        requireNonNull(repository, "repository");
        requireNonNull(refName, "refName");

        Path ref = safeSubPath(repository.getPath(), refName);
        try (UnderLock.LockedFile ignored = UnderLock.forFile(ref, REF_LOCK_TIMEOUT).lock()) {
            Files.delete(ref);
            return true;
        } catch (FileNotFoundException | NoSuchFileException e) {
            //Nothing to delete
        } catch (OverlappingFileLockException | IOException e) {
            throw new DeleteRefFailedException(refName, e);
        }
        return false;
    }

    @Override
    public void enableReflog(@Nonnull Repository repository, @Nonnull String refName) {
        requireNonNull(repository, "repository");
        requireNonNull(refName, "refName");

        Path repositoryDir = repository.getPath();
        Path logsDir = repositoryDir.resolve(PATH_LOGS);
        Path ref = logsDir.resolve(refName);
        requireFileWithin(ref, repositoryDir);
        if (Files.exists(ref)) {
            log.trace("Logging has already been enabled for {}", refName);
            return;
        }

        Path refDirectory = ref.getParent();
        try {
            MoreFiles.mkdir(refDirectory);
        } catch (IllegalStateException e) {
            throw new EnableRefLogFailedException(refName, e);
        }

        try {
            MoreFiles.touch(ref);
        } catch (IOException e) {
            throw new EnableRefLogFailedException(refName, e);
        }
    }

    @Nonnull
    @Override
    public String getHead(@Nonnull Repository repository) throws DetachedHeadException, NoDefaultBranchException {
        requireNonNull(repository, "repository");

        Path head = repository.getPath().resolve(HEAD);
        log.trace("{}: Loading HEAD from {}", repository, head);

        String value = execute(new FirstLineCallback(), head);
        if (value == null) {
            throw new NoDefaultBranchException(null);
        }

        //There are two options for the contents of HEAD:
        //1. A 40-digit SHA identifying a commit; this indicates a detached head
        //2. A symbolic reference to the default branch
        //Detached head mode makes no sense, so if the value of HEAD is not a reference we throw an exception
        if (value.startsWith(SYMBOLIC_REF)) {
            //Symbolic link. Next, we need to strip away the leading "refs/heads/" and get just the branch name
            return value.substring(SYMBOLIC_REF.length());
        } else {
            throw new DetachedHeadException(repository);
        }
    }

    @Override
    public boolean hasObjects(@Nonnull Repository repository) {
        Path objectsDir = repository.getPath().resolve(PATH_OBJECTS);
        Path packDir = objectsDir.resolve(PATH_PACK);
        MutableBoolean objectOrPackFound = new MutableBoolean();
        try {
            Files.walkFileTree(objectsDir, new SimpleFileVisitor<Path>() {

                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                    if (packDir.equals(dir) || objectsDir.equals(dir) || isLooseObjectDir(dir)) {
                        return FileVisitResult.CONTINUE;
                    }
                    return FileVisitResult.SKIP_SUBTREE;
                }

                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                    if (isPackFile(file) || isLooseObject(file)) {
                        objectOrPackFound.setTrue();
                        return FileVisitResult.TERMINATE;
                    }
                    return FileVisitResult.CONTINUE;
                }

                private boolean isHex(String value) {
                    for (int i = 0; i < value.length(); ++i) {
                        char c = value.charAt(i);
                        if (!(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f')) {
                            return false;
                        }
                    }
                    return true;
                }

                private boolean isLooseObject(Path file) {
                    return isLooseObjectDir(file.getParent()) && isHex(file.getFileName().toString());
                }

                private boolean isLooseObjectDir(Path dir) {
                    if (dir == null || !objectsDir.equals(dir.getParent())) {
                        return false;
                    }
                    String filename = dir.getFileName().toString();
                    return filename.length() == 2 && isHex(filename);
                }

                private boolean isPackFile(Path file) {
                    return packDir.equals(file.getParent()) && file.getFileName().toString().endsWith(".pack");
                }
            });
        } catch (IOException e) {
            if (!Files.isDirectory(objectsDir)) {
                //If the objects directory does not exist, there cannot be any objects
                return false;
            }
            log.warn("[{}] Failed to check whether the repository has objects", repository, e);
        }

        return objectOrPackFound.booleanValue();
    }

    @Override
    public boolean isEmpty(@Nonnull Repository repository) {
        requireNonNull(repository, "repository");
        Path repositoryDir = requireNonNull(repository.getPath(), "repositoryDir");

        //STASH-1930 This implementation needs to be fairly resilient to situations that should generally never happen.
        //Due to the threaded nature of Stash and, more particularly, repository creation, it's possible empty checks
        //may happen after a repository has been created in the database but before it has been created on disk. As a
        //result, the check here needs to cope with the idea that the directory may not exist, or not be fully created.
        //
        //If the repository directory exists, packed refs are checked first. That should be the fastest check, since it
        //only involves opening a single well-known file. If there are no packed refs, then we look for any loose refs.
        //That's done second because it could involve traversing an arbitrary number of empty directories.
        if (hasPackedRefs(repositoryDir) || hasLooseRefs(repositoryDir)) {
            log.debug("{}: Repository is not empty", repository);
            return false;
        }

        log.debug("{}: Repository is empty", repository);
        return true;
    }

    @Nullable
    @Override
    public Branch resolveBranch(@Nonnull Repository repository, @Nonnull String refName) {
        requireNonNull(repository, "repository");
        requireNonNull(refName, "refName");

        return resolveBranch(repository, refName, false);
    }

    @Nullable
    @Override
    public Branch resolveBranch(@Nonnull Repository repository, @Nonnull String refName, boolean absolute) {
        requireNonNull(repository, "repository");
        requireNonNull(refName, "refName");
        String head = getHead(repository);

        try {
            return resolveBranch(repository, refName, absolute, head);
        } catch (IllegalArgumentException e) {
            //We can get into this block of code if the refName contains some characters that are not valid for paths
            throw new InvalidRefNameException(INVALID, refName);
        }
    }

    @Nonnull
    @Override
    public Branch resolveHead(@Nonnull Repository repository) throws DetachedHeadException, NoDefaultBranchException {
        requireNonNull(repository, "repository");
        String head = getHead(repository);
        log.trace("{}: Resolving head {}", repository, head);

        Branch branch = resolveBranch(repository, head, true, head);
        if (branch == null) {
            throw new NoDefaultBranchException(head);
        }

        return branch;
    }

    @Nonnull
    @Override
    public String revParse(@Nonnull Path repositoryDir, @Nonnull String value) {
        requireNonNull(repositoryDir, "repositoryDir");

        String next = requireNonNull(value, "value");
        while (true) {
            String result = execute(new FirstLineCallback(), safeSubPath(repositoryDir, next));
            if (result == null) {
                //If the "next" value to parse doesn't match a loose ref, break out and try to resolve it
                //as a packed ref
                break;
            }

            if (result.startsWith(SYMBOLIC_REF)) {
                //If the result is a symbolic ref, set "next" to the target ref and continue parsing
                next = result.substring(SYMBOLIC_REF.length());
            } else {
                //Otherwise, for a non-symbolic result, return it
                return result;
            }
        }

        //If we make it here, "next" (which may be "value" or may be something else, if "value" was a symbolic ref)
        //could not be resolved using loose refs. Search for it in packed-refs
        String searchId = next;
        String found = streamPackedRefs(repositoryDir.resolve(PACKED_REFS),
                (hash, refId) -> {
                    if (searchId.equals(refId)) {
                        //If "next" matches the current refId, return the hash. Note that symbolic refs are
                        //never packed, so "hash" can't possibly be a pointer to another ref
                        return hash;
                    }
                    //Otherwise, return null to continue searching
                    return null;
                });

        return firstNonNull(found, value);
    }

    @Override
    public void setHead(@Nonnull Path repositoryDir, @Nonnull String refName) {
        requireNonNull(repositoryDir, "repositoryDir");
        requireNonNull(refName, "refId");

        Path head = repositoryDir.resolve(HEAD);
        requireFileWithin(repositoryDir.resolve(refName), repositoryDir);
        try {
            Files.write(head, Collections.singleton(SYMBOLIC_REF + refName), UTF_8);
        } catch (IOException e) {
            throw new SetDefaultBranchFailedException(refName, e);
        }
    }

    @Override
    @Nonnull
    public Map<String, Ref> shallowResolveRefs(@Nonnull Repository repository, @Nonnull Collection<String> refIds,
                                               boolean absolute) {
        if (requireNonNull(refIds, "refIds").isEmpty()) {
            return Collections.emptyMap();
        }

        Path repositoryDir = requireNonNull(repository, "repository").getPath();
        Map<String, String> absoluteIdByRefId = new HashMap<>();
        Map<String, String> resolvedHashByAbsoluteId = new HashMap<>();
        Map<String, String> targetRefByAbsoluteId = new HashMap<>(); // to track what ref symbolic refs target

        //There are two possible ways to resolve a ref:
        //1. Look for a file on disk (a loose ref)
        //2. Look in packed-refs for an entry
        //The loose refs take precedence over packed refs, which is only updated by garbage collection and some other
        //git housekeeping commands.
        //
        //For loose refs, git applies a standard set of precedence rules. Those rules have been embedded into the
        //RefPattern enumeration. We need to apply the same rules here if the ref is not absolute.
        Map<String, List<String>> refsToResolve = new HashMap<>();
        if (absolute) {
            refIds.forEach(refId -> refsToResolve.put(refId, Collections.singletonList(refId)));
        } else {
            refIds.forEach(refId -> refsToResolve.put(refId, getRefCandidates(refId)));
        }

        //1. Try to resolve the refs using loose refs
        Iterator<Map.Entry<String, List<String>>> unresolvedIt = refsToResolve.entrySet().iterator();
        while (unresolvedIt.hasNext()) {
            Map.Entry<String, List<String>> unresolved = unresolvedIt.next();
            List<String> candidates = unresolved.getValue();
            for (int i = 0; i < candidates.size(); ++i) {
                String candidate = candidates.get(i);
                if (!resolvedHashByAbsoluteId.containsKey(candidate)) { // only try to resolve a candidate once
                    Either<String, Optional<String>> resolved = resolveLooseRef(repository, candidate);
                    if (resolved.isLeft()) {
                        // it's a symbolic ref that cannot be resolved as a loose ref
                        unresolvedIt.remove();
                        absoluteIdByRefId.put(unresolved.getKey(), candidate);
                        targetRefByAbsoluteId.put(candidate, resolved.left().get());
                    }
                    // store the resolved hash if found, a null if not found, or a null if found, but a symbolic ref
                    // that could not be resolved.
                    resolvedHashByAbsoluteId.put(candidate, resolved.getOrElse(Optional.empty()).orElse(null));
                }
                String hash = resolvedHashByAbsoluteId.get(candidate);
                if (hash != null) {
                    absoluteIdByRefId.put(unresolved.getKey(), candidate); // resolved it!
                    if (i == 0) {
                        // no need to check the packed-refs; it's not going to get any better than this
                        unresolvedIt.remove();
                    } else {
                        // there may be a better match in packed-refs - but we don't need to consider the remainder of
                        // the candidates
                        unresolved.setValue(candidates.subList(0, i));
                    }
                    break;
                }
            }
        }

        //2. Look for remaining items or better matches in packed-refs
        BiConsumer<String, String> packedRefCallback = (hash, refId) -> {
            Iterator<Map.Entry<String, List<String>>> it = refsToResolve.entrySet().iterator();
            while (it.hasNext()) {
                Map.Entry<String, List<String>> unresolved = it.next();
                List<String> candidates = unresolved.getValue();
                for (int i = 0; i < candidates.size(); ++i) {
                    if (refId.equals(candidates.get(i))) {
                        absoluteIdByRefId.put(unresolved.getKey(), refId);
                        resolvedHashByAbsoluteId.putIfAbsent(refId, hash);
                        if (i == 0) {
                            // no chance of finding a hit with a higher precedence
                            it.remove();
                        } else {
                            // there are candidates with a higher precedence
                            unresolved.setValue(candidates.subList(0, i + 1));
                        }
                    }
                }
            }
            // resolve symbolic ref targets
            targetRefByAbsoluteId.entrySet().stream()
                    .filter(entry -> entry.getValue().equals(refId))
                    .map(Map.Entry::getKey)
                    .forEach(absoluteId -> resolvedHashByAbsoluteId.put(absoluteId, hash));
        };

        if (!(refsToResolve.isEmpty() && targetRefByAbsoluteId.isEmpty())) {
            streamPackedRefs(repositoryDir.resolve(PACKED_REFS), packedRefCallback);
        }

        //Build the results
        @SuppressWarnings("Guava") //Can't be memoized as easily with Java's Supplier
        Supplier<String> defaultBranchName = Suppliers.memoize(() -> getHead(repository));
        Map<String, Ref> result = new HashMap<>();
        for (String refId : refIds) {
            String absoluteId = absoluteIdByRefId.get(refId);
            if (absoluteId == null) {
                continue;
            }

            String hash = resolvedHashByAbsoluteId.get(absoluteId);
            if (hash == null) {
                result.put(refId, null);
                continue;
            }

            if (absoluteId.startsWith(GitRefPattern.TAGS.getPath())) {
                // Tags cannot be peeled without forking out to git so we return a Tag here, where the Ref's
                // latestCommit field refers to the tag object (or the commit for lightweight tags). This is slightly
                // dubious given the Ref and Tag interfaces, but worth it for performance reasons. It is also the
                // reason why this method is for internal use only.
                result.put(refId, new Tag.Builder()
                        .id(absoluteId)
                        .latestCommit(hash)
                        .hash(hash)
                        .build());
            } else {
                result.put(refId, new Branch.Builder()
                        .id(absoluteId)
                        .latestCommit(hash)
                        .isDefault(absoluteId.equals(defaultBranchName.get()))
                        .build());
            }
        }

        return result;
    }

    private void createLooseRef(Path repositoryDir, String refName, String hash) {
        Path ref = safeSubPath(repositoryDir, refName);

        Path refDirectory = ref.getParent();
        try {
            MoreFiles.mkdir(refDirectory);
        } catch (IllegalStateException e) {
            throw new CreateRefFailedException(refName, e);
        }

        try (UnderLock.LockedFile ignored = UnderLock.forFile(ref, REF_LOCK_TIMEOUT).lock()) {
            Files.write(ref, Collections.singleton(hash), UTF_8);
        } catch (OverlappingFileLockException | IOException e) {
            throw new CreateRefFailedException(refName, e);
        }
    }

    private <T> T execute(IoFunction<LineReader, T> callback, Path file) {
        return execute(callback, file, null);
    }

    private <T> T execute(IoFunction<LineReader, T> callback, Path file, T defaultValue) {
        try (LineReader reader = new BufferedLineReader(file)) {
            return callback.apply(reader);
        } catch (FileNotFoundException | NoSuchFileException e) {
            return defaultValue;
        } catch (IOException e) {
            if (Files.isDirectory(file)) {
                log.debug("{}: Directory found where file was expected", file, e);
                return defaultValue;
            }
            throw new InternalFailureException("Error opening/reading [" + file.toAbsolutePath() + "]", e);
        }
    }

    private List<String> getRefCandidates(@Nonnull String refId) {
        return Arrays.asList(refId, "refs/" + refId, "refs/tags/" + refId, "refs/heads/" + refId, "refs/remotes/" + refId);
    }

    /**
     * Examines the repository stored in the provided {@code directory} to determine if it has any loose refs. Loose
     * refs are files stored under {@code refs/heads/} or {@code refs/tags/} rather than in the
     * {@link GitConstants#PACKED_REFS} file.
     *
     * @param repositoryDir the repository directory
     * @return {@code true} if there are loose refs; otherwise, {@code false}
     */
    private boolean hasLooseRefs(Path repositoryDir) {
        MutableBoolean loose = new MutableBoolean(false);
        MutableInt directoriesChecked = new MutableInt(0);
        boolean refsDirExists = true;
        try {
            Files.walkFileTree(repositoryDir.resolve(PATH_REFS), EnumSet.noneOf(FileVisitOption.class),
                    Integer.MAX_VALUE, new SimpleFileVisitor<Path>() {

                        @Override
                        public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                            directoriesChecked.increment();
                            return super.preVisitDirectory(dir, attrs);
                        }

                        @Override
                        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                            if (file.toString().endsWith(".lock")) {
                                //Locks aren't refs. They won't be packed, and won't appear in for-each-ref or
                                //other commands that list refs, so they shouldn't count here either
                                return FileVisitResult.CONTINUE;
                            }

                            //Once we find our first loose ref, we're done
                            loose.setValue(true);
                            return FileVisitResult.TERMINATE;
                        }
                    });
        } catch (NoSuchFileException e) {
            //If the refs directory doesn't exist, it's empty by nature. No need to log anything here
            refsDirExists = false;
        } catch (IOException e) {
            //We couldn't list the directory contents for some other reason. We'll assume there aren't
            //any loose refs, but we'll log a warning to hopefully trace down what went wrong
            log.warn("{}: Failed to check for loose refs", repositoryDir.getFileName(), e);
        }

        if (loose.isFalse()) {
            log.debug("{}: No loose refs found ('refs/' exists: {}, Directories checked: {})",
                    repositoryDir.getFileName(), refsDirExists, directoriesChecked);
        }

        return loose.isTrue();
    }

    /**
     * Examines the repository stored in the provided {@code directory} to determine if it has any packed refs. During
     * garbage collection, any {@link #hasLooseRefs(Path) loose refs} in the repository are packed to optimise
     * both storage and performance and stored in the {@link GitConstants#PACKED_REFS} file.
     * <p>
     * This check ignores any comment lines in the {@code packed-refs} file. It is <i>not</i> enough to simply check
     * for the existence of the file; it must be opened and iterated (at least until the first non-comment line).
     *
     * @param repositoryDir the repository directory
     * @return {@code true} if there are packed refs; otherwise, {@code false}
     */
    private boolean hasPackedRefs(Path repositoryDir) {
        Path packedRefs = repositoryDir.resolve(PACKED_REFS);

        //If the packed refs file doesn't exist we clearly don't have any, but even if it does exist it may contain
        //nothing but a header (meaning the repository is still empty).
        MutableBoolean packFileExists = new MutableBoolean(false);
        MutableInt linesChecked = new MutableInt(0);
        boolean packed = execute(reader -> {
            packFileExists.setTrue();
            for (String line = reader.readLine(); line != null; line = reader.readLine()) {
                linesChecked.increment();
                if (line.startsWith("#")) { //Lines starting with a # are comments
                    continue;
                }
                return Boolean.TRUE;
            }

            return Boolean.FALSE;
        }, packedRefs, Boolean.FALSE); //Return FALSE on NoSuchFileExceptions to avoid existence races

        if (!packed) {
            log.debug("{}: No packed refs found (packed-refs exists: {}; lines checked: {})",
                    repositoryDir, packFileExists, linesChecked);
        }

        return packed;
    }

    private void requireFileWithin(Path testPath, Path expectedParent) {
        try {
            MoreFiles.requireWithin(testPath, expectedParent);
        } catch (IOException e) {
            throw new InternalFailureException("Could not verify nested path", e);
        }
    }

    private Branch resolveBranch(Repository repository, String name, boolean absolute, String defaultName) {
        Path repositoryDir = repository.getPath();

        //There are two possible ways to resolve a branch:
        //1. Look for a file on disk (a loose ref)
        //2. Look in packed-refs for an entry
        //The loose refs take precedence over packed refs, which is only updated by garbage collection and some other
        //git housekeeping commands.
        //
        //For loose refs, git applies a standard set of precedence rules. Those rules have been embedded into the
        //RefPattern enumeration. We need to apply the same rules here if the ref is not absolute.
        List<String> searchIds = absolute ? Collections.singletonList(name) : HEADS.getSearchOrder(name);

        //All permutations for a loose ref are checked before any packed ref. Note that, to minimise memory usage for
        //repositories with a large number of loose refs, this doesn't use IOUtils to list all files.
        for (String searchId : searchIds) {
            Path ref = safeSubPath(repositoryDir, searchId);

            //For loose refs, rather than checking file existence, which can race, handle the FileNotFoundException
            String hash = execute(new FirstLineCallback(), ref);
            if (hash == null) {
                //If the returned hash is null, it means the ref doesn't exist. Look for the next pattern
                continue;
            }

            //Note: Symbolic refs are _never packed_. That means we only need to deal with this case on loose refs.
            Branch.Builder builder;
            if (hash.startsWith(SYMBOLIC_REF)) {
                //The branch is a symbolic reference to another branch. We need to resolve what it's referring to.
                //Note that the symbolic ref is always absolute.
                String target = hash.substring(SYMBOLIC_REF.length());

                Branch resolved = resolveBranch(repository, target, true, defaultName);
                if (resolved == null) {
                    //Dead link (or, possibly, a reference to something that isn't a HEAD)
                    log.warn("{}: {} is a symbolic ref to non-existent branch {}",
                            repository, name, target);
                    return null;
                }
                if (log.isTraceEnabled()) {
                    log.trace("{}: Resolved {} from {} via symbolic ref to {}",
                            repository, searchId, ref, target);
                }
                builder = new Branch.Builder(resolved);
            } else {
                if (log.isTraceEnabled()) {
                    log.trace("{}: Resolved {} from {}", repository, searchId, ref);
                }
                builder = new Branch.Builder()
                        .latestCommit(hash);
            }

            return builder
                    .id(searchId)
                    .isDefault(defaultName.equals(searchId))
                    .build();
        }

        //No loose ref. Look for an entry in packed-refs. Refs are searched in a one-shot pass streaming from the
        //appropriate packed refs file (see isDarkRef(String) documentation) to minimise memory usage
        Path packedRefs = repositoryDir.resolve(PACKED_REFS);

        log.trace("{}: Attempting to resolve {} from {}", repository, name, packedRefs);
        Branch branch = streamPackedRefs(packedRefs, (hash, refId) -> {
            for (String searchId : searchIds) {
                if (searchId.equals(refId)) {
                    return new Branch.Builder()
                            .id(searchId)
                            .latestCommit(hash)
                            .isDefault(defaultName.equals(searchId))
                            .build();
                }
            }

            //No entry in packed-refs corresponds to the provided name
            return null;
        });

        if (branch == null) {
            //Making it here is actually a valid case. It indicates there is no match, loose or packed, for the desired
            //reference. This indicates the ref doesn't exist.
            log.debug("{}: Could not resolve {} directly or from packed-refs", repository, name);
        }

        return branch;
    }

    /**
     * Attempts to resolves the provided refId from a loose ref file
     *
     * @param repository    the repository
     * @param absoluteRefId the absolute ref ID
     * @return <ul>
     * <li>
     * {@link Either#left()} if the loose ref existed that is a symbolic ref whose target couldn't be
     * resolved to another loose ref. This means that the target may be part of packed-refs, or
     * invalid
     * </li>
     * <li>
     * {@link Either#right()}:
     *                <ul>
     *                    <li>{@link Optional#empty()} if the ref could not be resolved as a loose ref</li>
     *                    <li>{@link Optional#of(Object)} if the ref could be resolved (either directly or through
     *                        N symbolic refs culminating in a loose ref)
     *                    </li>
     *                </ul>
     *            </li>
     *        </ul>
     */
    private Either<String, Optional<String>> resolveLooseRef(@Nonnull Repository repository,
                                                             @Nonnull String absoluteRefId) {
        // Git rejects any ref that contains:
        //     '..', a ASCII control character,
        //     and/or ":", "?", "[", "\", "^", "~", SP, or TAB
        if (absoluteRefId.contains("..")) {
            return Either.right(Optional.empty());
        }

        Path ref = safeSubPath(repository.getPath(), absoluteRefId);

        String hash = execute(new FirstLineCallback(), ref);
        if (hash == null) {
            //If the returned hash is null, it means the loose ref doesn't exist.
            return Either.right(Optional.empty());
        }

        //Found a loose ref.
        if (hash.startsWith(SYMBOLIC_REF)) {
            //The ref is a symbolic reference to another ref. We need to resolve what it's referring to.
            //Note that the symbolic ref is always absolute.
            String target = hash.substring(SYMBOLIC_REF.length());

            Either<String, Optional<String>> resolvedTarget = resolveLooseRef(repository, target);
            if (resolvedTarget.isLeft() || resolvedTarget.right().get().isPresent()) {
                // target was another symbolic ref that could not be resolved from a loose ref
                // or target was resolved to a hash
                return resolvedTarget;
            }
            // could not resolve target
            log.trace("{}: {} is a symbolic ref to {}", repository, absoluteRefId, target);
            return Either.left(target);
        }
        log.trace("{}: Resolved {} from {} to {}", repository, absoluteRefId, ref, hash);
        return Either.right(Optional.of(hash));
    }

    /**
     * Creates a new <code>File</code> instance from a parent pathname and a child pathname string.
     * <p>
     * This method has the same behaviour as {@link File#File(File, String)} except it protects against path traversal
     * by throwing an exception in the case the returned path would not be contained within the {@code parent}.
     *
     * @param parent the parent pathname
     * @param child  the child pathname string
     */
    private Path safeSubPath(Path parent, String child) {
        Path subPath = parent.resolve(child);
        requireFileWithin(subPath, parent);
        return subPath;
    }

    private void streamPackedRefs(Path packedRefsFile, BiConsumer<String, String> refConsumer) {
        streamPackedRefs(packedRefsFile, (hash, refId) -> {
            refConsumer.accept(hash, refId);

            return null;
        });
    }

    private <T> T streamPackedRefs(Path packedRefsFile, BiFunction<String, String, T> refCallback) {
        return execute(reader -> {
            for (String line = reader.readLine(); line != null; line = reader.readLine()) {
                if ((line.startsWith("^")) || line.startsWith("#")) {
                    //Lines starting with a # are comments
                    //Lines starting with a ^ show the hash of a tagged commit, so we ignore them
                    continue;
                }

                int index = line.indexOf(' ');
                if (index == -1) {
                    throw new IllegalStateException("Unexpected entry [" + line +
                            "] in " + packedRefsFile.getFileName());
                }

                String hash = line.substring(0, index);
                String refId = line.substring(index + 1);

                T result = refCallback.apply(hash, refId);
                if (result != null) {
                    return result;
                }
            }

            return null;
        }, packedRefsFile);
    }

    /**
     * A {@link LineReader} which uses a {@link BufferedReader} to read a file, logging warnings for any lines
     * that aren't valid UTF-8.
     */
    private static class BufferedLineReader implements LineReader {

        private final Path file;
        private final BufferedReader reader;

        BufferedLineReader(Path file) throws IOException {
            this.file = file;

            //Constructing our own InputStreamReader results in one that uses CodingErrorAction.REPLACE
            //for malformed input and unmappable characters
            reader = new BufferedReader(new InputStreamReader(Files.newInputStream(file), UTF_8));
            //Files.newBufferedReader creates its own StreamDecoder without setting the CodingErrorAction
            //to use, which defaults to REPORT for malformed input and unmappable characters, resulting in
            //exceptions for either case (this is documented as option 3 in readLine() below)
            //reader = Files.newBufferedReader(file.toPath(), UTF_8);
        }

        @Override
        public void close() throws IOException {
            reader.close();
        }

        @Override
        public String readLine() throws IOException {
            String line = reader.readLine();
            if (line != null && line.indexOf('\uFFFD') != -1) {
                //If the line contains the UTF-8 replacement character, it means we couldn't decode it
                //correctly. All our choices here are less than ideal:
                //
                //- Log a warning and ignore the line
                //  - This may omit refs from the UI, but at least, for any refs that make it through,
                //    we know that they're correctly decoded
                //- Log a warning and use the line with the replacement character in place
                //  - This could result in a "broken" ref name, but it's less misleading than omitting
                //    the ref entirely (which may make users wonder why it's not visible)
                //- Throw an exception
                //  - This renders the repository unusable via REST or UI until an administrator can
                //    manually clean up the offending ref
                //
                //Since the throw-an-exception behavior significantly impacts usability, and since neither
                //of the other options is ideal, it's essentially a judgment call.
                //
                //Prior to Stash 3.9, option 2 (use the line with the replacement character in place) is
                //what the system would have done, so that's what this does.
                log.warn("{}: Found one or more non-UTF-8 characters: {}", file, line);
            }

            return line;
        }
    }

    /**
     * Retrieves the first line from the {@link LineReader} and ignores anything after it.
     */
    private static class FirstLineCallback implements IoFunction<LineReader, String> {

        @Override
        public String apply(@Nonnull LineReader reader) throws IOException {
            return reader.readLine();
        }
    }

    /**
     * A minimal abstraction over a reader that produces output one line at a time.
     */
    private interface LineReader extends AutoCloseable {

        @Override
        void close() throws IOException;

        @Nullable
        String readLine() throws IOException;
    }
}
