interface PackageIndexEntry {
    name: string
    size?: number
    description?: string
    website?: string
    version?: string
    report?: boolean
}

function entryToHTML(entry: PackageIndexEntry | SearchResult): HTMLTableRowElement {
    let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : ""
    let s = entry.size != null && entry.size > 0 ? `<p>Size: ${toByteSizeString(entry.size)} (${entry.size})</p>` : ""
    let n: string
    let d: string
    if ('name_matches' in entry) {
        n = `<h2>${nameMatches(entry as SearchResult)} ${v}</h2>`
    } else {
        n = `<h2>${escapeHtml(entry.name)} ${v}</h2>`
    }
    if ('desc_matches' in entry && STATE.getIncludeDescriptions()) {
        d = descMatches(entry as SearchResult)
    } else {
        d = (entry as PackageIndexEntry).description != null ? `<p>${escapeHtml((entry as PackageIndexEntry).description)}</p>` : ""
    }
    let w = entry.website != null ? `<a href="${encodeURI(entry.website)}">Website</a>` : ""
    let r = entry.report ? `Log (<a href=\"${encodeURI('/api/v1/status/' + entry.name)}\">View</a> | <a href=\"${encodeURI('/status/' + entry.name)}\">Download</a>)` : ""
    let row = <HTMLTableRowElement>(document.createElement('tr'))
    row.innerHTML = `<td>
            ${n}
            ${d}
            ${s}
            ${w}
            ${r}
        </td>`
    return row
}

function nameMatches(sr: SearchResult): string {
    return markMatches(sr.name, sr.name_matches)
}

function descMatches(sr: SearchResult): string {
    return markMatches(sr.description!, sr.desc_matches)
}

function markMatches(str: string, indices: [number, number][]): string {
    if (indices == null) {
        return str
    }
    let out: string = ""
    let j = 0
    for (let i = 0; i < str.length; i++) {
        if (j < indices.length) {
            if (i === indices[j][0]) {
                out += `<mark>${escapeHtmlChar(str[i])}`
                continue
            }
            if (i === indices[j][1]) {
                out += `</mark>${escapeHtmlChar(str[i])}`
                j++
                continue
            }
        }
        out += escapeHtmlChar(str[i])
    }
    if (indices[j] !== undefined) {
        out += "</mark>"
    }
    return out
}

function toByteSizeString(bytes: number): string {
    if (bytes == null) return `unspecified`
    if (bytes < 1024) return `${bytes}B`
    if (bytes < Math.pow(1024, 2)) return `${(bytes / 1024).toFixed(2)}kiB`
    if (bytes < Math.pow(1024, 3)) return `${(bytes / Math.pow(1024, 2)).toFixed(2)}MiB`
    if (bytes < Math.pow(1024, 4)) return `${(bytes / Math.pow(1024, 3)).toFixed(2)}GiB`
    if (bytes < Math.pow(1024, 5)) return `${(bytes / Math.pow(1024, 4)).toFixed(2)}TiB`
    return "not only is it big, it's large"
}

const API_VERSION = 1
const ENDPOINT = `/api/v${API_VERSION}`

interface InfoPayload {
    count?: number
    hakurei_version?: string
}

async function infoRequest(): Promise<InfoPayload> {
    const res = await fetch(`${ENDPOINT}/info`)
    const payload = await res.json()
    return payload as InfoPayload
}

interface GetPayload {
    values?: PackageIndexEntry[]
}

enum SortOrders {
    DeclarationAscending,
    DeclarationDescending,
    NameAscending,
    NameDescending
}

async function getRequest(limit: number, index: number, sort: SortOrders): Promise<GetPayload> {
    const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`)
    const payload = await res.json()
    return payload as GetPayload
}

interface SearchResult extends PackageIndexEntry {
    name_matches: [number, number][]
    desc_matches: [number, number][]
    score: number
}

interface SearchPayload {
    count?: number
    values?: SearchResult[]
}

async function searchRequest(limit: number, index: number, search: string, desc: boolean): Promise<SearchPayload> {
    const res = await fetch(`${ENDPOINT}/search?limit=${limit}&index=${index}&search=${encodeURIComponent(search)}&desc=${desc}`)
    if (!res.ok) {
        exitSearch()
        alert("invalid search query!")
        return Promise.reject(res.statusText)
    }
    const payload = await res.json()
    return payload as SearchPayload
}

class State {
    entriesPerPage: number = 10
    entryIndex: number = 0
    maxTotal: number = 0
    maxEntries: number = 0
    sort: SortOrders = SortOrders.DeclarationAscending
    search: boolean = false

    getEntriesPerPage(): number {
        return this.entriesPerPage
    }

    setEntriesPerPage(entriesPerPage: number) {
        this.entriesPerPage = entriesPerPage
        this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage)
    }

    getEntryIndex(): number {
        return this.entryIndex
    }

    setEntryIndex(entryIndex: number) {
        this.entryIndex = entryIndex
        this.updatePage()
        this.updateRange()
        this.updateListings()
    }

    getMaxTotal(): number {
        return this.maxTotal
    }

    setMaxTotal(max: number) {
        this.maxTotal = max
    }

    getSortOrder(): SortOrders {
        return this.sort
    }

    setSortOrder(sortOrder: SortOrders) {
        this.sort = sortOrder
        this.setEntryIndex(0)
    }

    updatePage() {
        let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage())
        for (let e of document.getElementsByClassName("page-number")) {
            (e as HTMLInputElement).value = String(page)
        }
    }

    updateRange() {
        let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxTotal())
        document.getElementById("entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxTotal()}`
        if (this.search) {
            document.getElementById("search-entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.maxTotal}/${this.maxEntries}`
            document.getElementById("search-query")!.innerHTML = `<code>${escapeHtml(this.getSearchQuery())}</code>`
        }
    }

    getSearchQuery(): string {
        let queryString = document.getElementById("search")!;
        return (queryString as HTMLInputElement).value
    }

    getIncludeDescriptions(): boolean {
        let includeDesc = document.getElementById("include-desc")!;
        return (includeDesc as HTMLInputElement).checked
    }

    updateListings() {
        if (this.search) {
            searchRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSearchQuery(), this.getIncludeDescriptions())
                .then(res => {
                    let table = document.getElementById("pkg-list")!
                    table.innerHTML = ''
                    for (let row of res.values!) {
                        table.appendChild(entryToHTML(row))
                    }
                    STATE.maxTotal = res.count!
                    STATE.updateRange()
                    if(res.count! < 1) {
                        exitSearch()
                        alert("no results found!")
                    }
                })
        } else {
            getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
                .then(res => {
                    let table = document.getElementById("pkg-list")!
                    table.innerHTML = ''
                    for (let row of res.values!) {
                        table.appendChild(entryToHTML(row))
                    }
                })
        }
    }
}

let STATE: State


function lastPageIndex(): number {
    return Math.floor(STATE.getMaxTotal() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()
}

function setPage(page: number) {
    STATE.setEntryIndex(Math.max(0, Math.min(STATE.getEntriesPerPage() * (page - 1), lastPageIndex())))
}


function escapeHtml(str?: string): string {
    let out: string = ''
    if (str == undefined) return ""
    for (let i = 0; i < str.length; i++) {
        out += escapeHtmlChar(str[i])
    }
    return out
}

function escapeHtmlChar(char: string): string {
    if (char.length != 1) return char
    switch (char[0]) {
        case '&':
            return "&amp;"
        case '<':
            return "&lt;"
        case '>':
            return "&gt;"
        case '"':
            return "&quot;"
        case "'":
            return "&apos;"
        default:
            return char
    }
}

function firstPage() {
    STATE.setEntryIndex(0)
}

function prevPage() {
    let index = STATE.getEntryIndex()
    STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
}

function lastPage() {
    STATE.setEntryIndex(lastPageIndex())
}

function nextPage() {
    let index = STATE.getEntryIndex()
    STATE.setEntryIndex(Math.min(lastPageIndex(), index + STATE.getEntriesPerPage()))
}

function doSearch() {
    document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
    document.getElementById("search-top-controls")!.toggleAttribute("hidden");
    STATE.search = true;
    STATE.setEntryIndex(0);
}

function exitSearch() {
    document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
    document.getElementById("search-top-controls")!.toggleAttribute("hidden");
    STATE.search = false;
    STATE.setMaxTotal(STATE.maxEntries)
    STATE.setEntryIndex(0)
}

function main() {
    STATE = new State()
    infoRequest()
        .then(res => {
            STATE.maxEntries = res.count!
            STATE.setMaxTotal(STATE.maxEntries)
            document.getElementById("hakurei-version")!.textContent = res.hakurei_version!
            STATE.updateRange()
            STATE.updateListings()
        })
    for (let e of document.getElementsByClassName("page-number")) {
        e.addEventListener("change", (_) => {
            setPage(parseInt((e as HTMLInputElement).value))
        })
    }
    document.getElementById("count")?.addEventListener("change", (event) => {
        STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value))
    })
    document.getElementById("sort")?.addEventListener("change", (event) => {
        STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value))
    })
    document.getElementById("search")?.addEventListener("keyup", (event) => {
        if (event.key === 'Enter') doSearch()
    })
}