#!/usr/bin/env python
# -*- coding: utf-8 -*-

# This file is part of the syzygy-tables.info tablebase probing website.
# Copyright (C) 2015-2020 Niklas Fiekas <niklas.fiekas@backscattering.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import aiohttp.web

import jinja2

import chess
import chess.pgn
import chess.syzygy

import asyncio
import configparser
import os
import json
import logging
import warnings
import datetime
import functools
import itertools
import math
import sys
import textwrap

try:
    import htmlmin

    html_minify = functools.partial(htmlmin.minify, remove_optional_attribute_quotes=False)
except ImportError:
    warnings.warn("Not using HTML minification, htmlmin not imported.")

    def html_minify(html):
        return html


DEFAULT_FEN = "4k3/8/8/8/8/8/8/4K3 w - - 0 1"

EMPTY_FEN = "8/8/8/8/8/8/8/8 w - - 0 1"


def kib(num):
    for unit in ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB"]:
        if abs(num) < 1024:
            return "%3.1f %s" % (num, unit)
        num /= 1024
    return "%.1f %s" % (num, "Yi")


def static(path, content_type=None):
    def handler(request):
        headers = { "Content-Type": content_type } if content_type else None
        return aiohttp.web.FileResponse(os.path.join(os.path.dirname(__file__), path), headers=headers)
    return handler

def asset_url(path):
    return "/static/{}?mtime={}".format(path, os.path.getmtime(os.path.join(os.path.dirname(__file__), "static", path)))


def with_turn(board, turn):
    board = board.copy(stack=False)
    board.turn = turn
    return board


@aiohttp.web.middleware
async def trust_x_forwarded_for(request, handler):
    if request.remote == "127.0.0.1":
        request = request.clone(remote=request.headers.get("X-Forwarded-For", "127.0.0.1"))
    return await handler(request)

def backend_session(request):
    return aiohttp.ClientSession(headers={"X-Forwarded-For": request.remote})


def prepare_stats(request, material, fen, active_dtz):
    render = {}

    # Get stats and side.
    stats = request.app["stats"].get(material)
    side = "white"
    other = "black"
    if stats is None:
        stats = request.app["stats"].get(chess.syzygy.normalize_tablename(material))
        side = "black"
        other = "white"
    if stats is None:
        return None

    material_side, _ = render["material_side"], render["material_other"] = material.split("v", 1)

    # Basic statistics.
    outcomes = {
        "white": stats["histogram"][side]["wdl"]["2"] + stats["histogram"][other]["wdl"]["-2"],
        "cursed": stats["histogram"][side]["wdl"]["1"] + stats["histogram"][other]["wdl"]["-1"],
        "draws": stats["histogram"][side]["wdl"]["0"] + stats["histogram"][other]["wdl"]["0"],
        "blessed": stats["histogram"][side]["wdl"]["-1"] + stats["histogram"][other]["wdl"]["1"],
        "black": stats["histogram"][side]["wdl"]["-2"] + stats["histogram"][other]["wdl"]["2"],
    }

    total = sum(outcomes.values())
    if not total:
        return None

    for key in outcomes:
        render[key] = outcomes[key]
        render[key + "_pct"] = round(outcomes[key] * 100 / total, 1)

    # Longest endgames.
    render["longest"] = [{
        "label": "{} {} with DTZ {}{}".format(
            material_side,
            "winning" if (longest["wdl"] > 0) == ((" " + side) in longest["epd"]) else "losing",
            longest["ply"],
            " (frustrated)" if abs(longest["wdl"]) == 1 else ""),
        "fen": longest["epd"] + " 0 1",
    } for longest in stats["longest"]]

    # Histogram.
    side_winning = (" w" in fen) == (active_dtz is not None and active_dtz > 0)
    render["verb"] = "winning" if side_winning else "losing"

    win_hist = stats["histogram"][side]["win" if side_winning else "loss"]
    loss_hist = stats["histogram"][other]["loss" if side_winning else "win"]
    hist = [a + b for a, b in itertools.zip_longest(win_hist, loss_hist, fillvalue=0)]
    if not any(hist):
        return render

    maximum = max(math.log(num) if num else 0 for num in hist)

    render["histogram"] = []
    empty = 0
    for ply, num in enumerate(hist):
        if num == 0:
            empty += 1
            continue

        if empty > 5:
            render["histogram"].append({"empty": empty})
        else:
            for i in range(empty):
                render["histogram"].append({
                    "ply": ply - empty + i,
                    "num": 0,
                    "width": 0,
                })
        empty = 0

        rounding = request.app["config"].getboolean("server", "rounding")
        render["histogram"].append({
            "ply": ply,
            "num": num,
            "width": round((math.log(num) if num else 0) * 100 / maximum, 1),
            "active": active_dtz is not None and (abs(active_dtz) == ply or (rounding and active_dtz and abs(active_dtz) + 1 == ply)),
        })

    return render


def longest_fen(stats, endgame):
    if endgame == "KNvK":
        return "4k3/8/8/8/8/8/8/1N2K3 w - - 0 1"
    elif endgame == "KBvK":
        return "4k3/8/8/8/8/8/8/2B1K3 w - - 0 1"

    try:
        stats = stats[endgame]
    except KeyError:
        return None

    try:
        longest = max(stats["longest"], key=lambda e: e["ply"])
    except ValueError:
        return None
    else:
        return longest["epd"] + " 0 1"


def sort_key(endgame):
    w, b = endgame.split("v", 1)
    return len(endgame), len(w), [-chess.syzygy.PCHR.index(p) for p in w], len(b), [-chess.syzygy.PCHR.index(p) for p in b]


routes = aiohttp.web.RouteTableDef()

@routes.get("/syzygy-vs-syzygy/{material}.pgn")
async def syzygy_vs_syzygy_pgn(request):
    # Parse FEN.
    try:
        board = chess.Board(request.query["fen"].replace("_", " "))
        board.halfmove_clock = 0
        board.fullmoves = 1
    except KeyError:
        raise aiohttp.web.HTTPBadRequest(reason="fen required")
    except ValueError:
        raise aiohttp.web.HTTPBadRequest(reason="invalid fen")

    if not board.is_valid():
        raise aiohttp.web.HTTPBadRequest(reason="illegal fen")

    # Send HTTP headers early, to let the client know we got the request.
    # Creating the actual response might take a while.
    response = aiohttp.web.StreamResponse()
    response.content_type = "application/x-chess-pgn"
    if request.version >= (1, 1):
        response.enable_chunked_encoding()
    await response.prepare(request)

    # Force reverse proxies like nginx to send the first chunk.
    await response.write("[Event \"\"]\n".encode("utf-8"))

    # Prepare PGN headers.
    game = chess.pgn.Game()
    game.setup(board)
    del game.headers["Event"]
    game.headers["Site"] = request.app["config"].get("server", "base_url") + "?fen=" + board.fen().replace(" ", "_")
    game.headers["Date"] = datetime.datetime.now().strftime("%Y.%m.%d")
    game.headers["Round"] = "-"
    game.headers["White"] = "Syzygy"
    game.headers["Black"] = "Syzygy"
    game.headers["Annotator"] = request.app["config"].get("server", "name")

    # Query backend.
    async with backend_session(request) as session:
        async with session.get(request.app["config"].get("server", "backend") + "/mainline", params={"fen": board.fen()}) as res:
            if res.status == 404:
                result = {
                    "dtz": None,
                    "mainline": [],
                }
            else:
                result = await res.json()

    # Starting comment.
    if result["dtz"] == 0:
        game.comment = "Tablebase draw"
    elif result["dtz"] is not None:
        game.comment = "DTZ %d" % (result["dtz"], )
    else:
        game.comment = "Position not in tablebases"

    # Follow the DTZ mainline.
    dtz = result["dtz"]
    node = game
    for move_info in result["mainline"]:
        move = board.push_uci(move_info["uci"])
        node = node.add_variation(move)
        dtz = move_info["dtz"]

        if board.halfmove_clock == 0:
            node.comment = "%s with DTZ %d" % (chess.syzygy.calc_key(board), dtz)

    # Final comment.
    if board.is_checkmate():
        node.comment = "Checkmate"
    elif board.is_stalemate():
        node.comment = "Stalemate"
    elif board.is_insufficient_material():
        node.comment = "Insufficient material"
    elif dtz is not None and dtz != 0 and result["winner"] is None:
        node.comment = "Draw claimed at DTZ %d" % (dtz, )

    # Set result.
    if dtz is not None:
        if result["winner"] is None:
            game.headers["Result"] = "1/2-1/2"
        elif result["winner"].startswith("w"):
            game.headers["Result"] = "1-0"
        elif result["winner"].startswith("b"):
            game.headers["Result"] = "0-1"

    # Send response.
    await response.write(str(game).encode("utf-8"))
    return response

@routes.get("/")
async def index(request):
    render = {}

    # Setup a board from the given valid FEN or fall back to the default FEN.
    try:
        board = chess.Board(request.query.get("fen", DEFAULT_FEN).replace("_", " "))
        board.halfmove_clock = 0
        board.fullmoves = 1
    except ValueError:
        board = chess.Board(DEFAULT_FEN)

    # Get FENs with the current side to move, black and white to move.
    render["fen"] = fen = board.fen()
    render["white_fen"] = with_turn(board, chess.WHITE).fen()
    render["black_fen"] = with_turn(board, chess.BLACK).fen()
    render["board_fen"] = board.board_fen()
    render["check_square"] = chess.SQUARE_NAMES[board.king(board.turn)] if board.is_check() else None

    # Mirrored and color swapped FENs for the toolbar.
    render["turn"] = "white" if board.turn == chess.WHITE else "black"
    render["horizontal_fen"] = board.transform(chess.flip_horizontal).fen()
    render["vertical_fen"] = board.transform(chess.flip_vertical).fen()
    render["swapped_fen"] = with_turn(board, not board.turn).fen()
    render["clear_fen"] = with_turn(chess.Board(DEFAULT_FEN), board.turn).fen()
    render["fen_input"] = "" if board.fen() == DEFAULT_FEN else board.fen()

    # Material key for the page title.
    render["material"] = material = chess.syzygy.calc_key(board)
    render["normalized_material"] = chess.syzygy.normalize_tablename(material)
    render["piece_count"] = chess.popcount(board.occupied)

    # Moves are going to be grouped by WDL.
    grouped_moves = {-2: [], -1: [], 0: [], 1: [], 2: [], None: []}

    dtz = None
    active_dtz = None

    if not board.is_valid():
        render["status"] = "Invalid position"
        render["illegal"] = True
    elif board.is_stalemate():
        render["status"] = "Draw by stalemate"
    elif board.is_checkmate():
        active_dtz = 0
        if board.turn == chess.WHITE:
            render["status"] = "Black won by checkmate"
            render["winning_side"] = "black"
        else:
            render["status"] = "White won by checkmate"
            render["winning_side"] = "white"
    else:
        # Query backend.
        async with backend_session(request) as session:
            async with session.get(request.app["config"].get("server", "backend"), params={"fen": board.fen()}) as res:
                if res.status != 200:
                    return aiohttp.web.Response(
                        status=res.status,
                        content_type=res.content_type,
                        body=await res.read(),
                        charset=res.charset)

                probe = await res.json()

        dtz = probe["dtz"]
        active_dtz = dtz if dtz else None

        render["blessed_loss"] = probe["wdl"] == -1
        render["cursed_win"] = probe["wdl"] == 1

        # Set status line.
        if board.is_insufficient_material():
            render["status"] = "Draw by insufficient material"
            render["insufficient_material"] = True
        elif probe["wdl"] is None or probe["dtz"] is None:
            render["status"] = "Position not found in tablebases"
        elif probe["wdl"] == 0:
            render["status"] = "Tablebase draw"
        elif probe["dtz"] > 0 and board.turn == chess.WHITE:
            render["status"] = "White is winning with DTZ %d" % (abs(probe["dtz"]), )
            render["winning_side"] = "white"
        elif probe["dtz"] < 0 and board.turn == chess.WHITE:
            render["status"] = "White is losing with DTZ %d" % (abs(probe["dtz"]), )
            render["winning_side"] = "black"
        elif probe["dtz"] > 0 and board.turn == chess.BLACK:
            render["status"] = "Black is winning with DTZ %d" % (abs(probe["dtz"]), )
            render["winning_side"] = "black"
        elif probe["dtz"] < 0 and board.turn == chess.BLACK:
            render["status"] = "Black is losing with DTZ %d" % (abs(probe["dtz"]), )
            render["winning_side"] = "white"

        render["frustrated"] = probe["wdl"] is not None and abs(probe["wdl"]) == 1

        # Label and group all legal moves.
        for move_info in probe["moves"]:
            move = board.push_uci(move_info["uci"])
            move_info["fen"] = board.fen()
            board.pop()

            move_info["capture"] = board.is_capture(move)

            move_info["dtm"] = abs(move_info["dtm"]) if move_info["dtm"] is not None else None

            if move_info["checkmate"]:
                move_info["wdl"] = -2
            elif move_info["stalemate"] or move_info["insufficient_material"]:
                move_info["wdl"] = 0

            if move_info["checkmate"]:
                move_info["badge"] = "Checkmate"
            elif move_info["stalemate"]:
                move_info["badge"] = "Stalemate"
            elif move_info["insufficient_material"]:
                move_info["badge"] = "Insufficient material"
            elif move_info["dtz"] == 0:
                move_info["badge"] = "Draw"
            elif move_info["dtz"] is None:
                move_info["badge"] = "Unknown"
            elif move_info["zeroing"]:
                move_info["badge"] = "Zeroing"
            elif move_info["dtz"] < 0:
                move_info["badge"] = "Win with DTZ %d" % (abs(move_info["dtz"]), )
            elif move_info["dtz"] > 0:
                move_info["badge"] = "Loss with DTZ %d" % (abs(move_info["dtz"]), )

            grouped_moves[move_info["wdl"]].append(move_info)

    # Sort winning moves.
    grouped_moves[-2].sort(key=lambda move: move["uci"])
    grouped_moves[-2].sort(key=lambda move: (move["dtm"] is None, move["dtm"]))
    grouped_moves[-2].sort(key=lambda move: (move["dtz"] is None, move["dtz"]), reverse=True)
    grouped_moves[-2].sort(key=lambda move: move["zeroing"], reverse=True)
    grouped_moves[-2].sort(key=lambda move: move["capture"], reverse=True)
    grouped_moves[-2].sort(key=lambda move: move["checkmate"], reverse=True)
    render["winning_moves"] = grouped_moves[-2]

    # Sort moves leading to cursed wins.
    grouped_moves[-1].sort(key=lambda move: move["uci"])
    grouped_moves[-1].sort(key=lambda move: (move["dtm"] is None, move["dtm"]))
    grouped_moves[-1].sort(key=lambda move: (move["dtz"] is None, move["dtz"]), reverse=True)
    grouped_moves[-1].sort(key=lambda move: move["zeroing"], reverse=True)
    grouped_moves[-1].sort(key=lambda move: move["capture"], reverse=True)
    render["cursed_moves"] = grouped_moves[-1]

    # Sort drawing moves.
    grouped_moves[0].sort(key=lambda move: move["uci"])
    grouped_moves[0].sort(key=lambda move: move["zeroing"], reverse=True)
    grouped_moves[0].sort(key=lambda move: move["capture"], reverse=True)
    grouped_moves[0].sort(key=lambda move: move["insufficient_material"], reverse=True)
    grouped_moves[0].sort(key=lambda move: move["stalemate"], reverse=True)
    render["drawing_moves"] = grouped_moves[0]

    # Sort moves leading to a blessed loss.
    grouped_moves[1].sort(key=lambda move: move["uci"])
    grouped_moves[1].sort(key=lambda move: (move["dtm"] is not None, move["dtm"]), reverse=True)
    grouped_moves[1].sort(key=lambda move: (move["dtz"] is None, move["dtz"]), reverse=True)
    grouped_moves[1].sort(key=lambda move: move["zeroing"])
    grouped_moves[1].sort(key=lambda move: move["capture"])
    render["blessed_moves"] = grouped_moves[1]

    # Sort losing moves.
    grouped_moves[2].sort(key=lambda move: move["uci"])
    grouped_moves[2].sort(key=lambda move: (move["dtm"] is not None, move["dtm"]), reverse=True)
    grouped_moves[2].sort(key=lambda move: (move["dtz"] is None, move["dtz"]), reverse=True)
    grouped_moves[2].sort(key=lambda move: move["zeroing"])
    grouped_moves[1].sort(key=lambda move: move["capture"])
    render["losing_moves"] = grouped_moves[2]

    # Sort unknown moves.
    grouped_moves[None].sort(key=lambda move: move["uci"])
    grouped_moves[None].sort(key=lambda move: move["zeroing"], reverse=True)
    grouped_moves[None].sort(key=lambda move: move["capture"], reverse=True)
    render["unknown_moves"] = grouped_moves[None]

    # Stats.
    render["stats"] = prepare_stats(request, material, render["fen"], active_dtz)

    # Dependencies.
    render["is_table"] = chess.syzygy.is_tablename(material, normalized=False) and material != "KvK"
    if render["is_table"]:
        render["deps"] = [{
            "material": dep,
            "longest_fen": longest_fen(request.app["stats"], dep),
        } for dep in chess.syzygy.dependencies(material)]

    if "xhr" in request.query:
        template = request.app["jinja"].get_template("xhr-probe.html")
    else:
        template = request.app["jinja"].get_template("index.html")

    return aiohttp.web.Response(text=html_minify(template.render(render)), content_type="text/html")

@routes.get("/legal")
def legal(request):
    template = request.app["jinja"].get_template("legal.html")
    return aiohttp.web.Response(text=html_minify(template.render()), content_type="text/html")

@routes.get("/metrics")
def metrics(request):
    template = request.app["jinja"].get_template("metrics.html")
    return aiohttp.web.Response(text=html_minify(template.render()), content_type="text/html")

@routes.get("/robots.txt")
def robots(request):
    return aiohttp.web.Response(text=textwrap.dedent("""\
        User-agent: SemrushBot
        User-agent: SemrushBot-SA
        User-agent: AhrefsBot
        User-agent: MegaIndex.ru
        Disallow: /

        User-agent: *
        Disallow: /syzygy-vs-syzygy/
        Disallow: /endgames.pgn
        """))

@routes.get("/sitemap.txt")
def sitemap(request):
    entries = [
        "endgames",
        "stats",
        "legal",
        "/?fen=QN4n1/6r1/3k4/8/b2K4/8/8/8_b_-_-_0_1",
    ]

    base_url = request.app["config"].get("server", "base_url")

    content = "\n".join(base_url + entry for entry in entries)
    return aiohttp.web.Response(text=content)

@routes.get("/stats")
def stats_doc(request):
    template = request.app["jinja"].get_template("stats.html")
    return aiohttp.web.Response(text=html_minify(template.render()), content_type="text/html")

@routes.get("/stats/{material}.json")
def stats_json(request):
    table = request.match_info["material"]
    if len(table) > 7 + 1 or not chess.syzygy.TABLENAME_REGEX.match(table):
        raise aiohttp.web.HTTPNotFound()

    normalized = chess.syzygy.normalize_tablename(table)
    if table != normalized:
        raise aiohttp.web.HTTPMovedPermanently(location="/stats/{}.json".format(normalized))

    try:
        stats = request.app["stats"][table]
    except KeyError:
        raise aiohttp.web.HTTPNotFound()
    else:
        return aiohttp.web.json_response(stats)

@routes.get("/graph.dot")
@routes.get("/graph/{material}.dot")
def graph_dot(request):
    root = request.match_info.get("material", "KPPPPPvK,KPPPPvKP,KPPPvKPP").split(",")
    if not all(chess.syzygy.is_tablename(r) for r in root):
        raise aiohttp.web.HTTPNotFound()

    closed = set(["KvK"])
    target = root[:]

    result = []
    result.append("digraph Syzygy {")
    while target:
        material = target.pop()
        if material in closed:
            continue

        deps = list(chess.syzygy.dependencies(material))
        target.extend(deps)
        if not deps and material in root:
            result.append("  {};".format(material))
        for dep in deps:
            result.append("  {} -> {};".format(material, dep))

        closed.add(material)
    result.append("}")

    result.append("")
    return aiohttp.web.Response(text="\n".join(result))

@routes.get("/download.txt")
@routes.get("/download/{material}.txt")
def download_txt(request):
    root = request.match_info.get("material", "KPPPPPvK,KPPPPvKP,KPPPvKPP").split(",")
    if not all(chess.syzygy.is_tablename(r) for r in root):
        raise aiohttp.web.HTTPNotFound()

    source = request.query.get("source", "lichess")
    dtz = request.query.get("dtz", "all")

    try:
        max_pieces = int(request.query.get("max-pieces", "7"))
        min_pieces = int(request.query.get("min-pieces", "3"))
    except ValueError:
        raise aiohttp.web.HTTPBadRequest(reason="invalid piece count")

    tables = list(chess.syzygy.all_dependencies(root))
    tables.sort(key=sort_key)

    result = []
    for table in tables:
        piece_count = len(table) - 1
        if piece_count > max_pieces or piece_count < min_pieces:
            continue

        include_dtz = dtz in ["all", "only"] or (dtz == "root" and table in root)
        include_wdl = dtz != "only"
        if source in ["lichess", "lichess.org", "lichess.ovh", "tablebase.lichess.ovh"]:
            base = "https://tablebase.lichess.ovh/tables/standard"
            if len(table) <= 6:
                if include_wdl:
                    result.append("{}/3-4-5/{}.rtbw".format(base, table))
                if include_dtz:
                    result.append("{}/3-4-5/{}.rtbz".format(base, table))
            elif len(table) <= 7:
                if include_wdl:
                    result.append("{}/6-wdl/{}.rtbw".format(base, table))
                if include_dtz:
                    result.append("{}/6-dtz/{}.rtbz".format(base, table))
            else:
                suffix = "pawnful" if "P" in table else "pawnless"
                w, b = table.split("v")
                if include_wdl:
                    result.append("{}/7/{}v{}_{}/{}.rtbw".format(base, len(w), len(b), suffix, table))
                if include_dtz:
                    result.append("{}/7/{}v{}_{}/{}.rtbz".format(base, len(w), len(b), suffix, table))
        elif source in ["sesse", "sesse.net", "tablebase.sesse.net"]:
            base = "http://tablebase.sesse.net/syzygy"
            if len(table) <= 6:
                if include_wdl:
                    result.append("{}/3-4-5/{}.rtbw".format(base, table))
                if include_dtz:
                    result.append("{}/3-4-5/{}.rtbz".format(base, table))
            elif len(table) <= 7:
                if include_wdl:
                    result.append("{}/6-WDL/{}.rtbw".format(base, table))
                if include_dtz:
                    result.append("{}/6-DTZ/{}.rtbz".format(base, table))
            else:
                if include_wdl:
                    result.append("{}/7-WDL/{}.rtbw".format(base, table))
                if include_dtz:
                    result.append("{}/7-DTZ/{}.rtbz".format(base, table))
        elif source in ["ipfs", "ipfs.syzygy-tables.info"]:
            if len(table) <= 6:
                # More reliably seeded.
                base = "QmNbKYpPyXFAHFMnAxoc2i28Jf7jhShM8EEnfWUMv6u2DQ"
            else:
                # /ipns/ipfs.syzygy-tables.info
                base = "QmVgcSADsoW5w19MkL2RNKNPGtaz7UhGhU62XRm6pQmzct"
            if include_wdl:
                result.append("/ipfs/{}/{}.rtbw".format(base, table))
            if include_dtz:
                result.append("/ipfs/{}/{}.rtbz".format(base, table))
        elif source in ["stem", "material"]:
            result.append(table)
        elif source in ["file", "filename"]:
            if include_wdl:
                result.append("{}.rtbw".format(table))
            if include_dtz:
                result.append("{}.rtbz".format(table))
        else:
            raise aiohttp.web.HTTPBadRequest(reason="unknown source")

    result.append("")
    return aiohttp.web.Response(text="\n".join(result))

@routes.get("/endgames")
def endgames(request):
    def subgroup(endgames, num_pieces, num_pawns):
        return filter(lambda t: len(t) - 1 == num_pieces and t.count("P") == num_pawns, endgames)

    endgames = list(chess.syzygy.tablenames(piece_count=7))
    endgames.sort(key=sort_key)

    render = {
        "groups": [{
            "num_pieces": num_pieces,
            "split_pawns": num_pieces >= 5,
            "subgroups": [{
                "num_pawns": num_pawns,
                "endgames": [{
                    "material": endgame,
                    "has_stats": endgame in request.app["stats"],
                    "longest_fen": longest_fen(request.app["stats"], endgame),
                    "maximal": endgame in ["KRvK", "KBNvK", "KNNvKP", "KRNvKNN", "KRBNvKQN"],
                } for endgame in subgroup(endgames, num_pieces, num_pawns)],
            } for num_pawns in range(0, num_pieces - 2 + 1)],
        } for num_pieces in range(3, 7 + 1)],
    }

    template = request.app["jinja"].get_template("endgames.html")
    return aiohttp.web.Response(text=html_minify(template.render(render)), content_type="text/html")


def make_app(config):
    app = aiohttp.web.Application(middlewares=[trust_x_forwarded_for])
    app["config"] = config

    # Check configured base url.
    assert config.get("server", "base_url").startswith("http")
    assert config.get("server", "base_url").endswith("/")

    # Configure templating.
    app["jinja"] = jinja2.Environment(
        loader=jinja2.FileSystemLoader("templates"),
        autoescape=jinja2.select_autoescape(["html"]))
    app["jinja"].globals["DEFAULT_FEN"] = DEFAULT_FEN
    app["jinja"].globals["STARTING_FEN"] = chess.STARTING_FEN
    app["jinja"].globals["development"] = config.getboolean("server", "development")
    app["jinja"].globals["asset_url"] = asset_url
    app["jinja"].globals["kib"] = kib

    # Load stats.
    with open("stats.json") as f:
        app["stats"] = json.load(f)

    # Setup routes.
    app.router.add_routes(routes)
    app.router.add_static("/static", "static")
    app.router.add_route("GET", "/checksums/bytes.tsv", static("checksums/bytes.tsv"))
    app.router.add_route("GET", "/checksums/tbcheck.txt", static("checksums/tbcheck.txt", content_type="text/plain"))
    app.router.add_route("GET", "/checksums/PackManifest", static("checksums/PackManifest", content_type="text/plain"))
    app.router.add_route("GET", "/checksums/B2SUM", static("checksums/B2SUM", content_type="text/plain"))
    app.router.add_route("GET", "/checksums/MD5SUM", static("checksums/MD5SUM", content_type="text/plain"))
    app.router.add_route("GET", "/checksums/SHA1SUM", static("checksums/SHA1SUM", content_type="text/plain"))
    app.router.add_route("GET", "/checksums/SHA256SUM", static("checksums/SHA256SUM", content_type="text/plain"))
    app.router.add_route("GET", "/checksums/SHA512SUM", static("checksums/SHA512SUM", content_type="text/plain"))
    app.router.add_route("GET", "/endgames.pgn", static("stats/regular/maxdtz.pgn", content_type="application/x-chess-pgn"))
    app.router.add_route("GET", "/stats.json", static("stats.json"))
    return app


def main(argv):
    logging.basicConfig(level=logging.DEBUG)

    config = configparser.ConfigParser()
    config.read([
        os.path.join(os.path.dirname(__file__), "config.default.ini"),
        os.path.join(os.path.dirname(__file__), "config.ini"),
    ] + argv)

    bind = config.get("server", "bind")
    port = config.getint("server", "port")

    app = make_app(config)

    print("* Server name: ", config.get("server", "name"))
    print("* Base url: ", config.get("server", "base_url"))
    aiohttp.web.run_app(app, host=bind, port=port, access_log=None)


if __name__ == "__main__":
    main(sys.argv[1:])