"""Serve a downloaded manual locally, so it can be read in a browser.

    serve.cmd                                   (what a reader double-clicks)
    python tools/serve_manual.py                (serve the current folder)
    python tools/serve_manual.py "<path>" 8801  (maintainer form, unchanged)

With no arguments it serves the current directory, picks a free port and opens
a browser. `--dev` switches to the refactoring behaviour described at the
bottom of this docstring.

Python's own http.server is not enough for this collection on three counts,
each of which is handled below:

* **No byte ranges.** The stdlib has no Range support at all. Nearly every
  cross-reference in these manuals is a `<file>.pdf#page=N` link, so without
  Range the browser downloads a whole PDF to show one page. Single ranges are
  implemented here, and `Accept-Ranges: bytes` is advertised, matching what
  `nginx.conf` already sends on the hosted side.

* **.svgz / .siasgz are gzip streams under their own extension.** IE's plugins
  unpacked them; a browser only does that when the response says so, and
  otherwise renders nothing. `Content-Encoding: gzip` is therefore sent -- but
  only on a real hit, because these manuals probe for `.siasgz` variants that
  do not exist and a 404 body tagged as gzip is a decoding error rather than a
  clean miss the page can recover from. Range is refused on those responses
  (`Accept-Ranges: none`): a partial gzip stream is not something the client
  can inflate, and advertising ranges we then ignore would let a resuming
  downloader concatenate a full body onto a partial one.

* **HTTP/1.0 and no MIME pinning.** A frameset opens many assets at once, so
  keep-alive matters; and `mimetypes` reads content types out of the Windows
  registry, which differs per machine. Both are pinned below.

* **These manuals are not UTF-8.** Of the manuals still in their original
  IE-era form, most declare something else -- shift_jis and x-euc-jp for the
  JDM Subarus, windows-1251 for the Russian ones, latin-1 for a long tail. A
  charset in the HTTP `Content-Type` overrides the document's own `<meta>`, so
  asserting utf-8 here turns a manual that reads perfectly from disk into
  mojibake. The type is still pinned (see above); the charset is now read back
  out of the file, and omitted when the file does not say.

`--dev` (refactoring an original manual, not reading one): responses are sent
`no-store` and `Last-Modified` is stripped. These manuals ship 2003 dates and
no `Cache-Control`, so a browser's heuristic freshness -- 10% of the file's
age, decades here -- pins the very files an edit touches: a changed toc.js or
a re-saved left_menu HTML is not re-fetched, not even on a normal reload.
A browser that already cached a manual on some host:port keeps those entries,
so serve a fresh refactor on an unused port or hard-reload once (Ctrl+Shift+R).
"""
import argparse
import functools
import http.server
import os
import re
import sys
import webbrowser

# Extensions holding a gzip stream under a name the browser does not recognise
# as compressed. Both need Content-Encoding, and neither can serve a range.
GZIP_EXT = (".svgz", ".siasgz")

# Ports tried in order when none is given. A stable port beats an ephemeral one:
# bookmarks keep working and the browser cache survives a restart.
PORT_LADDER = range(8801, 8811)

RANGE_SPEC = re.compile(r"(\d*)-(\d*)")

# Types whose bytes carry a declared encoding worth echoing back. Anything else
# (json is utf-8 by specification, images and PDFs are binary) is left alone.
CHARSET_TYPES = ("text/", "image/svg+xml", "application/xml",
                 "application/xhtml+xml", "application/javascript")

# How far in to look. The HTML spec obliges a browser to find a meta charset
# within the first 1024 bytes; a hand-edited manual can push it further with a
# long comment, and reading 4 KiB of a file we are about to serve is free.
SNIFF_BYTES = 4096

# What a declaration may name. Deliberately narrow: whatever comes out of this
# is going straight into a response header.
CHARSET_LABEL = re.compile(rb"[A-Za-z][A-Za-z0-9_.:+-]{0,39}")

# `<?xml version="1.0" encoding="Shift_JIS"?>` -- only valid at the very start.
XML_PROLOG = re.compile(rb"^<\?xml[^>]*?encoding\s*=\s*[\"']([^\"']+)[\"']", re.I)
# `@charset "windows-1251";` -- likewise only valid as the first thing in a
# stylesheet, so it is matched anchored rather than searched for.
CSS_CHARSET = re.compile(rb"^@charset\s+\"([^\"]+)\"\s*;", re.I)
# `<meta charset=x>` and the older `<meta http-equiv=Content-Type content="...">`
META_CHARSET = re.compile(rb"<meta\b[^>]*?charset\s*=\s*[\"']?\s*([A-Za-z0-9_.:+-]+)",
                          re.I)

# A byte order mark settles the encoding on its own and outranks any later
# declaration, which is why these are checked first.
BOMS = ((b"\xef\xbb\xbf", "utf-8"),
        (b"\xff\xfe\x00\x00", "utf-32le"), (b"\x00\x00\xfe\xff", "utf-32be"),
        (b"\xff\xfe", "utf-16le"), (b"\xfe\xff", "utf-16be"))


# Windows still refuses paths over 259 characters unless LongPathsEnabled is
# set. The deepest path inside a manual runs to ~185 characters, so a root much
# longer than this leaves no headroom and files 404 that are plainly on disk.
LONG_PATH_ROOT = 110
MAX_PATH = 260


def sniff_charset(head):
    """The encoding these bytes declare, or None if they declare nothing.

    None is a real answer, not a failure: it means the browser should apply its
    own rules, which is exactly right for a `.js` file (it inherits the
    document's encoding) and better than a guess of ours for anything else.
    """
    for bom, label in BOMS:
        if head.startswith(bom):
            return label
    for rx in (XML_PROLOG, CSS_CHARSET):
        m = rx.match(head)
        if m:
            return _clean(m.group(1))
    # Only the head of the document may declare one, and scanning the whole
    # window would let a charset named in prose or in a code sample win.
    m = META_CHARSET.search(head)
    if m:
        return _clean(m.group(1))
    return None


def _clean(label):
    """A declared label, or None if it is not something we will put in a header."""
    m = CHARSET_LABEL.fullmatch(label.strip())
    return m.group(0).decode("ascii").lower() if m else None


class Handler(http.server.SimpleHTTPRequestHandler):
    protocol_version = "HTTP/1.1"   # framesets open many assets; keep-alive matters
    # BaseHTTPRequestHandler defaults to no timeout, and ThreadingHTTPServer
    # pins a thread per keep-alive socket (a browser holds ~6 per origin) for
    # as long as the tab is open.
    timeout = 60

    dev = False
    verbose = False

    # Pinned rather than guessed: guess_type consults extensions_map before
    # mimetypes, and mimetypes.init() reads HKCR\.js\Content Type, so an
    # installer that wrote text/plain there would break a reader's manual.
    #
    # The type is pinned; the charset is not stated here. guess_type() below
    # reads it back out of the file, because these manuals predate utf-8 and a
    # charset asserted in the header silently overrides the one the document
    # declares about itself.
    extensions_map = {
        **http.server.SimpleHTTPRequestHandler.extensions_map,
        ".html": "text/html",
        ".htm": "text/html",
        ".js": "text/javascript",
        ".css": "text/css",
        ".xml": "text/xml",
        ".xsl": "text/xml",
        ".txt": "text/plain",
        ".json": "application/json",
        ".svg": "image/svg+xml",
        ".svgz": "image/svg+xml",
        ".siasgz": "image/svg+xml",
        ".pdf": "application/pdf",
        ".png": "image/png",
        ".gif": "image/gif",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".ico": "image/x-icon",
        ".webp": "image/webp",
        ".woff": "font/woff",
        ".woff2": "font/woff2",
    }

    # Class-level defaults so a request that never reaches _begin() -- an
    # unsupported method answered by send_error(), say -- still has them.
    _status = None
    _tpath = ""
    _gzip = None
    _range = None
    _range_out = None

    # ---- per-request state ------------------------------------------------

    def do_GET(self):
        if self._begin():
            super().do_GET()

    def do_HEAD(self):
        if self._begin():
            super().do_HEAD()

    def _begin(self):
        """Reset state and parse Range. False means a response was already sent."""
        self._status = None
        self._tpath = self.translate_path(self.path)
        self._gzip = None
        self._range = None
        self._range_out = None
        if self.dev:
            # send_head runs its own If-Modified-Since comparison against the
            # file's mtime, before any header of ours is written -- so dropping
            # Last-Modified on the way out is not enough. A browser holding a
            # copy from an earlier session would still be told 304 and would
            # keep showing the file being edited.
            del self.headers["If-Modified-Since"]
            del self.headers["If-None-Match"]
        return self._prepare_range()

    # ---- content type -----------------------------------------------------

    # A frameset opens a few hundred assets, and a reader walks back over the
    # same shell on every page, so the same handful of files are typed over and
    # over. Keyed on identity, not just path, so an edit is picked up at once.
    _charset_cache = {}
    _CHARSET_CACHE_MAX = 4096

    def guess_type(self, path):
        """The pinned type, plus whatever charset the file declares about itself."""
        ctype = super().guess_type(path)
        if not ctype.startswith(CHARSET_TYPES):
            return ctype
        # .svgz/.siasgz hold a gzip stream: the head is deflate output, not a
        # declaration, and Content-Encoding already tells the browser that.
        if os.path.splitext(path)[1].lower() in GZIP_EXT:
            return ctype
        charset = self._charset_for(path)
        return "%s; charset=%s" % (ctype, charset) if charset else ctype

    def _charset_for(self, path):
        try:
            st = os.stat(path)
        except OSError:
            return None
        key = (path, st.st_mtime_ns, st.st_size)
        if key in self._charset_cache:
            return self._charset_cache[key]
        try:
            with open(path, "rb") as fh:
                charset = sniff_charset(fh.read(SNIFF_BYTES))
        except OSError:
            # send_head is about to open the same file and report the failure
            # properly; typing it is not this method's job.
            return None
        # A collection this size can hold more distinct files than the cache,
        # and an unbounded dict on a long-lived server is a slow leak.
        if len(self._charset_cache) >= self._CHARSET_CACHE_MAX:
            self._charset_cache.clear()
        self._charset_cache[key] = charset
        return charset

    def _is_gzip_ext(self):
        """Sole authority for both the Content-Encoding header and the Range gate.

        Deciding this from the URL in one place and the file path in another is
        how the two drift apart on a path like /a/b%2Esvgz.
        """
        if self._gzip is None:
            self._gzip = os.path.splitext(self._tpath)[1].lower() in GZIP_EXT
        return self._gzip

    # ---- Range ------------------------------------------------------------

    def _prepare_range(self):
        """Parse a satisfiable single range, or leave the response a plain 200."""
        spec = self.headers.get("Range")
        if not spec:
            return True
        spec = spec.strip()
        if not spec.lower().startswith("bytes="):
            return True
        spec = spec[6:].strip()
        # Multipart/byteranges is not worth writing: no browser needs it to read
        # a PDF, and a single wrong boundary corrupts the whole response.
        if "," in spec:
            return True
        # Directory listings and 404s never carry a range.
        if not os.path.isfile(self._tpath):
            return True
        # A byte range of a gzip stream is legal on the wire but useless to a
        # client that has to inflate it.
        if self._is_gzip_ext():
            return True
        try:
            st = os.stat(self._tpath)
        except OSError:
            return True
        # If-Range makes resumption safe when a file is replaced mid-download.
        if_range = self.headers.get("If-Range")
        if if_range is not None and if_range.strip() != self.date_time_string(st.st_mtime):
            return True

        m = RANGE_SPEC.fullmatch(spec)
        if not m:
            return True
        first, last = m.group(1), m.group(2)
        if first == "":
            if last == "":
                return True
            suffix = int(last)
            if suffix == 0:
                return self._send_416(st.st_size)
            self._range = ("suffix", suffix, None)
        else:
            start = int(first)
            if start >= st.st_size:
                return self._send_416(st.st_size)
            end = None if last == "" else int(last)
            if end is not None and end < start:
                return True
            self._range = ("first", start, end)
        return True

    def _resolve_range(self, size):
        """Turn the parsed spec into (start, end) against the authoritative size."""
        kind, a, b = self._range
        if kind == "suffix":
            start, end = max(0, size - a), size - 1
        else:
            start = a
            end = size - 1 if b is None else min(b, size - 1)
        # Defensive only: _prepare_range already checked this file's size, and
        # send_head has the descriptor open by now, so it cannot have changed.
        start = min(start, max(size - 1, 0))
        return start, max(end, start)

    def _send_416(self, size):
        self.send_response(416)
        self.send_header("Content-Range", "bytes */%d" % size)
        self.send_header("Content-Length", "0")
        # Say so on the wire, not just in our own state: under HTTP/1.1 the
        # client otherwise keeps the connection and waits for a reply we will
        # never send.
        self.send_header("Connection", "close")
        self.end_headers()
        self.close_connection = True
        return False

    # ---- header rewriting -------------------------------------------------
    #
    # Safe because BaseHTTPRequestHandler buffers the status line and every
    # header in _headers_buffer and only writes to the socket in end_headers().

    def send_response_only(self, code, message=None):
        self._status = code
        if code == 200 and self._range is not None:
            self._status = code = 206
        super().send_response_only(code, message)

    def send_header(self, keyword, value):
        low = keyword.lower()
        # Only while refactoring: for a reader, Last-Modified is what makes the
        # cheap 304 revalidation below possible.
        if low == "last-modified" and self.dev:
            return
        if low == "content-length" and self._status == 206 and self._range is not None:
            # This length comes from os.fstat on the descriptor send_head just
            # opened, so it cannot disagree with what copyfile will read.
            size = int(value)
            start, end = self._resolve_range(size)
            self._range_out = (start, end - start + 1)
            super().send_header("Content-Length", str(end - start + 1))
            super().send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size))
            return
        super().send_header(keyword, value)

    def end_headers(self):
        if self._status in (200, 206):
            gzipped = self._is_gzip_ext()
            # Advertising bytes on a response whose Range we then ignore would
            # let a resuming downloader append a full body to a partial one.
            self.send_header("Accept-Ranges", "none" if gzipped else "bytes")
            if gzipped:
                self.send_header("Content-Encoding", "gzip")
        self.send_header("Cache-Control",
                         "no-store, must-revalidate" if self.dev else "no-cache")
        super().end_headers()

    # ---- body -------------------------------------------------------------

    def copyfile(self, source, outputfile):
        try:
            if self._range_out is None:
                return super().copyfile(source, outputfile)
            start, remaining = self._range_out
            source.seek(start)
            while remaining:
                chunk = source.read(min(65536, remaining))
                if not chunk:
                    # We promised more bytes than the file holds; the only
                    # honest way out of a keep-alive connection is to close it.
                    self.close_connection = True
                    return
                outputfile.write(chunk)
                remaining -= len(chunk)
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
            # Under HTTP/1.1 an unhandled error mid-body leaves a desynced
            # socket, and the next request is parsed out of the tail of a PDF.
            self.close_connection = True

    def log_message(self, fmt, *args):
        # Only the misses matter when checking a manual for gaps -- and reading
        # one now produces hundreds of 206s.
        msg = fmt % args
        if self.verbose or not any(" %s " % c in msg for c in ("200", "206", "304")):
            super().log_message(fmt, *args)


class Server(http.server.ThreadingHTTPServer):
    # The stdlib default is True, and on Windows SO_REUSEADDR lets a second
    # socket bind a port another process is already listening on -- which makes
    # the port ladder below report a free port that is not free.
    allow_reuse_address = False
    daemon_threads = True


def long_paths(root):
    """Absolute paths at or over the Windows limit, which 404 for no visible reason."""
    over = []
    for dirpath, _dirnames, filenames in os.walk(root):
        for name in filenames:
            full = os.path.join(dirpath, name)
            if len(full) >= MAX_PATH:
                over.append(full)
    return over


def listen(bind, port, handler):
    """Bind the given port, or walk the ladder and fall back to an ephemeral one."""
    if port is not None:
        return Server((bind, port), handler)
    for candidate in PORT_LADDER:
        try:
            return Server((bind, candidate), handler)
        except OSError:
            continue
    return Server((bind, 0), handler)


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("root", nargs="?", default=".", help="manual folder (default: here)")
    ap.add_argument("port", nargs="?", type=int, default=None,
                    help="port (default: first free from 8801)")
    ap.add_argument("--dev", action="store_true",
                    help="refactoring mode: no-store, no Last-Modified")
    ap.add_argument("--no-browser", action="store_true", help="do not open a browser")
    ap.add_argument("-v", "--verbose", action="store_true", help="log every request")
    ap.add_argument("--check", action="store_true",
                    help="list files whose path is too long for Windows, and exit")
    ap.add_argument("--bind", default="127.0.0.1", help=argparse.SUPPRESS)
    args = ap.parse_args(argv)

    root = os.path.abspath(args.root)
    if not os.path.isdir(root):
        sys.exit("not a directory: %s" % root)

    if args.check:
        over = long_paths(root)
        for path in over:
            print("%d  %s" % (len(path), path))
        print("%d file(s) at or over %d characters" % (len(over), MAX_PATH))
        return 0

    Handler.dev = args.dev
    Handler.verbose = args.verbose
    handler = functools.partial(Handler, directory=root)

    try:
        server = listen(args.bind, args.port, handler)
    except OSError as exc:
        sys.exit("cannot listen on port %s: %s" % (args.port, exc))
    port = server.server_address[1]
    url = "http://%s:%d/" % (args.bind, port)

    print("Serving %s" % root)
    print("  %s" % url)
    if args.dev:
        print("  --dev: caching disabled, Last-Modified stripped")
    if os.name == "nt" and len(root) > LONG_PATH_ROOT:
        print("  warning: this folder's path is long. Windows limits a full path to")
        print("  %d characters, and files past it will 404. Move the manual somewhere"
              % MAX_PATH)
        print("  shorter such as C:\\Manuals, or run again with --check to list them.")
    print("Press Ctrl+C to stop.", flush=True)

    if not args.no_browser:
        # No sleep needed: HTTPServer.__init__ has already bound *and* listened,
        # so a connection arriving before serve_forever() waits in the backlog.
        webbrowser.open(url)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nstopped")
    finally:
        server.server_close()
    return 0


if __name__ == "__main__":
    sys.exit(main())
