#!/usr/bin/env python3
"""Read bounded public WRS JSON with an identified application User-Agent.

python wrs-read.py /data/catalogue/manifest.json
python wrs-read.py /data/start-projects.json
Use --sha256 and --bytes from a manifest when reading its selected shard.
No sign-in, cookies, credential file, submission or model execution is used.
"""
import argparse
import hashlib
import json
import sys
from urllib.error import HTTPError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener

USER_AGENT = "WRS-ReadClient/1.0 (+https://worldresearchstate.com/data/)"
LIMIT = 2 * 1024 * 1024


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("Duplicate JSON field")
        result[key] = value
    return result


def read_public(path, base="https://worldresearchstate.com", expected_hash=None,
                expected_bytes=None):
    origin = urlsplit(base)
    if (origin.username or origin.password or origin.query or origin.fragment
            or origin.path not in ("", "/") or not origin.hostname
            or not (origin.scheme == "https" or origin.scheme == "http"
                    and origin.hostname in ("127.0.0.1", "localhost", "::1"))):
        raise ValueError("Use an HTTPS origin, or loopback HTTP for a local test")
    part = urlsplit(path)
    if (part.scheme or part.netloc or part.fragment or "\\" in path
            or "%" in part.path or ".." in part.path.split("/")
            or not part.path.startswith(("/data/", "/v1/", "/.well-known/"))):
        raise ValueError("Use a public JSON path on the selected WRS origin")
    if expected_bytes is not None and not 0 <= expected_bytes <= LIMIT:
        raise ValueError("Expected size exceeds the public-read budget")
    if expected_hash is not None:
        expected_hash = expected_hash.removeprefix("sha256:")
        if len(expected_hash) != 64 or any(c not in "0123456789abcdef" for c in expected_hash):
            raise ValueError("Invalid expected SHA-256")
    request = Request(base.rstrip("/") + path, headers={
        "User-Agent": USER_AGENT, "Accept": "application/json",
        "Accept-Encoding": "identity"})
    try:
        with build_opener(NoRedirect()).open(request, timeout=20) as response:
            if response.headers.get_content_type() != "application/json":
                raise ValueError("Expected JSON; no HTML or challenge page is accepted")
            if response.headers.get("Content-Encoding", "identity") != "identity":
                raise ValueError("Unsupported content encoding")
            body = response.read(LIMIT + 1)
    except HTTPError as error:
        raise ValueError("Public read returned HTTP " + str(error.code)
                         + "; redirects and authentication are not followed") from error
    if len(body) > LIMIT:
        raise ValueError("Read exceeds 2 MiB; use the catalogue manifest and selected shards")
    if expected_bytes is not None and len(body) != expected_bytes:
        raise ValueError("Byte count differs from the selected manifest")
    if expected_hash is not None and hashlib.sha256(body).hexdigest() != expected_hash:
        raise ValueError("SHA-256 differs from the selected manifest; refresh once, do not mix versions")
    return json.loads(body.decode("utf-8"), object_pairs_hook=unique_object,
                      parse_constant=lambda _: (_ for _ in ()).throw(ValueError("Non-finite JSON number")))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("path", nargs="?", default="/data/catalogue/manifest.json")
    parser.add_argument("--base", default="https://worldresearchstate.com")
    parser.add_argument("--sha256")
    parser.add_argument("--bytes", type=int)
    args = parser.parse_args()
    try:
        result = read_public(args.path, args.base, args.sha256, args.bytes)
        print(json.dumps(result, ensure_ascii=True, indent=2, allow_nan=False))
    except (ValueError, OSError, RecursionError) as error:
        parser.exit(1, "WRS read failed: " + str(error) + "\n")


if __name__ == "__main__":
    main()
