#!/usr/bin/env python3
"""Un proxy con cache, para ver max-age, Age y la revalidación con ETag.

    python3 mini_cache.py <dirección> <puerto>

El cliente lo usa con `curl -x`. Solo entiende GET por HTTP sin cifrar, y guarda en memoria.
Cada respuesta lleva X-Cache: HIT (no tocó el origen), REVALIDATED (preguntó y recibió 304),
MISS (bajó todo) o BYPASS (no se puede guardar).
"""
import http.client
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit

store = {}
lock = threading.Lock()
HOP_BY_HOP = {"connection", "keep-alive", "proxy-connection", "transfer-encoding", "te", "upgrade"}


def cache_directives(value):
    """Convierte `max-age=60, no-cache` en {"max-age": "60", "no-cache": ""}."""
    directives = {}
    for item in (value or "").split(","):
        name, _, argument = item.strip().partition("=")
        if name:
            directives[name.lower()] = argument
    return directives


def freshness_lifetime(cache_control):
    """Cuántos segundos está fresca una respuesta, o 0 si hay que revalidar siempre."""
    directives = cache_directives(cache_control)
    if "no-cache" in directives:
        return 0
    try:
        return max(0, int(directives.get("max-age", "0")))
    except ValueError:
        return 0


def storable(status, cache_control, etag):
    """Aquí guardamos un 200 si trae max-age o ETag, y si no lo prohíbe no-store."""
    directives = cache_directives(cache_control)
    if status != 200 or "no-store" in directives or "private" in directives:
        return False
    return freshness_lifetime(cache_control) > 0 or etag is not None


class Entry:
    def __init__(self, status, headers, body, now):
        self.status = status
        self.headers = headers
        self.body = body
        self.stored_at = now

    def header(self, name):
        return next((value for key, value in self.headers if key.lower() == name), None)

    def age(self, now):
        return int(now - self.stored_at)

    def fresh(self, now):
        return self.age(now) < freshness_lifetime(self.header("cache-control"))


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "mini_cache"
    disable_nagle_algorithm = True

    def version_string(self):
        return self.server_version

    def log_message(self, format, *args):
        pass

    def fetch(self, parts, extra_headers=()):
        conn = http.client.HTTPConnection(parts.hostname, parts.port or 80, timeout=5)
        try:
            path = parts.path or "/"
            headers = {"Host": parts.netloc, "Via": "1.1 mini_cache", **dict(extra_headers)}
            conn.request("GET", path, headers=headers)
            response = conn.getresponse()
            kept = [(key, value) for key, value in response.getheaders()
                    if key.lower() not in HOP_BY_HOP | {"content-length"}]
            return response.status, kept, response.read()
        finally:
            conn.close()

    def forward(self, status, headers, body, state, age=None):
        self.send_response_only(status)
        for name, value in headers:
            if name.lower() != "age":
                self.send_header(name, value)
        if age is not None:
            self.send_header("Age", str(age))
        self.send_header("Via", "1.1 mini_cache")
        self.send_header("X-Cache", state)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
        print(f"{state} {self.path}", flush=True)

    def do_GET(self):
        parts = urlsplit(self.path)
        if parts.scheme != "http" or not parts.hostname:
            self.send_error(400, "use una URL completa: esto es un proxy")
            return
        key = self.path
        now = time.monotonic()
        with lock:
            entry = store.get(key)
        try:
            if entry and entry.fresh(now):
                return self.forward(entry.status, entry.headers, entry.body, "HIT", entry.age(now))
            conditional = []
            if entry and entry.header("etag"):
                conditional = [("If-None-Match", entry.header("etag"))]
            status, headers, body = self.fetch(parts, conditional)
        except OSError as error:
            self.send_error(502, f"el origen no responde: {error}")
            return
        now = time.monotonic()
        if status == 304 and entry:
            # El origen confirma que sigue igual: se renueva sin volver a bajar el cuerpo.
            with lock:
                entry.stored_at = now
            return self.forward(entry.status, entry.headers, entry.body, "REVALIDATED", 0)
        lookup = dict((name.lower(), value) for name, value in headers)
        if storable(status, lookup.get("cache-control"), lookup.get("etag")):
            with lock:
                store[key] = Entry(status, headers, body, now)
            return self.forward(status, headers, body, "MISS")
        with lock:
            store.pop(key, None)
        return self.forward(status, headers, body, "BYPASS")


def main():
    if len(sys.argv) != 3:
        sys.exit("uso: mini_cache.py <dirección> <puerto>")
    server = ThreadingHTTPServer((sys.argv[1], int(sys.argv[2])), Handler)
    print(f"mini_cache en {sys.argv[1]}:{sys.argv[2]}", flush=True)
    server.serve_forever()


if __name__ == "__main__":
    main()
