sourceafHcaptcha::CaptchaClient.fan

using dom::Win
using concurrent::AtomicRef

@Js class CaptchaClient {
    private static const AtomicRef responseStubRef := AtomicRef()

    private Bool    enabled
    private Str     siteKey
    private |->|[]  messageQueue    := |->|[,]
    private Str:Str widgetIds       := Str:Str[:]
    private Str:Str widgetResponses := Str:Str[:]

    new make(Str siteKey, Bool enabled) {
        this.enabled = enabled
        this.siteKey = siteKey
        if (enabled) iAmHere
    }
    
    ** theme, type, size, tabindex
    ** callback, expired-callback, error-callback not supported
    ** 
    ** No need to add sitekey
    ** 
    ** Themes: normal =  304px x78px
    **         compact = 164px x 144px
    ** 
    ** What's returned is NOT the hCaptcha widget id
    ** 
    ** See [hCaptcha Container Configuration]`https://docs.hcaptcha.com/configuration/#hcaptcha-container-configuration` for details.
    Str render(Str containerId, [Str:Obj?]? params := null) {
        if (!enabled)
            return "<hcaptcha-not-enabled>"
        myId := genId
        call |->| {
            gooId := doRender(containerId, ["sitekey" : siteKey].setAll(params ?: [:]))
            widgetIds[myId] = gooId
        }
        return myId
    }
    
    Void reset(Str widgetId) {
        responseStubRef.val = null
        if (enabled)
            call |->| { doReset(widgetIds[widgetId]) }      
    }
    
    ** Use in testing to set a stubbed response to be validated by the server.
    ** Valid codes accepted by CaptchaServer are:
    **  - '<fail>'
    **  - '<error>'
    **  - '<success>'
    ** 
    ** Only works when hCapture is **not** enabled.
    static Void setResponse(Str? response) {
        responseStubRef.val = response
    }
    
    ** Returns the result of the user completing hCapture puzzles.
    Str? getResponse(Str widgetId) {
        if (!enabled)
            return responseStubRef.val ?: "<hcaptcha-not-enabled>"
        if (!hasLoaded)
            throw Err("hCaptcha JS library has not loaded")
        return widgetResponses[widgetIds[widgetId]]
    }
    
    private Void call(|->| func) {
        if (hasLoaded)
            func()
        else
            messageQueue.add(func)      
    }
    
    private Void onLoad() {
        while (messageQueue.size > 0) {
            messageQueue.removeAt(0).call()
        }
    }
    
    private Int _lastGenIdTime
    private Str genId() {
        // 2.pow(32) / 1000ms / 60 sec / 60 min / 24 hour ~~ 50 days --> and no one will over need more than 48K of RAM
        time := DateTime.nowTicks / 1ms.ticks
        if (time < _lastGenIdTime)
            time = _lastGenIdTime++
        _lastGenIdTime = time
        time  = time.and(0xffff_ffff)
        rand := Int.random.and(0xffff_ffff)
        return StrBuf(17).add(time.toHex(8)).addChar('-').add(rand.toHex(8)).toStr
    }
    
    private native Void iAmHere()
    private native Bool hasLoaded()
    private native Str  doRender(Str containerId, [Str:Obj?]? params := null)
    private native Void doReset(Str widgetId)
}