// playground.tg
// ===============================================================
// The TargaScript playground. Everything the page does is written
// here in TargaScript and runs in the browser through WebAssembly.
// Programs run in a worker (runner.tg), so a long loop never
// freezes the page.
// ===============================================================

let doc = js.document
let byId = fn(id) { doc.getElementById(id) }

let code = byId("code")
let highlight = byId("highlight")
let gutter = byId("gutter")
let output = byId("output")
let status = byId("status")
let runButton = byId("run")
let stopButton = byId("stop")
let shareButton = byId("share")
let formatButton = byId("format")
let examples = byId("examples")
let nextButton = byId("next")

const TIME_LIMIT = 10000

// SECTION 1: Syntax highlighting
// ------------------------------
// One regular expression finds comments, strings, numbers, keywords
// and function calls. Everything else is matched one run at a time.

let tokenPattern = js.RegExp.new(
    "(//[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$))" +
    "|(\"(?:\\\\[\\s\\S]|[^\"\\\\])*\"?|`(?:\\\\[\\s\\S]|[^`\\\\])*`?)" +
    "|(\\b(?:0[xXbBoO][\\da-fA-F_]+|\\d[\\d_]*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b)" +
    "|(\\b(?:let|const|fn|if|else|return|repeat|in|break|continue|load|as|await|try|catch|finally|throw|match|true|false|null|this)\\b)" +
    "|(\\b[A-Za-z_]\\w*(?=\\s*\\())" +
    "|([^/\"`\\w]+|[\\s\\S])",
    "g"
)

fn escapeHtml(text) {
    text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

fn span(kind, text) {
    "<span class=\"tok-${kind}\">${escapeHtml(text)}</span>"
}

fn colorToken(text, comment, string, number, keyword, call) {
    if comment != null { return span("comment", text) }
    if string != null { return span("string", text) }
    if number != null { return span("number", text) }
    if keyword != null { return span("keyword", text) }
    if call != null { return span("call", text) }
    escapeHtml(text)
}

fn render() {
    let source = code.value
    // A trailing newline keeps the last line visible in the overlay
    highlight.innerHTML = js.String.prototype.replace.call(source, tokenPattern, colorToken) + "\n"

    renderGutter(source.split("\n").length)
    syncScroll()
    scheduleCheck()
}

// Problems found by the last check, keyed by line number
let problems = {}
let checkTimer = null

fn renderGutter(lines) {
    let numbers = []
    repeat n in 1..lines {
        let message = problems["${n}"]
        if message == null {
            numbers.insert("${n}")
        } else {
            numbers.insert("<span class=\"problem\" title=\"${escapeHtml(message).replace("\"", "&quot;")}\">${n}</span>")
        }
    }
    gutter.innerHTML = numbers.join("\n")
}

// Checks the code a moment after the last keystroke, without running
// it, and marks the lines with problems
fn scheduleCheck() {
    if checkTimer != null { js.clearTimeout(checkTimer) }
    checkTimer = js.setTimeout(fn() {
        checkTimer = null
        let found = {}
        repeat problem in js.targascriptCheck(code.value) {
            let line = "${problem.line}"
            if problem.line > 0 && found[line] == null { found[line] = problem.message }
        }
        problems = found
        renderGutter(code.value.split("\n").length)
    }, 300)
}

fn syncScroll() {
    highlight.scrollTop = code.scrollTop
    highlight.scrollLeft = code.scrollLeft
    gutter.scrollTop = code.scrollTop
}

fn setCode(source) {
    code.value = source
    // A new value puts the cursor at its end, and the browser scrolls the
    // focused editor there; new code starts at the top
    code.setSelectionRange(0, 0)
    code.scrollTop = 0
    render()
}

code.addEventListener("input", fn(event) { render() })
code.addEventListener("scroll", fn(event) { syncScroll() })
code.addEventListener("keydown", fn(event) {
    if event.key == "Enter" && (event.ctrlKey || event.metaKey) {
        event.preventDefault()
        run()
        return null
    }
    if event.code == "KeyF" && event.shiftKey && event.altKey {
        event.preventDefault()
        formatCode()
        return null
    }
    if event.key == "Tab" && !event.shiftKey {
        event.preventDefault()
        code.setRangeText("    ", code.selectionStart, code.selectionEnd, "end")
        render()
    }
})

// SECTION 2: Running programs
// ---------------------------
// Each run gets a fresh worker. Stop, a new run or the time limit
// terminate it.

let worker = null
let timer = null

fn finish(message) {
    if worker != null {
        worker.terminate()
        worker = null
    }
    if timer != null {
        js.clearTimeout(timer)
        timer = null
    }
    runButton.disabled = false
    stopButton.disabled = true
    status.textContent = message
}

fn showText(text, className) {
    let span = doc.createElement("span")
    span.className = className
    span.textContent = text
    output.appendChild(span)
}

fn showError(text) { showText(text + "\n", "error") }

// Results of test() in the current run
let passed = 0
let failed = 0

// Colors the lines test() prints, which come in one piece each
fn showOutput(text) {
    if text.startsWith("✓ ") {
        passed++
        showText(text, "pass")
    } else if text.startsWith("✗ ") {
        failed++
        showText(text, "fail")
    } else {
        output.append(text)
    }
}

fn run() {
    if worker != null { finish("") }
    output.textContent = ""
    passed = 0
    failed = 0
    status.textContent = "Running..."
    runButton.disabled = true
    stopButton.disabled = false

    let started = js.performance.now()
    let current = js.Worker.new("targascript.js?src=runner.tg")
    worker = current

    current.onmessage = fn(event) {
        let message = event.data
        if message.type == "ready" {
            current.postMessage(code.value)
        } else if message.type == "output" {
            showOutput(message.text)
        } else if message.type == "done" {
            if message.error != null { showError(message.error) }
            let elapsed = round(js.performance.now() - started)
            let tests = if passed + failed > 0 { " · ${passed} passed, ${failed} failed" } else { "" }
            finish("Finished in ${elapsed} ms${tests}")
        }
    }

    current.onerror = fn(event) {
        event.preventDefault()
        showError("The program crashed: ${event.message}")
        finish("Crashed")
    }

    timer = js.setTimeout(fn() {
        showError("Stopped after ${TIME_LIMIT / 1000} seconds.")
        finish("Timed out")
    }, TIME_LIMIT)
}

runButton.addEventListener("click", fn(event) { run() })
stopButton.addEventListener("click", fn(event) {
    showError("Stopped.")
    finish("Stopped")
})

// SECTION 3: Sharing
// ------------------
// The code is compressed and stored in the address after #code=, so
// a link carries the whole program.

fn toBase64Url(bytes) {
    let binary = js.String.fromCharCode.apply(null, bytes)
    js.btoa(binary).replace("+", "-").replace("/", "_").replace("=", "")
}

fn compress(text) {
    let stream = js.Blob.new([text]).stream().pipeThrough(js.CompressionStream.new("deflate-raw"))
    let buffer = await js.Response.new(stream).arrayBuffer()
    toBase64Url(js.Uint8Array.new(buffer))
}

// Fetching a data: URL turns the base64 back into bytes
fn decompress(text) {
    let base64 = text.replace("-", "+").replace("_", "/")
    let response = await js.fetch("data:application/octet-stream;base64," + base64)
    let stream = response.body.pipeThrough(js.DecompressionStream.new("deflate-raw"))
    await js.Response.new(stream).text()
}

// Lays the code out like targascript fmt. Only spaces change, so the
// cursor keeps its place among the other characters.
fn formatCode() {
    let formatted = js.targascriptFormat(code.value)
    if formatted == code.value { return null }
    let before = code.value.slice(0, code.selectionStart).replacePattern("\\s", "").length
    // A cursor right before a word stays before it, not at the end of the
    // line above
    let atWord = !((code.value[code.selectionStart] ?? " ") in " \n\t")
    setCode(formatted)
    let position = 0
    let seen = 0
    repeat position < formatted.length && seen < before {
        if !(formatted[position] in " \n\t") { seen++ }
        position++
    }
    repeat atWord && position < formatted.length && formatted[position] in " \n\t" { position++ }
    code.setSelectionRange(position, position)
}

formatButton.addEventListener("click", fn(event) { formatCode() })

shareButton.addEventListener("click", fn(event) {
    let encoded = compress(code.value)
    js.history.replaceState(null, "", "#code=" + encoded)

    // Without clipboard access the link is still in the address bar
    shareButton.textContent = try {
        await js.navigator.clipboard.writeText(js.location.href)
        "Link copied"
    } catch {
        "Link in address bar"
    }
    js.setTimeout(fn() { shareButton.textContent = "Share" }, 2000)
})

// SECTION 4: Tour and examples
// -----------------------------
// tour/index.txt lists the lessons as file|title, and examples/index.txt
// the repository's examples. Both go in the same menu.

fn fetchText(path) {
    let response = await js.fetch(path)
    await response.text()
}

let lessons = []

fn addGroup(label, entries) {
    let group = doc.createElement("optgroup")
    group.label = label
    repeat [path, title] in entries {
        let option = doc.createElement("option")
        option.value = path
        option.textContent = title
        group.appendChild(option)
    }
    examples.appendChild(group)
}

fn openFile(path) {
    setCode(fetchText(path))
    js.history.replaceState(null, "", js.location.pathname)
    output.textContent = ""
    status.textContent = ""

    // Lessons lead to the next one
    let lesson = lessons.indexOf(path)
    nextButton.hidden = lesson < 0 || lesson == lessons.length - 1
}

examples.addEventListener("change", fn(event) {
    if examples.value != "" { openFile(examples.value) }
})

nextButton.addEventListener("click", fn(event) {
    let next = lessons[lessons.indexOf(examples.value) + 1]
    examples.value = next
    openFile(next)
})

// SECTION 5: Start
// ----------------

// A link cut short or edited by hand can't be read; the welcome code opens
// instead of nothing
let hash = js.location.hash
let shared = try {
    if hash.startsWith("#code=") { decompress(hash.replace("#code=", "")) }
} catch {
    null
}
setCode(shared ?? fetchText("welcome.tg"))
runButton.disabled = false
status.textContent = if hash.startsWith("#code=") && shared == null { "Couldn't read the shared code" } else { "Ready" }

let tour = []
repeat line in fetchText("tour/index.txt").lines() {
    let [file, title] = line.split("|")
    lessons.insert("tour/" + file)
    tour.insert(["tour/" + file, "${lessons.length}. ${title}"])
}
addGroup("Tour", tour)

let files = fetchText("examples/index.txt").lines()
addGroup("Examples", files.map(fn(name) { ["examples/" + name, name.replace(".tg", "").replace("_", " ")] }))
