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

using Test, FileWatching
using Base: uv_error, Experimental
using Base.Filesystem: StatStruct

@test isempty(Test.detect_closure_boxes(FileWatching))

@testset "FileWatching" begin

# This script does the following
# Sets up N unix pipes (or WSA sockets)
# For the odd pipes, a byte is written to the write end at intervals specified in intvls
# Nothing is written into the even numbered pipes
# Odd numbered pipes are tested for reads
# Even numbered pipes are tested for timeouts
# Writable ends are always tested for write-ability before a write

n = 20
intvls = [2, .2, .1, .005, .00001]
pipe_fds = fill((Base.INVALID_OS_HANDLE, Base.INVALID_OS_HANDLE), n)

for i in 1:n
    if Sys.iswindows() || i > n ÷ 2
        uv_error("socketpair", ccall(:uv_socketpair, Cint, (Cint, Cint, Ptr{NTuple{2, Base.OS_HANDLE}}, Cint, Cint), 1, (Sys.iswindows() ? 6 : 0), Ref(pipe_fds, i), 0, 0))
    else
        uv_error("pipe", ccall(:uv_pipe, Cint, (Ptr{NTuple{2, Base.OS_HANDLE}}, Cint, Cint), Ref(pipe_fds, i), 0, 0))
    end
    Ctype = Sys.iswindows() ? Ptr{Cvoid} : Cint
    FDmax = Sys.iswindows() ? typemax(Int32) : (n + 60 + (isdefined(Main, :Revise) * 30)) # expectations on reasonable values
    fd_in_limits =
        0 <= Int(Base.cconvert(Ctype, pipe_fds[i][1])) <= FDmax &&
        0 <= Int(Base.cconvert(Ctype, pipe_fds[i][2])) <= FDmax
    # Dump out what file descriptors are open for easier debugging of failure modes
    if !fd_in_limits && Sys.islinux()
        run(`ls -la /proc/$(getpid())/fd`)
    end
    if !Sys.isapple()
        @test fd_in_limits
    end
end

function pfd_tst_reads(idx, intvl)
    global ready += 1
    wait(ready_c)
    start_evt2 = Condition()
    evt2 = @async (notify(start_evt2); poll_fd(pipe_fds[idx][1], intvl; readable=true, writable=false))
    wait(start_evt2); yield() # make sure the async poll_fd is pumping events
    evt = poll_fd(pipe_fds[idx][1], intvl; readable=true, writable=false)
    @test !evt.timedout
    @test evt.readable
    @test !evt.writable
    @test evt === fetch(evt2)

    dout = zeros(UInt8, 1)
    @static if Sys.iswindows()
        1 == ccall(:recv, stdcall, Cint, (Ptr{Cvoid}, Ptr{UInt8}, Cint, Cint), pipe_fds[idx][1], dout, 1, 0) || error(Libc.FormatMessage())
    else
        @test 1 == ccall(:read, Csize_t, (Cint, Ptr{UInt8}, Csize_t), pipe_fds[idx][1], dout, 1)
    end
    @test dout[1] == Int8('A')
end


function pfd_tst_timeout(idx, intvl)
    global ready += 1
    wait(ready_c)
    start_evt2 = Condition()
    evt2 = @async (notify(start_evt2); poll_fd(pipe_fds[idx][1], intvl; readable=true, writable=false))
    wait(start_evt2); yield() # make sure the async poll_fd is pumping events
    evt = poll_fd(pipe_fds[idx][1], intvl; readable=true, writable=false)
    @test evt.timedout
    @test !evt.readable
    @test !evt.writable
    @test evt === fetch(evt2)
end

# Odd numbers trigger reads, even numbers timeout
for (i, intvl) in enumerate(intvls)
    Experimental.@sync begin
        global ready = 0
        global ready_c = Condition()
        for idx in 1:n
            if isodd(idx)
                @async pfd_tst_reads(idx, intvl)
            else
                @async pfd_tst_timeout(idx, intvl)
            end
        end

        while ready < n
            sleep(0.1)
        end
        ready = 0
        # tickle only the odd ones, but test for writability for everyone
        for idx in 1:n
            event = poll_fd(pipe_fds[idx][2], 0.001; readable=true, writable=true)
            @test !event.timedout
            @test !event.readable
            @test event.writable

            if isodd(idx)
                @static if Sys.iswindows()
                    1 == ccall(:send, stdcall, Cint, (Ptr{Cvoid}, Ptr{UInt8}, Cint, Cint), pipe_fds[idx][2], "A", 1, 0) || error(Libc.FormatMessage())
                else
                    @test 1 == ccall(:write, Csize_t, (Cint, Ptr{UInt8}, Csize_t), pipe_fds[idx][2], "A", 1)
                end
            end
        end
        notify(ready_c, all=true)
    end
end

for i in 1:n
    for j = 1:2
        @static if Sys.iswindows()
            0 == ccall(:closesocket, stdcall, Cint, (Ptr{Cvoid},), pipe_fds[i][j]) || error(Libc.FormatMessage())
        else
            @test 0 == ccall(:close, Cint, (Cint,), pipe_fds[i][j])
        end
    end
end

for f in (watch_file, watch_folder, poll_file)
    local f
    @test_throws ArgumentError f("adir\0bad")
end

#issue #12992
function test_12992()
    pfw = PollingFileWatcher(@__FILE__, 0.01)
    close(pfw)
    pfw = PollingFileWatcher(@__FILE__, 0.01)
    close(pfw)
    pfw = PollingFileWatcher(@__FILE__, 0.01)
    close(pfw)
    GC.gc()
    GC.gc()
end

# Make sure multiple close is fine
function test2_12992()
    pfw = PollingFileWatcher(@__FILE__, 0.01)
    close(pfw)
    close(pfw)
    pfw = PollingFileWatcher(@__FILE__, 0.01)
    close(pfw)
    close(pfw)
    pfw = PollingFileWatcher(@__FILE__, 0.01)
    close(pfw)
    close(pfw)
    GC.gc()
    GC.gc()
end

test_12992()
test_12992()
test_12992()

test2_12992()
test2_12992()
test2_12992()

#######################################################################
# This section tests file watchers.                                   #
#######################################################################
F_GETPATH = Sys.islinux() || Sys.iswindows() || Sys.isapple() # platforms where F_GETPATH is available
F_PATH = F_GETPATH ? "afile.txt" : ""
dir = mktempdir()
file = joinpath(dir, "afile.txt")

# initialize a watch_folder instance and create afile.txt
function test_init_afile()
    watched_folders = FileWatching.watched_folders
    @test @lock watched_folders isempty(watched_folders[])
    @test(watch_folder(dir, 0) == ("" => FileWatching.FileEvent()))
    @test @elapsed(@test(watch_folder(dir, 0) == ("" => FileWatching.FileEvent()))) <= 0.5
    @test @lock(watched_folders, length(FileWatching.watched_folders[])) == 1
    @test unwatch_folder(dir) === nothing
    @test @lock watched_folders isempty(watched_folders[])
    @test 0.002 <= @elapsed(@test(watch_folder(dir, 0.004) == ("" => FileWatching.FileEvent())))
    @test 0.002 <= @elapsed(@test(watch_folder(dir, 0.004) == ("" => FileWatching.FileEvent()))) <= 0.5
    @test unwatch_folder(dir) === nothing
    @test 0.99 <= @elapsed(@test(watch_folder(dir, 1) == ("" => FileWatching.FileEvent())))
    @test 0.99 <= @elapsed(@test(watch_folder(dir, 1) == ("" => FileWatching.FileEvent())))
    # like touch, but lets the operating system update the timestamp
    # for greater precision on some platforms (windows)
    @test close(open(file, "w")) === nothing
    @test(watch_folder(dir) == (F_PATH => FileWatching.FileEvent(FileWatching.UV_RENAME)))
    @test close(open(file, "w")) === nothing
    sleep(3)
    if !Sys.isapple()
        let c
            c = watch_folder(dir, 0)

            if F_GETPATH
                @test c.first == F_PATH
                @test c.second.changed ⊻ c.second.renamed
                @test !c.second.timedout
            else # we don't expect to be able to detect file changes in this case
                @test c.first == ""
                @test !c.second.changed && !c.second.renamed
                @test c.second.timedout
            end
        end
    end
    @test unwatch_folder(dir) === nothing
    @test(watch_folder(dir, 0) == ("" => FileWatching.FileEvent()))
    @test 0.9 <= @elapsed(@test(watch_folder(dir, 1) == ("" => FileWatching.FileEvent())))
    @test @lock(watched_folders, length(FileWatching.watched_folders[])) == 1
    nothing
end

function test_file_poll(channel, interval, timeout_s)
    rc = poll_file(file, interval, timeout_s)
    put!(channel, rc)
end

function test_timeout(tval)
    t_elapsed = @elapsed begin
        channel = Channel(1)
        @async test_file_poll(channel, 10, tval)
        tr = take!(channel)
    end
    @test ispath(tr[1]::StatStruct) && tr[2] === EOFError()
    @test tval <= t_elapsed
end

function test_touch(slval)
    tval = slval*1.1
    channel = Channel(1)
    t = @async test_file_poll(channel, tval/3, tval)
    sleep(tval/3)  # one poll period
    f = open(file, "a")
    write(f, "Hello World\n")
    close(f)
    tr = take!(channel)
    @test ispath(tr[1]::StatStruct) && ispath(tr[2]::StatStruct)
    fetch(t)
end

function test_watch_file_timeout(tval)
    watch = @async watch_file(file, tval)
    @test fetch(watch) == FileWatching.FileEvent(false, false, true)
end

function test_watch_file_change(tval)
    watch = @async watch_file(file, tval)
    sleep(tval/3)
    open(file, "a") do f
        write(f, "small change\n")
    end
    @test fetch(watch) == FileWatching.FileEvent(false, true, false)
end

function test_monitor_wait(tval)
    fm = FileMonitor(file)
    @sync begin
        @async begin
            sleep(tval)
            f = open(file, "a")
            write(f, "Hello World\n")
            close(f)
        end
        events = wait(fm)
        close(fm)
        @test events.changed && !events.timedout && !events.renamed
    end
end

function test_dirmonitor_wait(tval)
    fm = FolderMonitor(file)
    @sync begin
        @async begin
            sleep(tval)
            for i = 1:3
                f = open(file, "w")
                write(f, "Hello World\n")
                close(f)
            end
        end
        fname, events = wait(fm)::Pair
        @test fname == basename(file)
        @test events.changed && !events.timedout && !events.renamed
        close(fm)
    end
end

function test_dirmonitor_wait2(tval)
    fm = FolderMonitor(dir)
    @sync begin
        @async begin
            sleep(tval)
            for i = 1:3
                f = open("$file$i", "w")
                write(f, "Hello World $i\n")
                close(f)
            end
        end
        if F_GETPATH
            let (fname, events) = wait(fm)::Pair
                for i = 1:3
                    @test fname == "$F_PATH$i"
                    @test (!events.changed && events.renamed) && !events.timedout
                    i == 3 && break
                    fname, events = wait(fm)
                    if fname == "$F_PATH$i"
                        @test (events.changed ⊻ events.renamed) && !events.timedout
                        fname, events = wait(fm)
                    end
                end
            end
        else
            let (fname, events) = wait(fm)::Pair
                @test fname == ""
                @test (!events.changed && events.renamed) && !events.timedout
            end
            close(fm)
            fm = FolderMonitor(dir)
        end
    end
    @sync begin
        @async begin
            for i = 1:3
                sleep(tval)
                rm("$file$i")
            end
        end
        if F_GETPATH
            let (fname, events) = wait(fm)::Pair
                if fname == "$(F_PATH)3" # actually one more event from above
                    @test events.changed && !events.timedout && !events.renamed
                    fname, events = wait(fm)
                end
                for i = 1:3
                    @testset let (fname, events) = (fname, events)
                        @test fname == "$F_PATH$i"
                        @test !events.changed && !events.timedout && events.renamed
                    end
                    i == 3 && break
                    fname, events = wait(fm)
                end
            end
        else
            let (fname, events) = wait(fm)::Pair
                @test fname == ""
                @test !events.changed && !events.timedout && events.renamed
            end
        end
    end
    close(fm)
end

function test_monitor_wait_poll()
    pfw = PollingFileWatcher(file, 5.007)
    @sync begin
        @async begin
            sleep(2.5)
            f = open(file, "a")
            write(f, "Hello World\n")
            close(f)
        end
        (old, new) = wait(pfw)
        close(pfw)
        @test new.mtime - old.mtime > 2.5 - 1.5 # mtime may only have second-level accuracy (plus add some hysteresis)
    end
end

test_init_afile()
test_timeout(0.1)
test_timeout(1)
test_touch(6)
test_monitor_wait(0.2)
test_monitor_wait(0.2)
test_dirmonitor_wait(0.2)
test_dirmonitor_wait(0.2)
test_monitor_wait_poll()
test_monitor_wait_poll()
test_watch_file_timeout(0.2)
test_watch_file_change(6)

if !Sys.isapple()
    test_dirmonitor_wait2(0.2)
    test_dirmonitor_wait2(0.2)

    mv(file, file * "~")
    mv(file * "~", file)
    let changes = []
        while true
            let c
                Sys.iswindows() && sleep(0.1)
                @test @elapsed(c = watch_folder(dir, 0.0)) < 0.5
                push!(changes, c)
                (c.second::FileWatching.FileEvent).timedout && break
            end
        end
        if F_GETPATH
            @test 12 < length(changes) < 48
        else
            @test 5 < length(changes) < 16
        end
        @test pop!(changes) == ("" => FileWatching.FileEvent())
        if F_GETPATH
            Sys.iswindows() && @test pop!(changes) == (F_PATH => FileWatching.FileEvent(FileWatching.UV_CHANGE))
            p = pop!(changes)
            if !Sys.isapple()
                @test p == (F_PATH => FileWatching.FileEvent(FileWatching.UV_RENAME))
            end
            while changes[end][1] == F_PATH
                @test pop!(changes)[2] == FileWatching.FileEvent(FileWatching.UV_RENAME)
            end
            p = pop!(changes)
            if !Sys.isapple()
                @test p == (F_PATH * "~" => FileWatching.FileEvent(FileWatching.UV_RENAME))
            end
            while changes[end][1] == F_PATH * "~"
                @test pop!(changes)[2] == FileWatching.FileEvent(FileWatching.UV_RENAME)
            end
            if changes[end][1] == F_PATH
                @test pop!(changes)[2] == FileWatching.FileEvent(FileWatching.UV_RENAME)
            end
            for j = 1:4
                for i = 3:-1:1
                    while changes[end - 1][1] == "$F_PATH$i"
                        @test let x = pop!(changes)[2]; x.changed ⊻ x.renamed; end
                    end
                    p = pop!(changes)
                    if !Sys.isapple()
                        @test p == ("$F_PATH$i" => FileWatching.FileEvent(FileWatching.UV_RENAME))
                    end
                end
            end
        end
        @test all(x -> (isa(x, Pair) && x[1] == F_PATH && (x[2].changed ⊻ x[2].renamed)), changes) context=changes
    end
end
@test_throws(Base._UVError("FileMonitor (start)", Base.UV_ENOENT),
             watch_file("____nonexistent_file", 10))
@test_throws(Base._UVError("FolderMonitor (start)", Base.UV_ENOENT),
             watch_folder("____nonexistent_file", 10))
@test(@elapsed(
    @test(poll_file("____nonexistent_file", 1, 3.1) ==
          (StatStruct(), EOFError()))) > 3)

unwatch_folder(dir)
@test @lock FileWatching.watched_folders isempty(FileWatching.watched_folders[])
rm(file)
rm(dir)

# Test that creating a FDWatcher with a (probably) negative FD fails
@test_throws ArgumentError FDWatcher(RawFD(-1), true, true)

@testset "Pidfile" begin
    include("pidfile.jl")
end

@test isempty(Docs.undocumented_names(FileWatching))

end # testset
