# This file is a part of Julia. License is MIT: https://julialang.org/license

"""
    LibGit2.GitRepo(path::AbstractString)

Open a git repository at `path`.
"""
function GitRepo(path::AbstractString)
    ensure_initialized()
    repo_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)
    @check ccall((:git_repository_open, libgit2), Cint,
                 (Ptr{Ptr{Cvoid}}, Cstring), repo_ptr_ptr, path)
    return GitRepo(repo_ptr_ptr[])
end

"""
    LibGit2.GitRepoExt(path::AbstractString, flags::Cuint = Cuint(Consts.REPOSITORY_OPEN_DEFAULT))

Open a git repository at `path` with extended controls (for instance, if the current
user must be a member of a special access group to read `path`).
"""
function GitRepoExt(path::AbstractString, flags::Cuint = Cuint(Consts.REPOSITORY_OPEN_DEFAULT))
    ensure_initialized()
    separator = @static Sys.iswindows() ? ";" : ":"
    repo_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)
    @check ccall((:git_repository_open_ext, libgit2), Cint,
                 (Ptr{Ptr{Cvoid}}, Cstring, Cuint, Cstring),
                 repo_ptr_ptr, path, flags, separator)
    return GitRepo(repo_ptr_ptr[])
end

function cleanup(r::GitRepo)
    if r.ptr != C_NULL
        ensure_initialized()
        @check ccall((:git_repository__cleanup, libgit2), Cint, (Ptr{Cvoid},), r)
    end
end

"""
    LibGit2.init(path::AbstractString, bare::Bool=false) -> GitRepo

Open a new git repository at `path`. If `bare` is `false`,
the working tree will be created in `path/.git`. If `bare`
is `true`, no working directory will be created.
"""
function init(path::AbstractString, bare::Bool=false)
    ensure_initialized()
    repo_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)
    @check ccall((:git_repository_init, libgit2), Cint,
                (Ptr{Ptr{Cvoid}}, Cstring, Cuint), repo_ptr_ptr, path, bare)
    return GitRepo(repo_ptr_ptr[])
end

"""
    LibGit2.head_oid(repo::GitRepo) -> GitHash

Lookup the object id of the current HEAD of git
repository `repo`.
"""
function head_oid(repo::GitRepo)
    head_ref = head(repo)
    try
        return GitHash(head_ref)
    finally
        close(head_ref)
    end
end

"""
    LibGit2.headname(repo::GitRepo)

Lookup the name of the current HEAD of git
repository `repo`. If `repo` is currently
detached, return the name of the HEAD it's
detached from.
"""
function headname(repo::GitRepo)
    with(head(repo)) do href
        if isattached(repo)
            shortname(href)
        else
            "(detached from $(string(GitHash(href))[1:7]))"
        end
    end
end

"""
    isbare(repo::GitRepo) -> Bool

Determine if `repo` is bare. Suppose the top level directory of `repo` is `DIR`.
A non-bare repository is one in which the git directory (see [`gitdir`](@ref)) is
`DIR/.git`, and the working tree can be checked out. A bare repository is one in
which all of git's administrative files are simply in `DIR`, rather than "hidden"
in the `.git` subdirectory. This means that there is nowhere to check out the working
tree, and no tracking information for remote branches or configurations is present.
"""
function isbare(repo::GitRepo)
    ensure_initialized()
    @assert repo.ptr != C_NULL
    return ccall((:git_repository_is_bare, libgit2), Cint, (Ptr{Cvoid},), repo) == 1
end

"""
    isattached(repo::GitRepo) -> Bool

Determine if `repo` is detached - that is, whether its HEAD points to a commit
(detached) or whether HEAD points to a branch tip (attached).
"""
function isattached(repo::GitRepo)
    ensure_initialized()
    @assert repo.ptr != C_NULL
    ccall((:git_repository_head_detached, libgit2), Cint, (Ptr{Cvoid},), repo) != 1
end

@doc """
    GitObject(repo::GitRepo, hash::AbstractGitHash)
    GitObject(repo::GitRepo, spec::AbstractString)

Return the specified object ([`GitCommit`](@ref), [`GitBlob`](@ref), [`GitTree`](@ref) or [`GitTag`](@ref)) from `repo`
specified by `hash`/`spec`.

- `hash` is a full (`GitHash`) or partial (`GitShortHash`) hash.
- `spec` is a textual specification: see [the git docs](https://git-scm.com/docs/git-rev-parse.html#_specifying_revisions) for a full list.
""" GitObject

for T in (:GitCommit, :GitBlob, :GitTree, :GitTag)
    @eval @doc $"""
    $T(repo::GitRepo, hash::AbstractGitHash)
    $T(repo::GitRepo, spec::AbstractString)

Return a `$T` object from `repo` specified by `hash`/`spec`.

- `hash` is a full (`GitHash`) or partial (`GitShortHash`) hash.
- `spec` is a textual specification: see [the git docs](https://git-scm.com/docs/git-rev-parse.html#_specifying_revisions) for a full list.
""" $T
end

function (::Type{T})(repo::GitRepo, spec::AbstractString) where T<:GitObject
    ensure_initialized()
    obj_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)
    @assert repo.ptr != C_NULL
    @check ccall((:git_revparse_single, libgit2), Cint,
                 (Ptr{Ptr{Cvoid}}, Ptr{Cvoid}, Cstring), obj_ptr_ptr, repo, spec)
    obj_ptr = obj_ptr_ptr[]
    # check object is of correct type
    if T != GitObject && T != GitUnknownObject
        t = Consts.OBJECT(obj_ptr)
        if t != Consts.OBJECT(T)
            if obj_ptr != C_NULL
                # free result
                ccall((:git_object_free, libgit2), Cvoid, (Ptr{Cvoid},), obj_ptr)
            end
            throw(GitError(Error.Object, Error.ERROR, "Expected object of type $T, received object of type $(objtype(t))"))
        end
    end
    return T(repo, obj_ptr)
end

function (::Type{T})(repo::GitRepo, oid::GitHash) where T<:GitObject
    ensure_initialized()
    oid_ptr  = Ref(oid)
    obj_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)

    @assert repo.ptr != C_NULL
    @check ccall((:git_object_lookup, libgit2), Cint,
                 (Ptr{Ptr{Cvoid}}, Ptr{Cvoid}, Ptr{GitHash}, Consts.OBJECT),
                 obj_ptr_ptr, repo, oid_ptr, Consts.OBJECT(T))

    return T(repo, obj_ptr_ptr[])
end
function (::Type{T})(repo::GitRepo, oid::GitShortHash) where T<:GitObject
    ensure_initialized()
    oid_ptr  = Ref(oid.hash)
    obj_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)

    @assert repo.ptr != C_NULL
    @check ccall((:git_object_lookup_prefix, libgit2), Cint,
                 (Ptr{Ptr{Cvoid}}, Ptr{Cvoid}, Ptr{GitHash}, Csize_t, Consts.OBJECT),
                 obj_ptr_ptr, repo, oid_ptr, oid.len, Consts.OBJECT(T))

    return T(repo, obj_ptr_ptr[])
end

# TODO: deprecate this function
revparseid(repo::GitRepo, spec) = GitHash(GitUnknownObject(repo, spec))

"""
    LibGit2.gitdir(repo::GitRepo)

Return the location of the "git" files of `repo`:

 - for normal repositories, this is the location of the `.git` folder.
 - for bare repositories, this is the location of the repository itself.

See also [`workdir`](@ref), [`path`](@ref).
"""
function gitdir(repo::GitRepo)
    ensure_initialized()
    @assert repo.ptr != C_NULL
    GC.@preserve repo begin
        return unsafe_string(ccall((:git_repository_path, libgit2), Cstring,
                                   (Ptr{Cvoid},), repo))
    end
end

"""
    LibGit2.workdir(repo::GitRepo)

Return the location of the working directory of `repo`.
This will throw an error for bare repositories.

!!! note

    This will typically be the parent directory of `gitdir(repo)`, but can be different in
    some cases: e.g. if either the `core.worktree` configuration variable or the
    `GIT_WORK_TREE` environment variable is set.

See also [`gitdir`](@ref), [`path`](@ref).
"""
function workdir(repo::GitRepo)
    ensure_initialized()
    @assert repo.ptr != C_NULL
    GC.@preserve repo begin
        sptr = ccall((:git_repository_workdir, libgit2), Cstring,
                     (Ptr{Cvoid},), repo)
        sptr == C_NULL && throw(GitError(Error.Object, Error.ERROR, "No working directory found."))
        return unsafe_string(sptr)
    end
end

"""
    LibGit2.path(repo::GitRepo)

Return the base file path of the repository `repo`.

 - for normal repositories, this will typically be the parent directory of the ".git"
   directory (note: this may be different than the working directory, see `workdir` for
   more details).
 - for bare repositories, this is the location of the "git" files.

See also [`gitdir`](@ref), [`workdir`](@ref).
"""
function path(repo::GitRepo)
    d = gitdir(repo)
    if isdirpath(d)
        d = dirname(d) # strip trailing separator
    end
    if isbare(repo)
        return d
    else
        parent, base = splitdir(d)
        return base == ".git" ? parent : d
    end
end

"""
    peel([T,] obj::GitObject)

Recursively peel `obj` until an object of type `T` is obtained. If no `T` is provided,
then `obj` will be peeled until the type changes.

- A `GitTag` will be peeled to the object it references.
- A `GitCommit` will be peeled to a `GitTree`.
"""
function peel(::Type{T}, obj::GitObject) where T<:GitObject
    ensure_initialized()
    new_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)

    @check ccall((:git_object_peel, libgit2), Cint,
                (Ptr{Ptr{Cvoid}}, Ptr{Cvoid}, Cint), new_ptr_ptr, obj, Consts.OBJECT(T))

    return T(obj.owner, new_ptr_ptr[])
end
peel(obj::GitObject) = peel(GitObject, obj)

"""
    LibGit2.GitDescribeResult(committish::GitObject; kwarg...)

Produce a `GitDescribeResult` of the `committish` `GitObject`, which
contains detailed information about it based on the keyword argument:

  * `options::DescribeOptions=DescribeOptions()`

A git description of a `committish` object looks for the tag (by default, annotated,
although a search of all tags can be performed) which can be reached from `committish`
which is most recent. If the tag is pointing to `committish`, then only the tag is
included in the description. Otherwise, a suffix is included which contains the
number of commits between `committish` and the most recent tag. If there is no such
tag, the default behavior is for the description to fail, although this can be
changed through `options`.

Equivalent to `git describe <committish>`. See [`DescribeOptions`](@ref) for more
information.
"""
function GitDescribeResult(committish::GitObject;
                           options::DescribeOptions=DescribeOptions())
    ensure_initialized()
    result_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)
    @check ccall((:git_describe_commit, libgit2), Cint,
                 (Ptr{Ptr{Cvoid}}, Ptr{Cvoid}, Ptr{DescribeOptions}),
                 result_ptr_ptr, committish, Ref(options))
    return GitDescribeResult(committish.owner, result_ptr_ptr[])
end

"""
    LibGit2.GitDescribeResult(repo::GitRepo; kwarg...)

Produce a `GitDescribeResult` of the repository `repo`'s working directory.
The `GitDescribeResult` contains detailed information about the workdir based
on the keyword argument:

  * `options::DescribeOptions=DescribeOptions()`

In this case, the description is run on HEAD, producing the most recent tag
which is an ancestor of HEAD. Afterwards, a status check on
the [`workdir`](@ref) is performed and if the `workdir` is dirty
(see [`isdirty`](@ref)) the description is also considered dirty.

Equivalent to `git describe`. See [`DescribeOptions`](@ref) for more
information.
"""
function GitDescribeResult(repo::GitRepo; options::DescribeOptions=DescribeOptions())
    ensure_initialized()
    result_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)
    @assert repo.ptr != C_NULL
    @check ccall((:git_describe_workdir, libgit2), Cint,
                 (Ptr{Ptr{Cvoid}}, Ptr{Cvoid}, Ptr{DescribeOptions}),
                 result_ptr_ptr, repo, Ref(options))
    return GitDescribeResult(repo, result_ptr_ptr[])
end

"""
    LibGit2.format(result::GitDescribeResult; kwarg...) -> String

Produce a formatted string based on a `GitDescribeResult`.
Formatting options are controlled by the keyword argument:

  * `options::DescribeFormatOptions=DescribeFormatOptions()`
"""
function format(result::GitDescribeResult; options::DescribeFormatOptions=DescribeFormatOptions())
    ensure_initialized()
    buf_ref = Ref(Buffer())
    @check ccall((:git_describe_format, libgit2), Cint,
                 (Ptr{Buffer}, Ptr{Cvoid}, Ptr{DescribeFormatOptions}),
                 buf_ref, result, Ref(options))
    buf = buf_ref[]
    str = unsafe_string(buf.ptr, buf.size)
    free(buf_ref)
    return str
end

function Base.show(io::IO, result::GitDescribeResult)
    fmt_desc = format(result)
    println(io, "GitDescribeResult:")
    println(io, fmt_desc)
end

"""
    checkout_tree(repo::GitRepo, obj::GitObject; options::CheckoutOptions = CheckoutOptions())

Update the working tree and index of `repo` to match the tree pointed to by `obj`.
`obj` can be a commit, a tag, or a tree. `options` controls how the checkout will
be performed. See [`CheckoutOptions`](@ref) for more information.
"""
function checkout_tree(repo::GitRepo, obj::GitObject;
                       options::CheckoutOptions = CheckoutOptions())
    ensure_initialized()
    @assert repo.ptr != C_NULL
    @check ccall((:git_checkout_tree, libgit2), Cint,
                 (Ptr{Cvoid}, Ptr{Cvoid}, Ptr{CheckoutOptions}),
                 repo, obj, Ref(options))
end

"""
    checkout_index(repo::GitRepo, idx::Union{GitIndex, Nothing} = nothing; options::CheckoutOptions = CheckoutOptions())

Update the working tree of `repo` to match the index `idx`. If `idx` is `nothing`, the
index of `repo` will be used. `options` controls how the checkout will be performed.
See [`CheckoutOptions`](@ref) for more information.
"""
function checkout_index(repo::GitRepo, idx::Union{GitIndex, Nothing} = nothing;
                        options::CheckoutOptions = CheckoutOptions())
    ensure_initialized()
    @assert repo.ptr != C_NULL
    @check ccall((:git_checkout_index, libgit2), Cint,
                 (Ptr{Cvoid}, Ptr{Cvoid}, Ptr{CheckoutOptions}),
                 repo,
                 idx === nothing ? C_NULL : idx,
                 Ref(options))
end

"""
    checkout_head(repo::GitRepo; options::CheckoutOptions = CheckoutOptions())

Update the index and working tree of `repo` to match the commit pointed to by HEAD.
`options` controls how the checkout will be performed. See [`CheckoutOptions`](@ref) for more information.

!!! warning
    *Do not* use this function to switch branches! Doing so will cause checkout
    conflicts.
"""
function checkout_head(repo::GitRepo; options::CheckoutOptions = CheckoutOptions())
    ensure_initialized()
    @assert repo.ptr != C_NULL
    @check ccall((:git_checkout_head, libgit2), Cint,
                 (Ptr{Cvoid}, Ptr{CheckoutOptions}),
                 repo, Ref(options))
end

"""
    LibGit2.cherrypick(repo::GitRepo, commit::GitCommit; options::CherrypickOptions = CherrypickOptions())

Cherrypick the commit `commit` and apply the changes in it to the current state of `repo`.
The keyword argument `options` sets checkout and merge options for the cherrypick.

!!! note
    `cherrypick` will *apply* the changes in `commit` but not *commit* them, so `repo` will
    be left in a dirty state. If you want to also commit the changes in `commit` you must
    call [`commit`](@ref) yourself.
"""
function cherrypick(repo::GitRepo, commit::GitCommit; options::CherrypickOptions = CherrypickOptions())
    ensure_initialized()
    @assert repo.ptr != C_NULL
    @check ccall((:git_cherrypick, libgit2), Cint,
                 (Ptr{Cvoid}, Ptr{Cvoid}, Ptr{CherrypickOptions}),
                 repo, commit, Ref(options))
end

"""Updates some entries, determined by the `pathspecs`, in the index from the target commit tree."""
function reset!(repo::GitRepo, obj::Union{GitObject, Nothing}, pathspecs::AbstractString...)
    ensure_initialized()
    @assert repo.ptr != C_NULL
    @check ccall((:git_reset_default, libgit2), Cint,
                 (Ptr{Cvoid}, Ptr{Cvoid}, Ptr{StrArrayStruct}),
                 repo,
                 obj === nothing ? C_NULL : obj,
                 collect(pathspecs))
    return head_oid(repo)
end

"""Sets the current head to the specified commit oid and optionally resets the index and working tree to match."""
function reset!(repo::GitRepo, obj::GitObject, mode::Cint;
               checkout_opts::CheckoutOptions = CheckoutOptions())
    ensure_initialized()
    @assert repo.ptr != C_NULL
    @check ccall((:git_reset, libgit2), Cint,
                 (Ptr{Cvoid}, Ptr{Cvoid}, Cint, Ptr{CheckoutOptions}),
                  repo, obj, mode, Ref(checkout_opts))
    return head_oid(repo)
end

"""
    clone(repo_url::AbstractString, repo_path::AbstractString, clone_opts::CloneOptions)

Clone the remote repository at `repo_url` (which can be a remote URL or a path on the local
filesystem) to `repo_path` (which must be a path on the local filesystem). Options for the
clone, such as whether to perform a bare clone or not, are set by [`CloneOptions`](@ref).

# Examples
```julia
repo_url = "https://github.com/JuliaLang/Example.jl"
repo = LibGit2.clone(repo_url, "/home/me/projects/Example")
```
"""
function clone(repo_url::AbstractString, repo_path::AbstractString,
               clone_opts::CloneOptions)
    ensure_initialized()
    clone_opts_ref = Ref(clone_opts)
    repo_ptr_ptr = Ref{Ptr{Cvoid}}(C_NULL)
    @check ccall((:git_clone, libgit2), Cint,
            (Ptr{Ptr{Cvoid}}, Cstring, Cstring, Ref{CloneOptions}),
            repo_ptr_ptr, repo_url, repo_path, clone_opts_ref)
    return GitRepo(repo_ptr_ptr[])
end

"""
    fetchheads(repo::GitRepo) -> Vector{FetchHead}

Return the list of all the fetch heads for `repo`, each represented as a [`FetchHead`](@ref),
including their names, URLs, and merge statuses.

# Examples
```julia-repl
julia> fetch_heads = LibGit2.fetchheads(repo);

julia> fetch_heads[1].name
"refs/heads/master"

julia> fetch_heads[1].ismerge
true

julia> fetch_heads[2].name
"refs/heads/test_branch"

julia> fetch_heads[2].ismerge
false
```
"""
function fetchheads(repo::GitRepo)
    ensure_initialized()
    fh = FetchHead[]
    ffcb = fetchhead_foreach_cb()
    @assert repo.ptr != C_NULL
    @check ccall((:git_repository_fetchhead_foreach, libgit2), Cint,
                 (Ptr{Cvoid}, Ptr{Cvoid}, Any),
                 repo, ffcb, fh)
    return fh
end

"""
    LibGit2.remotes(repo::GitRepo)

Return a vector of the names of the remotes of `repo`.
"""
function remotes(repo::GitRepo)
    ensure_initialized()
    sa_ref = Ref(StrArrayStruct())
    @assert repo.ptr != C_NULL
    @check ccall((:git_remote_list, libgit2), Cint,
                  (Ptr{StrArrayStruct}, Ptr{Cvoid}), sa_ref, repo)
    res = convert(Vector{String}, sa_ref[])
    free(sa_ref)
    return res
end

function Base.show(io::IO, repo::GitRepo)
    print(io, "LibGit2.GitRepo(")
    if repo.ptr == C_NULL
        print(io, "<closed>")
    else
        show(io, path(repo))
    end
    print(io, ")")
end
