#!/usr/bin/env python3

"""
Download a file if it has changed.

For usage information:
    download-if-changed -h

This script does not change the modification time of OUTPUT_FILE if the remote
resource has not changed.  It writes to OUTPUT_FILE.etag to avoid re-downloading
when possible.
"""

import argparse
import collections
import json
import os
import re
import shutil
import sys
import subprocess
import tempfile
import time
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError

arg_parser = argparse.ArgumentParser(description="Downloader")
arg_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode")
arg_parser.add_argument("URL", help="URL to download")
arg_parser.add_argument("OUT_FILE", help="Output file")
args = arg_parser.parse_args()

def warn(s):
    print("WARNING:", s)

def info(s):
    if args.verbose:
        print(s)

url = args.URL
out_path = args.OUT_FILE
out_etag_path = out_path + ".etag"
CacheControl = collections.namedtuple("CacheControl", ["cache_allowed", "public", "max_age_seconds"])

def format_date(secs_since_epoch):
    if secs_since_epoch is None:
        return None
    return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(secs_since_epoch))

out_etag = None
cc = None
last_modified = None
now = time.time()
if os.path.exists(out_path):
    try:
        with open(out_etag_path, "r") as f:
            d = json.load(f)
            out_etag = d.get("etag")
            cc = CacheControl(**d["cache-control"]) if "cache-control" in d else None
            last_modified = int(d["last-check-time"]) if "last-check-time" in d else None
        if cc is not None:
            cc = CacheControl(*cc)
    except FileNotFoundError:
        info("File {} did not exist, skipping etag checking".format(out_etag_path))
        pass
    except Exception as e:
        warn("Failed to read {}".format(out_etag_path))
        info("Error was: {!r}".format(e))
    info("Old ETag: {!r}".format(out_etag))
    info("Cache control: {!r}".format(cc))
    info("Last checked: {}".format(format_date(last_modified)))
    if cc is not None and cc.cache_allowed and last_modified + cc.max_age_seconds > now:
        info("Cache control allows skipping HTTP request")
        sys.exit(0)

def parse_cache_control_header(s):
    parts = [part.strip().lower() for part in s.split(",")]
    cache_allowed = True
    public = True
    max_age_seconds = 0
    for p in parts:
        if not p:
            continue
        if p == "private":
            public = False
        elif p == "no-cache":
            cache_allowed = False
        elif re.match(r"^max-age=(\d+)$", p):
            max_age_seconds = int(p[8:])
        else:
            warn("unknown value in Cache-Control header: {!r}".format(p))
    return CacheControl(cache_allowed=cache_allowed, public=public, max_age_seconds=max_age_seconds)

info("Checking remote resource {} ---> {}".format(url, out_path))
try:
    etag = out_etag
    headers = { }
    if out_etag is not None:
        headers["If-None-Match"] = out_etag
        info("Header If-None-Match: {}".format(out_etag))
    if last_modified is not None:
        t = format_date(last_modified)
        headers["If-Modified-Since"] = t
        info("Header If-Modified-Since: {}".format(t))
    with urlopen(
        Request(url,
            headers={"If-None-Match": out_etag} if out_etag is not None else {})) as response:
        with tempfile.NamedTemporaryFile(mode="wb", delete=False) as tmp_file:
            etag = response.getheader("ETag")
            cc   = response.getheader("Cache-Control")
            info("Got a response [etag={!r}, cache={!r}]".format(etag, cc))
            cc = parse_cache_control_header(cc or "")
            shutil.copyfileobj(response, tmp_file)
            tmp_file.write(response.read())
        if subprocess.run(["cmp", out_path, tmp_file.name]).returncode != 0:
            print("Fetched new version of {}".format(out_path))
            os.rename(tmp_file.name, out_path)
        else:
            info("Bytes of {} are unchanged".format(out_path))
            os.unlink(tmp_file.name)
except HTTPError as e:
    if e.code == 304:
        info("Remote server reports {} is unchanged".format(out_path))
    else:
        raise
except URLError:
    warn("Failed to update {}... are you offline?".format(out_path))
    sys.exit(0 if os.path.exists(out_path) else 1)

info("Dumping cache info to {}".format(out_etag_path))
with open(out_etag_path, "w") as f:
    json.dump({
        "etag": etag,
        "cache-control": cc._asdict(),
        "last-check-time": now}, f)
