"""A minimal HTTP/1.1 server on a raw TCP socket, with pluggable response framing.

There is no framework here and no `http.server`: a listening socket, a read
loop, some string splitting, and a `sendall` of bytes you assembled yourself.
That much is only the setup. The toy is the FRAMINGS table below -- ten ways
to end a response body, of which only some tell the client where the body
stopped. TCP will not tell it. TCP has no messages, only bytes.

Run one framing by hand:

    python3 http_server.py --mode none --after hold --port 8127
    curl -s --max-time 1 http://127.0.0.1:8127/hello ; echo "exit=$?"
"""
import argparse
import socket
import sys
import time

BODY = b"the framing is the message\n"          # 27 bytes
HOLD_SECONDS = 30                               # outlives any client timeout


def log(message):
    """Server chatter goes to stderr, so it can't contaminate curl's stdout."""
    print(message, file=sys.stderr, flush=True)


def head(status_line, *headers):
    """Status line + headers + the blank line that ends the head."""
    out = status_line + "\r\n"
    for header in headers:
        out += header + "\r\n"
    return (out + "\r\n").encode()


def chunk(part):
    """One chunk of a chunked body: hex length, CRLF, the bytes, CRLF."""
    return b"%x\r\n" % len(part) + part + b"\r\n"


# --- the ten framings ------------------------------------------------------
# Each takes the body and returns the exact bytes that go on the wire.

def frame_correct(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
                "Content-Length: %d" % len(body)) + body


def frame_none(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain") + body


def frame_short(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
                "Content-Length: 10") + body


def frame_long(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
                "Content-Length: %d" % (len(body) + 20)) + body


def frame_chunked(body):
    return (head("HTTP/1.1 200 OK", "Transfer-Encoding: chunked")
            + chunk(body[:13]) + chunk(body[13:]) + b"0\r\n\r\n")


def frame_chunked_noterm(body):
    return (head("HTTP/1.1 200 OK", "Transfer-Encoding: chunked")
            + chunk(body[:13]) + chunk(body[13:]))


def frame_close_header_only(body):
    return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
                "Connection: close") + body


def frame_http10(body):
    return head("HTTP/1.0 200 OK", "Content-Type: text/plain") + body


def frame_both(body):
    return (head("HTTP/1.1 200 OK", "Content-Length: 99",
                 "Transfer-Encoding: chunked") + chunk(body) + b"0\r\n\r\n")


def frame_both_te_first(body):
    return (head("HTTP/1.1 200 OK", "Transfer-Encoding: chunked",
                 "Content-Length: 99") + chunk(body) + b"0\r\n\r\n")


FRAMINGS = {
    "correct":           (frame_correct, "Content-Length: 27, the true length"),
    "none":              (frame_none, "no Content-Length, no chunked, nothing"),
    "short":             (frame_short, "Content-Length: 10 for a 27-byte body"),
    "long":              (frame_long, "Content-Length: 47 for a 27-byte body"),
    "chunked":           (frame_chunked, "two chunks + the 0-length terminator"),
    "chunked-noterm":    (frame_chunked_noterm, "two chunks, terminator omitted"),
    "close-header-only": (frame_close_header_only, "Connection: close, no length"),
    "http10":            (frame_http10, "HTTP/1.0 status line, no length"),
    "both":              (frame_both, "Content-Length: 99 AND chunked"),
    "both-te-first":     (frame_both_te_first, "chunked AND Content-Length: 99"),
}


def read_request(conn, bufsize=4096):
    """Read one request; return (request_line, headers, body, recv_sizes).

    Two loops, because two different rules end two different things. The head
    ends at a blank line -- a delimiter you scan for, so you must be willing
    to call recv() again mid-header. The body ends after Content-Length bytes
    -- a count you subtract from. There is no third rule and, if the header is
    absent, no rule at all: `want` is 0 and any body sits unread in the
    kernel's buffer. That asymmetry is the whole toy, seen from the other end.
    """
    buf, sizes = b"", []
    while b"\r\n\r\n" not in buf:
        data = conn.recv(bufsize)
        if not data:                            # client closed: no request
            return None, {}, b"", sizes
        sizes.append(len(data))
        buf += data
    raw_head, rest = buf.split(b"\r\n\r\n", 1)
    lines = raw_head.split(b"\r\n")
    request_line = lines[0].decode("latin-1")
    headers = {}
    for line in lines[1:]:
        name, _, value = line.partition(b":")
        headers[name.decode("latin-1").lower()] = value.strip().decode("latin-1")
    want = int(headers.get("content-length", 0))
    while len(rest) < want:
        data = conn.recv(bufsize)
        if not data:
            break
        sizes.append(len(data))
        rest += data
    return request_line, headers, rest, sizes


def serve(port, mode, after, bufsize=4096, body=BODY):
    """Accept connections forever, answering each request with `mode`'s framing.

    `after` decides what becomes of the socket once the response is written,
    and it is the second half of every result this toy reports:

        fin        close() at once -- the FIN itself frames the body
        hold       leave it open and stop reading -- the client is on its own
        keepalive  go round again for the next request on this connection
    """
    build, _ = FRAMINGS[mode]
    lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    lsock.bind(("127.0.0.1", port))
    lsock.listen(8)
    log("listening on 127.0.0.1:%d mode=%s after=%s bufsize=%d"
        % (port, mode, after, bufsize))
    conns = 0
    while True:
        conn, _ = lsock.accept()
        conns += 1
        log("connection %d accepted" % conns)
        while True:
            request_line, _, req_body, sizes = read_request(conn, bufsize)
            if request_line is None:
                log("connection %d closed by client" % conns)
                break
            log("  %s | recv sizes %s | request body %d bytes"
                % (request_line, sizes, len(req_body)))
            conn.sendall(build(body))
            if after == "keepalive":
                continue
            if after == "hold":
                time.sleep(HOLD_SECONDS)
            conn.close()
            break


def main():
    parser = argparse.ArgumentParser(description="one framing at a time")
    parser.add_argument("--mode", default="correct", choices=sorted(FRAMINGS))
    parser.add_argument("--after", default="fin",
                        choices=("fin", "hold", "keepalive"))
    parser.add_argument("--port", type=int, default=8127)
    parser.add_argument("--bufsize", type=int, default=4096)
    args = parser.parse_args()
    try:
        serve(args.port, args.mode, args.after, args.bufsize)
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
