/// Route handlers for Giraffe endpoints
module JobsJobsJobs.Api.Handlers

open Giraffe
open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging

/// Handler to return the files required for the Vue client app
module Vue =

    /// Handler that returns index.html (the Vue client app)
    let app = htmlFile "wwwroot/index.html"


/// Handlers for error conditions
module Error =
  
    open System.Threading.Tasks

    /// URL prefixes for the Vue app
    let vueUrls =
        [ "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
          "/so-long"; "/success-story"
        ]

    /// Handler that will return a status code 404 and the text "Not Found"
    let notFound : HttpHandler = fun next ctx -> task {
        let fac  = ctx.GetService<ILoggerFactory> ()
        let log  = fac.CreateLogger "Handler"
        let path = string ctx.Request.Path
        match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
        | true when path = "/" || vueUrls |> List.exists path.StartsWith ->
            log.LogInformation "Returning Vue app"
            return! Vue.app next ctx
        | _ ->
            log.LogInformation "Returning 404"
            return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
    }
  
    /// Handler that returns a 403 NOT AUTHORIZED response
    let notAuthorized : HttpHandler =
        setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None

    /// Handler to log 500s and return a message we can display in the application
    let unexpectedError (ex: exn) (log : ILogger) =
        log.LogError(ex, "An unexpected error occurred")
        clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
  

open NodaTime

/// Helper functions
[<AutoOpen>]
module Helpers =

    open System.Security.Claims
    open Microsoft.Extensions.Configuration
    open Microsoft.Extensions.Options

    /// Get the NodaTime clock from the request context
    let now (ctx : HttpContext) = ctx.GetService<IClock>().GetCurrentInstant ()

    /// Get the application configuration from the request context
    let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()

    /// Get the authorization configuration from the request context
    let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value

    /// Get the logger factory from the request context
    let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()

    /// `None` if a `string option` is `None`, whitespace, or empty
    let noneIfBlank (s : string option) =
      s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten
    
    /// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string
    let noneIfEmpty = Option.ofObj >> noneIfBlank
    
    /// Try to get the current user
    let tryUser (ctx : HttpContext) =
        ctx.User.FindFirst ClaimTypes.NameIdentifier
        |> Option.ofObj
        |> Option.map (fun x -> x.Value)

    /// Require a user to be logged in
    let authorize : HttpHandler =
        fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx

    /// Get the ID of the currently logged in citizen
    //  NOTE: if no one is logged in, this will raise an exception
    let currentCitizenId = tryUser >> Option.get >> CitizenId.ofString

    /// Return an empty OK response
    let ok : HttpHandler = Successful.OK ""


open System
open JobsJobsJobs.Data

/// Handlers for /api/citizen routes
[<RequireQualifiedAccess>]
module Citizen =
    
    // POST: /api/citizen/register
    let register : HttpHandler = fun next ctx -> task {
        let! form = ctx.BindJsonAsync<CitizenRegistrationForm> ()
        if form.Password.Length < 8 || form.ConfirmPassword.Length < 8 || form.Password <> form.ConfirmPassword then
            return! RequestErrors.BAD_REQUEST "Password out of range" next ctx
        else
            let now    = now ctx
            let noPass =
                { Citizen.empty with
                    Id          = CitizenId.create ()
                    Email       = form.Email
                    FirstName   = form.FirstName
                    LastName    = form.LastName
                    DisplayName = noneIfBlank form.DisplayName
                    JoinedOn    = now
                    LastSeenOn  = now
                }
            let citizen = { noPass with PasswordHash = Auth.Passwords.hash noPass form.Password }
            let security =
                { SecurityInfo.empty with
                    Id            = citizen.Id
                    AccountLocked = true
                    Token         = Some (Auth.createToken citizen)
                    TokenUsage    = Some "confirm"
                    TokenExpires  = Some (now + (Duration.FromDays 3))
                }
            let! success = Citizens.register citizen security
            if success then
                let! emailResponse = Email.sendAccountConfirmation citizen security
                let logFac = logger ctx
                let log    = logFac.CreateLogger "JobsJobsJobs.Api.Handlers.Citizen"
                log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}"
                return! ok next ctx
            else
                return! RequestErrors.CONFLICT "" next ctx
    }
    
    // PATCH: /api/citizen/confirm
    let confirmToken : HttpHandler = fun next ctx -> task {
        let! form = ctx.BindJsonAsync<{| token : string |}> ()
        let! valid = Citizens.confirmAccount form.token
        return! json {| Valid = valid |} next ctx
    }
    
    // DELETE: /api/citizen/deny
    let denyToken : HttpHandler = fun next ctx -> task {
        let! form = ctx.BindJsonAsync<{| token : string |}> ()
        let! valid = Citizens.denyAccount form.token
        return! json {| Valid = valid |} next ctx
    }
    
    // POST: /api/citizen/log-on
    let logOn : HttpHandler = fun next ctx -> task {
        let! form = ctx.BindJsonAsync<LogOnForm> ()
        match! Citizens.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with
        | Ok citizen ->
            return!
                json
                    { Jwt       = Auth.createJwt citizen (authConfig ctx)
                      CitizenId = CitizenId.toString citizen.Id
                      Name      = Citizen.name citizen
                    } next ctx
        | Error msg -> return! RequestErrors.BAD_REQUEST msg next ctx
    }
    
    // GET: /api/citizen/[id]
    let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
        match! Citizens.findById (CitizenId citizenId) with
        | Some citizen -> return! json { citizen with PasswordHash = "" } next ctx
        | None -> return! Error.notFound next ctx
    }
    
    // PATCH: /api/citizen/account
    let account : HttpHandler = authorize >=> fun next ctx -> task {
        let! form = ctx.BindJsonAsync<AccountProfileForm> ()
        match! Citizens.findById (currentCitizenId ctx) with
        | Some citizen ->
            let password =
                if defaultArg form.NewPassword "" = "" then citizen.PasswordHash
                else Auth.Passwords.hash citizen form.NewPassword.Value
            do! Citizens.save
                    { citizen with
                        FirstName     = form.FirstName
                        LastName      = form.LastName
                        DisplayName   = noneIfBlank form.DisplayName
                        PasswordHash  = password
                        OtherContacts = form.Contacts
                                        |> List.map (fun c -> {
                                            Id          = if c.Id.StartsWith "new" then OtherContactId.create ()
                                                          else OtherContactId.ofString c.Id
                                            ContactType = ContactType.parse c.ContactType
                                            Name        = noneIfBlank c.Name
                                            Value       = c.Value
                                            IsPublic    = c.IsPublic
                                        })
                         }
            return! ok next ctx
        | None -> return! Error.notFound next ctx
    }
    
    // DELETE: /api/citizen
    let delete : HttpHandler = authorize >=> fun next ctx -> task {
        do! Citizens.deleteById (currentCitizenId ctx)
        return! ok next ctx
    }


/// Handlers for /api/continent routes
[<RequireQualifiedAccess>]
module Continent =
  
    // GET: /api/continent/all
    let all : HttpHandler = fun next ctx -> task {
        let! continents = Continents.all ()
        return! json continents next ctx
    }


/// Handlers for /api/listing[s] routes
[<RequireQualifiedAccess>]
module Listing =

    /// Parse the string we receive from JSON into a NodaTime local date
    let private parseDate = DateTime.Parse >> LocalDate.FromDateTime

    // GET: /api/listings/mine
    let mine : HttpHandler = authorize >=> fun next ctx -> task {
        let! listings = Listings.findByCitizen (currentCitizenId ctx)
        return! json listings next ctx
    }

    // GET: /api/listing/[id]
    let get listingId : HttpHandler = authorize >=> fun next ctx -> task {
        match! Listings.findById (ListingId listingId) with
        | Some listing -> return! json listing next ctx
        | None -> return! Error.notFound next ctx
    }
  
    // GET: /api/listing/view/[id]
    let view listingId : HttpHandler = authorize >=> fun next ctx -> task {
        match! Listings.findByIdForView (ListingId listingId) with
        | Some listing -> return! json listing next ctx
        | None -> return! Error.notFound next ctx
    }

    // POST: /listings
    let add : HttpHandler = authorize >=> fun next ctx -> task {
        let! form = ctx.BindJsonAsync<ListingForm> ()
        let  now  = now ctx
        do! Listings.save {
            Id            = ListingId.create ()
            CitizenId     = currentCitizenId ctx
            CreatedOn     = now
            Title         = form.Title
            ContinentId   = ContinentId.ofString form.ContinentId
            Region        = form.Region
            IsRemote      = form.RemoteWork
            IsExpired     = false
            UpdatedOn     = now
            Text          = Text form.Text
            NeededBy      = (form.NeededBy |> Option.map parseDate)
            WasFilledHere = None
            IsLegacy      = false
        }
        return! ok next ctx
    }

    // PUT: /api/listing/[id]
    let update listingId : HttpHandler = authorize >=> fun next ctx -> task {
        match! Listings.findById (ListingId listingId) with
        | Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
        | Some listing ->
            let! form = ctx.BindJsonAsync<ListingForm> ()
            do! Listings.save
                    { listing with
                        Title       = form.Title
                        ContinentId = ContinentId.ofString form.ContinentId
                        Region      = form.Region
                        IsRemote    = form.RemoteWork
                        Text        = Text form.Text
                        NeededBy    = form.NeededBy |> Option.map parseDate
                        UpdatedOn   = now ctx
                    }
            return! ok next ctx
        | None -> return! Error.notFound next ctx
      }
  
    // PATCH: /api/listing/[id]
    let expire listingId : HttpHandler = authorize >=> fun next ctx -> task {
        let now = now ctx
        match! Listings.findById (ListingId listingId) with
        | Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx
        | Some listing ->
            let! form = ctx.BindJsonAsync<ListingExpireForm> ()
            do! Listings.save
                    { listing with
                        IsExpired     = true
                        WasFilledHere = Some form.FromHere
                        UpdatedOn     = now
                    }
            match form.SuccessStory with
            | Some storyText ->
                do! Successes.save
                        { Id         = SuccessId.create()
                          CitizenId  = currentCitizenId ctx
                          RecordedOn = now
                          IsFromHere = form.FromHere
                          Source     = "listing"
                          Story      = (Text >> Some) storyText
                          }
            | None -> ()
            return! ok next ctx
        | None -> return! Error.notFound next ctx
    }

    // GET: /api/listing/search
    let search : HttpHandler = authorize >=> fun next ctx -> task {
        let  search  = ctx.BindQueryString<ListingSearch> ()
        let! results = Listings.search search
        return! json results next ctx
    }
  

/// Handlers for /api/profile routes
[<RequireQualifiedAccess>]
module Profile =

    // GET: /api/profile
    //      This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet
    //      is not an error). The "get" handler returns a 404 if a profile is not found.
    let current : HttpHandler = authorize >=> fun next ctx -> task {
        match! Profiles.findById (currentCitizenId ctx) with
        | Some profile -> return! json profile next ctx
        | None -> return! Successful.NO_CONTENT next ctx
    }
  
    // GET: /api/profile/get/[id]
    let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
        match! Profiles.findById (CitizenId citizenId) with
        | Some profile -> return! json profile next ctx
        | None -> return! Error.notFound next ctx
    }

    // GET: /api/profile/view/[id]
    let view citizenId : HttpHandler = authorize >=> fun next ctx -> task {
        match! Profiles.findByIdForView (CitizenId citizenId) with
        | Some profile -> return! json profile next ctx
        | None -> return! Error.notFound next ctx
    }
  
    // GET: /api/profile/count
    let count : HttpHandler = authorize >=> fun next ctx -> task {
        let! theCount = Profiles.count ()
        return! json {| Count = theCount |} next ctx
    }
  
    // POST: /api/profile/save
    let save : HttpHandler = authorize >=> fun next ctx -> task {
        let  citizenId = currentCitizenId ctx
        let! form      = ctx.BindJsonAsync<ProfileForm>()
        let! profile   = task {
            match! Profiles.findById citizenId with
            | Some p -> return p
            | None -> return { Profile.empty with Id = citizenId }
        }
        do! Profiles.save
                { profile with
                    IsSeekingEmployment  = form.IsSeekingEmployment
                    IsPubliclySearchable = form.IsPublic
                    ContinentId          = ContinentId.ofString form.ContinentId
                    Region               = form.Region
                    IsRemote             = form.RemoteWork
                    IsFullTime           = form.FullTime
                    Biography            = Text form.Biography
                    LastUpdatedOn        = now ctx
                    Experience           = noneIfBlank form.Experience |> Option.map Text
                    Skills               = form.Skills
                                           |> List.map (fun s ->
                                               {   Id          = if s.Id.StartsWith "new" then SkillId.create ()
                                                                 else SkillId.ofString s.Id
                                                   Description = s.Description
                                                   Notes       = noneIfBlank s.Notes
                                               })
                }
        return! ok next ctx
    }
  
    // PATCH: /api/profile/employment-found
    let employmentFound : HttpHandler = authorize >=> fun next ctx -> task {
        match! Profiles.findById (currentCitizenId ctx) with
        | Some profile ->
            do! Profiles.save { profile with IsSeekingEmployment = false }
            return! ok next ctx
        | None -> return! Error.notFound next ctx
    }
  
    // DELETE: /api/profile
    let delete : HttpHandler = authorize >=> fun next ctx -> task {
        do! Profiles.deleteById (currentCitizenId ctx)
        return! ok next ctx
    }
  
    // GET: /api/profile/search
    let search : HttpHandler = authorize >=> fun next ctx -> task {
        let  search  = ctx.BindQueryString<ProfileSearch> ()
        let! results = Profiles.search search
        return! json results next ctx
    }
  
    // GET: /api/profile/public-search
    let publicSearch : HttpHandler = fun next ctx -> task {
        let  search  = ctx.BindQueryString<PublicSearch> ()
        let! results = Profiles.publicSearch search
        return! json results next ctx
    }


/// Handlers for /api/success routes
[<RequireQualifiedAccess>]
module Success =

    // GET: /api/success/[id]
    let get successId : HttpHandler = authorize >=> fun next ctx -> task {
        match! Successes.findById (SuccessId successId) with
        | Some story -> return! json story next ctx
        | None -> return! Error.notFound next ctx
    }

    // GET: /api/success/list
    let all : HttpHandler = authorize >=> fun next ctx -> task {
        let! stories = Successes.all ()
        return! json stories next ctx
    }
  
    // POST: /api/success/save
    let save : HttpHandler = authorize >=> fun next ctx -> task {
        let  citizenId = currentCitizenId ctx
        let! form      = ctx.BindJsonAsync<StoryForm> ()
        let! success = task {
            match form.Id with
            | "new" ->
                return Some { Id         = SuccessId.create ()
                              CitizenId  = citizenId
                              RecordedOn = now ctx
                              IsFromHere = form.FromHere
                              Source     = "profile"
                              Story      = noneIfEmpty form.Story |> Option.map Text
                              }
            | successId ->
                match! Successes.findById (SuccessId.ofString successId) with
                | Some story when story.CitizenId = citizenId ->
                    return Some { story with
                                    IsFromHere = form.FromHere
                                    Story    = noneIfEmpty form.Story |> Option.map Text
                                }
                | Some _ | None -> return None
        }
        match success with
        | Some story ->
            do! Successes.save story
            return! ok next ctx
        | None -> return! Error.notFound next ctx
    }


open Giraffe.EndpointRouting

/// All available endpoints for the application
let allEndpoints = [
    subRoute "/api" [
        subRoute "/citizen" [
            GET_HEAD [ routef "/%O" Citizen.get ]
            PATCH [
                route "/account" Citizen.account
                route "/confirm" Citizen.confirmToken
            ]
            POST [
                route "/log-on"   Citizen.logOn
                route "/register" Citizen.register
            ]
            DELETE [
                route ""      Citizen.delete
                route "/deny" Citizen.denyToken
            ]
        ]
        GET_HEAD [ route "/continents" Continent.all ]
        subRoute "/listing" [
            GET_HEAD [
                routef "/%O"      Listing.get
                route  "/search"  Listing.search
                routef "/%O/view" Listing.view
                route  "s/mine"   Listing.mine
            ]
            PATCH [ routef "/%O" Listing.expire ]
            POST [ route "s" Listing.add ]
            PUT [ routef "/%O" Listing.update ]
        ]
        subRoute "/profile" [
            GET_HEAD [
                route  ""               Profile.current
                route  "/count"         Profile.count
                routef "/%O"            Profile.get
                routef "/%O/view"       Profile.view
                route  "/public-search" Profile.publicSearch
                route  "/search"        Profile.search
            ]
            PATCH [ route "/employment-found" Profile.employmentFound ]
            POST [ route "" Profile.save ]
        ]
        subRoute "/success" [
            GET_HEAD [
                routef "/%O" Success.get
                route  "es"  Success.all
            ]
            POST [ route "" Success.save ]
        ]
    ]
]