export Client,
    me,
    enable_cache!,
    disable_cache!,
    start,
    start!,
    init!,
    add_handler!

include("limiter.jl")
include("state.jl")

# Messages are created regularly, and lose relevance quickly.
const DEFAULT_STRATEGIES = Dict{DataType, CacheStrategy}(
    Guild          => CacheForever(),
    DiscordChannel => CacheForever(),
    User           => CacheForever(),
    Member         => CacheForever(),
    Presence       => CacheForever(),
    Message        => CacheTTL(Hour(6)),
)

# A versioned WebSocket connection.
mutable struct Conn
    io
    v::Int
end

"""
    Client(
        token::String
        application_id::Snowflake
        intents::Int;
        presence::Union{Dict, NamedTuple}=Dict(),
        strategies::Dict{DataType, <:CacheStrategy}=Dict(),
        version::Int=$API_VERSION,
    ) -> Client

A Discord bot. `Client`s can connect to the gateway, respond to events, and make REST API
calls to perform actions such as sending/deleting messages, kicking/banning users, etc.

### Bot Token
A bot token can be acquired by creating a new application
[here](https://discordapp.com/developers/applications). Make sure not to hardcode the token
into your Julia code! Use an environment variable or configuration file instead.

### Application ID
The application id for your bot can be found [here](https://discordapp.com/developers/applications). 
Make sure not to hardcode the application id into your Julia code! 
Use an environment variable or configuration file instead.

### Intents 
Integer representing intents. More information [here](https://discord.com/developers/docs/topics/gateway#gateway-intents).

### Presence
The `presence` keyword sets the bot's presence upon connection. It also sets defaults
for future calls to [`set_game`](@ref). The schema
[here](https://discordapp.com/developers/docs/topics/gateway#update-status-gateway-status-update-structure)
must be followed.

### Cache Control
By default, most data that comes from Discord is cached for later use. However, to avoid
memory leakage, not all of it is kept forever. The default setings are to keep everything
but [`Message`](@ref)s, which are deleted after 6 hours, forever. Although the default
settings are sufficient for most workloads, you can specify your own strategies per type
with the `strategies` keyword. Keys can be any of the following:

- [`Guild`](@ref)
- [`DiscordChannel`](@ref)
- [`Message`](@ref)
- [`User`](@ref)
- [`Member`](@ref)
- [`Presence`](@ref)

For potential values, see [`CacheStrategy`](@ref).

The cache can also be disabled/enabled permanently and temporarily as a whole with
[`enable_cache!`](@ref) and [`disable_cache!`](@ref).

### API Version
The `version` keyword chooses the Version of the Discord API to use. Using anything but
`$API_VERSION` is not officially supported by the Ekztazy.jl developers.

### Sharding
Sharding is handled automatically. The number of available processes is the number of
shards that are created. See the
[sharding example](https://github.com/Xh4H/Discord.jl/blob/master/examples/sharding.jl)
for more details.
"""
mutable struct Client
    token::AbstractString                                        # Bot token
    application_id::Snowflake                                    # Application ID for Interactions
    commands::Vector{ApplicationCommand}                         # Commands
    guild_commands::Dict{Snowflake, Vector{ApplicationCommand}}  # Guild commands
    intents::Int                                                 # Intents value
    handlers::Dict{Symbol, Vector{Handler}}                      # Handlers for each event
    hb_interval::Int                                             # Milliseconds between heartbeats.
    hb_seq::Nullable{Int}                                        # Sequence value sent by Discord for resuming.
    last_hb::DateTime                                            # Last heartbeat send.
    last_ack::DateTime                                           # Last heartbeat ack.
    version::Int                                                 # Discord API version.
    state::State                                                 # Client state, cached data, etc.
    shards::Int                                                  # Number of shards in use.
    shard::Int                                                   # Client's shard index.
    limiter::Limiter                                             # Rate limiter.
    ready::Bool                                                  # Client is connected and authenticated.
    use_cache::Bool                                              # Whether or not to use the cache.
    presence::Dict                                               # Default presence options.
    conn::Conn                                                   # WebSocket connection.
    p_guilds::Dict{Snowflake, String}                            # Command prefix overrides.
    no_auto_ack::Vector{String}                                  # No auto ack custom ids.
    auto_update_ack::Vector{String}                              # Auto ack for deferred message update custom ids.

    function Client(
        token::String,
        application_id::Snowflake,
        intents::Int;
        # kwargs
        presence::Union{Dict, NamedTuple}=Dict(),
        strategies::Dict{DataType, <:CacheStrategy}=Dict{DataType, CacheStrategy}(),
        version::Int=API_VERSION,
    )
        token = startswith(token, "Bot ") ? token : "Bot $token"
        state = State(merge(DEFAULT_STRATEGIES, strategies))
        app_id = application_id
        conn = Conn(nothing, 0)
        presence = merge(Dict(
            "since" => nothing,
            "game" => nothing,
            "status" => "online", # Should make a const out of statuses
            "afk" => false,
        ), Dict(string(k) => v for (k, v) in Dict(pairs(presence))))
        
        c = new(
            token,        # token
            app_id,       # application_id
            Vector(),     # Commands
            Dict(),       # Guild commands
            intents,      # intents
            Dict(),       # handlers
            0,            # hb_interval
            nothing,      # hb_seq
            DateTime(0),  # last_hb
            DateTime(0),  # last_ack
            version,      # version
            state,        # state
            nprocs(),     # shards
            myid() - 1,   # shard
            Limiter(),    # limiter
            false,        # ready
            true,         # use_cache
            presence,     # presence
            conn,         # conn
            Dict(),       # p_guilds
            [],           # no_auto_ack
            []            # auto_update_ack
        )

        return c
    end
end

Client(token::String, appid::Int, intents::Int, args...) = Client(token, UInt(appid), intents, args...)
Client(in::Int) = Client(
    ENV["DISCORD_TOKEN"], 
    ENV["APPLICATION_ID"] isa Number ? ENV["APPLICATION_ID"] : parse(UInt, ENV["APPLICATION_ID"]),
    in
)
Client() = Client(intents(GUILDS, GUILD_MESSAGES))

add!(d::Dict{U, Union{Vector{T}}}, k::U, v::T) where T where U = haskey(d, k) ? push!(d[k], v) : d[k] = [v]

"""
    add_handler!(c::Client, handler::Handler)

Adds a handler to the client.
"""
add_handler!(c::Client, handler::Handler) = add!(c.handlers, handlerkind(handler), handler)
"""
    add_handler!(c::Client, handler::Handler)

Bulk adds handlers to the client.
"""
add_handler!(c::Client, args...) = map(h -> add_handler!(c, h), args)

add_command!(c::Client; kwargs...) = add_command!(c, ApplicationCommand(; application_id=c.application_id, kwargs...))
add_command!(c::Client, cmd::ApplicationCommand) = push!(c.commands, cmd)
add_command!(c::Client, args...) = map(cmd -> add_command!(c, cmd), args)
add_command!(c::Client, g::Snowflake; kwargs...) = add_command!(c, g, ApplicationCommand(; application_id=c.application_id, kwargs...))
add_command!(c::Client, g::Snowflake, cmd::ApplicationCommand) = add!(c.guild_commands, g, cmd)
add_command!(c::Client, g::Snowflake, args...) = map(cmd -> add_command!(c, g, cmd), args)

mock(::Type{Client}; kwargs...) = Client("token")

function Base.show(io::IO, c::Client)
    print(io, "Discord.Client(shard=$(c.shard + 1)/$(c.shards), api=$(c.version), ")
    isopen(c) || print(io, "not ")
    print(io, "logged in)")
end

"""
    me(c::Client) -> Nullable{User}

Get the [`Client`](@ref)'s bot user.
"""
me(c::Client) = c.state.user

"""
    enable_cache!(c::Client)
    enable_cache!(f::Function c::Client)

Enable the cache. `do` syntax is also accepted.
"""
enable_cache!(c::Client) = c.use_cache = true
enable_cache!(f::Function, c::Client) = set_cache(f, c, true)

"""
    disable_cache!(c::Client)
    disable_cache!(f::Function, c::Client)

Disable the cache. `do` syntax is also accepted.
"""
disable_cache!(c::Client) = c.use_cache = false
disable_cache!(f::Function, c::Client) = set_cache(f, c, false)

# Compute some contextual keywords for log messages.
function logkws(c::Client; kwargs...)
    kws = Pair[:time => now()]
    c.shards > 1 && push!(kws, :shard => c.shard)
    c.conn.io === nothing || push!(kws, :conn => c.conn.v)

    for kw in kwargs
        if kw.second === undef  # Delete any keys overridden with undef.
            filter!(p -> p.first !== kw.first, kws)
        else
            # Replace any overridden keys.
            idx = findfirst(p -> p.first === kw.first, kws)
            if idx === nothing
                push!(kws, kw)
            else
                kws[idx] = kw
            end
        end
    end

    return kws
end

# Parse some data (usually a Dict from JSON), or return the thrown error.
function Base.tryparse(c::Client, T::Type, data)
    return try
        T <: Vector ? eltype(T).(data) : T(data), nothing
    catch e
        kws = logkws(c; T=T, exception=(e, catch_backtrace()))
        @error "Parsing failed" kws...
        nothing, e
    end
end

# Run some function with the cache enabled or disabled.
function set_cache(f::Function, c::Client, use_cache::Bool)
    old = c.use_cache
    c.use_cache = use_cache
    try
        f()
    finally
        # Usually the above function is going to be calling REST endpoints. The cache flag
        # is checked asynchronously, so by the time it happens there's a good chance we've
        # already returned and set the cache flag back to its original value.
        sleep(Millisecond(1))
        c.use_cache = old
    end
end

"""
    start!(c::Client)

Calls [`init`](@ref) on the client
Calls `open` then `wait` on the [`Client`](@ref).
"""
function start!(c::Client, duration=Minute(0))
    open(c)
    duration != Second(0) && auto_shutdown(c, duration, "SHUTDOWN")
    wait(c)
end


"""
    auto_shutdown(run_duration::TimePeriod)

Run a background process to track the program's run time and exit
the program when it has exceeded the specified `run_duration` or
when a file exists at `trigger_path`.
"""
function auto_shutdown(c::Client, run_duration::TimePeriod, trigger_path::AbstractString="SHUTDOWN", s=5)
    start_time = now()
    @async while true
        if now() > start_time + run_duration
            @info "Times up! The bot is shutting down automatically."
            shutdown_gracefully(c)
            break
        end
        if length(trigger_path) > 0 && isfile(trigger_path)
            @info "The bot is shutting down via trigger path `$trigger_path`."
            rm(trigger_path)
            shutdown_gracefully(c)
            break
        end
        sleep(s)
    end
end

function shutdown_gracefully(c::Client)
    try
        close(c)
    catch ex
        @warn "Unable to close client connection" ex
    end
end

"""
    init!(c::Client)

Creates a handler to generate [`ApplicationCommand`](@ref)s.\n
Creates handlers for GuildCreate events.\n 
"""
function init!(c::Client)
    hcreate = OnGuildCreate() do (ctx)
        put!(c.state, ctx.guild)
    end
    hupdate = OnGuildUpdate() do (ctx)
        put!(c.state, ctx.guild)
    end
    hready = OnReady() do (ctx)
        c.state.user = ctx.user
        for (g, cmds) in c.guild_commands
            create(c, Vector{ApplicationCommand}, g, cmds)
        end
        create(c, Vector{ApplicationCommand}, c.commands)
    end
    add_handler!(c, hcreate, hupdate, hready)
end

const start = start! # backwards compat
