#!/usr/bin/env python3
"""
URL Shortener — stdlib-only, Python 3.8+
Usage: python urlshort.py <config.json>

API (all routes are prefixed with base_path, e.g. /s):
  GET  /                        Frontend (or health check if no static/)
  GET  /api/health              Health check
  POST /api/shorten             Create a short URL  (API key required)
  GET  /api/urls                List all short URLs  (API key required)
  GET  /api/urls/<code>         Get info for a code  (no API key required)
  GET  /api/lookup?url=<url>    Look up by original URL (no API key required)
  DELETE /api/urls/<code>       Delete a short URL   (API key required)
  GET  /<code>                  Redirect to original URL
"""

import json
import logging
import os
import random
import sqlite3
import string
import sys
import time
import collections
import mimetypes
import re
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse

# ---------------------------------------------------------------------------
# Logging — outputs to console (stdout); Docker captures it automatically.
# ---------------------------------------------------------------------------

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    stream=sys.stdout,
)
log = logging.getLogger("urlshort")


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

def load_config(path: str) -> dict:
    with open(path, "r", encoding="utf-8") as fh:
        cfg = json.load(fh)

    for key in ("base_url", "api_key"):
        if key not in cfg:
            raise ValueError(f"Missing required config key: '{key}'")

    # Backward compat: old 'short_length' → 'min_short_length'
    if "short_length" in cfg and "min_short_length" not in cfg:
        cfg["min_short_length"] = cfg["short_length"]

    cfg.setdefault("host", "0.0.0.0")
    cfg.setdefault("port", 8080)
    cfg.setdefault("db_path", "data/urlshort.db")
    cfg.setdefault("retention_days", 0)
    cfg.setdefault("min_short_length", 6)
    cfg.setdefault("max_short_length", 32)
    cfg.setdefault("max_url_length", 2048)
    cfg.setdefault("max_retention_days", 3650)
    cfg.setdefault("rate_limit_requests", 60)
    cfg.setdefault("rate_limit_window", 60)

    # Derive base_path from the path component of base_url.
    raw = urlparse(cfg["base_url"]).path.strip("/")
    cfg["base_path"] = f"/{raw}" if raw else ""

    return cfg


# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------

def init_db(db_path: str) -> None:
    parent = os.path.dirname(db_path)
    if parent:
        os.makedirs(parent, exist_ok=True)

    with sqlite3.connect(db_path) as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS urls (
                short_code     TEXT    PRIMARY KEY,
                original_url   TEXT    NOT NULL,
                created_at     INTEGER NOT NULL,
                visit_count    INTEGER NOT NULL DEFAULT 0,
                retention_days INTEGER NOT NULL DEFAULT 0
            )
        """)
        conn.commit()

        # Migrate: add retention_days column if missing (existing DB).
        try:
            conn.execute(
                "ALTER TABLE urls ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 0"
            )
            conn.commit()
        except sqlite3.OperationalError:
            pass  # column already exists


def db_connect(db_path: str) -> sqlite3.Connection:
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    return conn


def cleanup_expired(db_path: str) -> None:
    """Delete URLs whose retention period has elapsed."""
    now = int(time.time())
    with db_connect(db_path) as conn:
        conn.execute(
            "DELETE FROM urls WHERE retention_days > 0 "
            "AND (created_at + retention_days * 86400) < ?",
            (now,),
        )
        conn.commit()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_CHARS = string.ascii_letters + string.digits


def generate_code(length: int) -> str:
    return "".join(random.choices(_CHARS, k=length))


def is_valid_url(url: str) -> bool:
    try:
        p = urlparse(url)
        return p.scheme in ("http", "https") and bool(p.netloc)
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------

_CODE_RE = re.compile(r'^[A-Za-z0-9]+$')


def is_valid_code(code: str, max_length: int = 32) -> bool:
    """Short codes must be alphanumeric and within length limits."""
    return bool(code) and len(code) <= max_length and bool(_CODE_RE.match(code))


# ---------------------------------------------------------------------------
# Static file serving
# ---------------------------------------------------------------------------

STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")


# ---------------------------------------------------------------------------
# Rate limiter (sliding window, per IP)
# ---------------------------------------------------------------------------

class RateLimiter:
    """Simple in-memory sliding-window rate limiter."""

    def __init__(self, max_requests: int = 60, window: int = 60):
        self.max_requests = max_requests
        self.window = window
        self._hits: dict = collections.defaultdict(list)
        self._lock = threading.Lock()

    def is_allowed(self, ip: str) -> bool:
        now = time.time()
        cutoff = now - self.window
        with self._lock:
            hits = self._hits[ip]
            self._hits[ip] = hits = [t for t in hits if t > cutoff]
            if len(hits) >= self.max_requests:
                return False
            hits.append(now)
            return True


_rate_limiter = RateLimiter()


# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------

class Handler(BaseHTTPRequestHandler):
    """Single handler that serves the whole URL shortener API."""

    # Injected by main() before the server starts.
    cfg: dict = {}

    # ------------------------------------------------------------------
    # Routing helpers
    # ------------------------------------------------------------------

    def _client_ip(self) -> str:
        """Get the real client IP, checking reverse-proxy headers first.

        nginx adds X-Real-IP and X-Forwarded-For via proxy_set_header.
        Without these headers, falls back to the TCP connection source.
        """
        ip = self.headers.get("X-Real-IP", "").strip()
        if ip:
            return ip
        xff = self.headers.get("X-Forwarded-For", "").strip()
        if xff:
            return xff.split(",")[0].strip()
        return self.client_address[0]

    def _local_path(self):
        """Return the request path with base_path prefix stripped.

        Returns None (→ 404) when the request path does not start with
        the configured base_path at all.
        """
        raw = urlparse(self.path).path
        base = self.cfg.get("base_path", "")
        if base:
            if raw == base or raw == base + "/":
                # exact match on the prefix itself → treat as root
                return "/"
            if raw.startswith(base + "/"):
                return raw[len(base):].rstrip("/") or "/"
            # path is outside our prefix entirely
            return None
        return raw.rstrip("/") or "/"

    # ------------------------------------------------------------------
    # Logging
    # ------------------------------------------------------------------

    def log_message(self, fmt, *args):  # noqa: N802 – stdlib override
        log.info("%s - %s", self._client_ip(), fmt % args)

    # ------------------------------------------------------------------
    # Low-level response helpers
    # ------------------------------------------------------------------

    def _send_json(self, status: int, payload: object) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _send_plain(self, status: int, text: str) -> None:
        body = text.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _send_empty(self, status: int) -> None:
        self.send_response(status)
        self.send_header("Content-Length", "0")
        self.end_headers()

    def _error(self, status: int, message: str) -> None:
        self._send_json(status, {"error": message})

    def _redirect(self, location: str) -> None:
        self.send_response(302)
        self.send_header("Location", location)
        self.send_header("Content-Length", "0")
        self.end_headers()

    # ------------------------------------------------------------------
    # CORS & Security headers (injected into every response)
    # ------------------------------------------------------------------

    def end_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.send_header("Access-Control-Max-Age", "86400")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("X-XSS-Protection", "1; mode=block")
        super().end_headers()

    # ------------------------------------------------------------------
    # Auth — purely api_key based (query param or JSON body)
    # ------------------------------------------------------------------

    def _check_api_key(self, body: dict = None) -> bool:
        """Check api_key from query parameter or JSON body."""
        qs = parse_qs(urlparse(self.path).query)
        key = qs.get("api_key", [""])[0]
        if key:
            return key == self.cfg["api_key"]
        if body and isinstance(body, dict):
            key = str(body.get("api_key", ""))
            if key:
                return key == self.cfg["api_key"]
        return False

    # ------------------------------------------------------------------
    # Body
    # ------------------------------------------------------------------

    def _read_json(self):
        length = int(self.headers.get("Content-Length", 0))
        if length == 0:
            return {}
        raw = self.rfile.read(length)
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return None  # signals parse failure to caller

    # ------------------------------------------------------------------
    # Route dispatch
    # ------------------------------------------------------------------

    def do_GET(self):  # noqa: N802
        cleanup_expired(self.cfg["db_path"])

        if not _rate_limiter.is_allowed(self._client_ip()):
            self._error(429, "Too many requests")
            return

        # Redirect bare base path to base path + / for correct relative URLs
        raw = urlparse(self.path).path
        base = self.cfg.get("base_path", "")
        if base and raw == base:
            self._redirect(base + "/")
            return

        path = self._local_path()
        if path is None:
            self._send_empty(404)
            return

        if path == "/":
            # Serve frontend if static/index.html exists, else health check
            index_path = os.path.join(STATIC_DIR, "index.html")
            if os.path.isfile(index_path):
                self._serve_static("index.html")
            else:
                self._send_json(200, {"status": "ok", "service": "url-shortener"})

        elif path == "/api/health":
            self._send_json(200, {"status": "ok", "service": "url-shortener"})

        elif path == "/api/urls":
            self._handle_list_urls()

        elif path.startswith("/api/urls/"):
            code = path[len("/api/urls/"):]
            self._handle_get_url(code)

        elif path == "/api/lookup":
            self._handle_lookup()

        elif path == "/static" or path.startswith("/static/"):
            rel = path[len("/static"):].lstrip("/") or "index.html"
            self._serve_static(rel)

        else:
            code = path.lstrip("/")
            self._handle_redirect(code)

    def do_POST(self):  # noqa: N802
        cleanup_expired(self.cfg["db_path"])

        if not _rate_limiter.is_allowed(self._client_ip()):
            self._error(429, "Too many requests")
            return

        path = self._local_path()
        if path is None:
            self._send_empty(404)
            return

        if path == "/api/shorten":
            self._handle_shorten()
        else:
            self._send_empty(404)

    def do_DELETE(self):  # noqa: N802
        cleanup_expired(self.cfg["db_path"])

        if not _rate_limiter.is_allowed(self._client_ip()):
            self._error(429, "Too many requests")
            return

        path = self._local_path()
        if path is None:
            self._send_empty(404)
            return

        if path.startswith("/api/urls/"):
            code = path[len("/api/urls/"):]
            self._handle_delete_url(code)
        else:
            self._send_empty(404)

    def do_OPTIONS(self):  # noqa: N802
        """Handle CORS preflight requests."""
        self.send_response(200)
        self.send_header("Content-Length", "0")
        self.send_header("Allow", "GET, POST, DELETE, OPTIONS")
        self.end_headers()
        self.wfile.flush()

    # ------------------------------------------------------------------
    # Handlers
    # ------------------------------------------------------------------

    def _handle_shorten(self) -> None:
        # Parse both query parameters and JSON body
        qs = parse_qs(urlparse(self.path).query)
        body = self._read_json()
        if body is None:
            self._error(400, "Invalid JSON body")
            return

        # Auth: check api_key from query param or body
        if not self._check_api_key(body):
            self._send_empty(403)
            return

        # url: query param takes precedence, then body
        original_url = qs.get("url", [""])[0] or str(body.get("url", "")).strip()
        if not original_url:
            self._error(400, "Missing required field: url")
            return
        if not is_valid_url(original_url):
            self._error(400, "Invalid URL — must start with http:// or https://")
            return
        max_url = self.cfg["max_url_length"]
        if len(original_url) > max_url:
            self._error(400, f"URL too long (max {max_url} characters)")
            return

        # retention_days: query param, then body, then config default
        raw_rd = qs.get("retention_days", [None])[0]
        if raw_rd is None:
            raw_rd = body.get("retention_days")
        if raw_rd is not None:
            try:
                retention_days = int(raw_rd)
            except (ValueError, TypeError):
                self._error(400, "Invalid retention_days")
                return
        else:
            retention_days = self.cfg.get("retention_days", 0)

        max_ret = self.cfg["max_retention_days"]
        if not (0 <= retention_days <= max_ret):
            self._error(400, f"retention_days must be 0–{max_ret}")
            return

        with db_connect(self.cfg["db_path"]) as conn:
            short_code = self._unique_code(conn)
            if short_code is None:
                self._error(500, "Could not generate a unique short code — try again")
                return

            created_at = int(time.time())
            conn.execute(
                "INSERT INTO urls (short_code, original_url, created_at, retention_days) "
                "VALUES (?, ?, ?, ?)",
                (short_code, original_url, created_at, retention_days),
            )
            conn.commit()

        short_url = f"{self.cfg['base_url'].rstrip('/')}/{short_code}"
        # Response: only the short URL in plain text
        self._send_plain(201, short_url)

    def _handle_list_urls(self) -> None:
        if not self._check_api_key():
            self._send_empty(403)
            return

        with db_connect(self.cfg["db_path"]) as conn:
            rows = conn.execute(
                "SELECT * FROM urls ORDER BY created_at DESC"
            ).fetchall()

        base = self.cfg["base_url"].rstrip("/")
        urls = []
        for r in rows:
            urls.append({
                "short_code": r["short_code"],
                "short_url": f"{base}/{r['short_code']}",
                "original_url": r["original_url"],
                "created_at": r["created_at"],
                "visit_count": r["visit_count"],
                "retention_days": r["retention_days"],
            })
        self._send_json(200, {"count": len(urls), "urls": urls})

    def _handle_get_url(self, code: str) -> None:
        # No API key required
        if not is_valid_code(code, self.cfg["max_short_length"]):
            self._send_empty(404)
            return

        with db_connect(self.cfg["db_path"]) as conn:
            row = conn.execute(
                "SELECT * FROM urls WHERE short_code = ?", (code,)
            ).fetchone()

        if row is None:
            self._send_empty(404)
            return

        base = self.cfg["base_url"].rstrip("/")
        self._send_json(200, {
            "short_code": row["short_code"],
            "short_url": f"{base}/{row['short_code']}",
            "original_url": row["original_url"],
            "created_at": row["created_at"],
            "visit_count": row["visit_count"],
            "retention_days": row["retention_days"],
        })

    def _handle_delete_url(self, code: str) -> None:
        # API key required — 403 if not authorized (no body)
        if not self._check_api_key():
            self._send_empty(403)
            return
        if not is_valid_code(code, self.cfg["max_short_length"]):
            self._send_empty(404)
            return

        with db_connect(self.cfg["db_path"]) as conn:
            row = conn.execute(
                "SELECT short_code FROM urls WHERE short_code = ?", (code,)
            ).fetchone()
            if row is None:
                # 404 if not found (no body)
                self._send_empty(404)
                return
            conn.execute("DELETE FROM urls WHERE short_code = ?", (code,))
            conn.commit()

        # 204 on success (no body)
        self._send_empty(204)

    def _handle_redirect(self, code: str) -> None:
        if not is_valid_code(code, self.cfg["max_short_length"]):
            self._send_empty(404)
            return

        with db_connect(self.cfg["db_path"]) as conn:
            row = conn.execute(
                "SELECT original_url FROM urls WHERE short_code = ?", (code,)
            ).fetchone()
            if row is None:
                self._error(404, "Short code not found")
                return
            conn.execute(
                "UPDATE urls SET visit_count = visit_count + 1 WHERE short_code = ?",
                (code,),
            )
            conn.commit()

        self._redirect(row["original_url"])

    def _handle_lookup(self) -> None:
        """Look up a URL by its original URL. No API key required."""
        qs = parse_qs(urlparse(self.path).query)
        url = qs.get("url", [""])[0].strip()
        if not url:
            self._error(400, "Missing required parameter: url")
            return
        max_url = self.cfg["max_url_length"]
        if len(url) > max_url:
            self._error(400, f"URL too long (max {max_url} characters)")
            return

        with db_connect(self.cfg["db_path"]) as conn:
            row = conn.execute(
                "SELECT * FROM urls WHERE original_url = ? ORDER BY created_at DESC LIMIT 1",
                (url,),
            ).fetchone()

        if row is None:
            self._send_empty(404)
            return

        base = self.cfg["base_url"].rstrip("/")
        self._send_json(200, {
            "short_code": row["short_code"],
            "short_url": f"{base}/{row['short_code']}",
            "original_url": row["original_url"],
            "created_at": row["created_at"],
            "visit_count": row["visit_count"],
            "retention_days": row["retention_days"],
        })

    def _serve_static(self, rel_path: str) -> None:
        """Serve a static file from STATIC_DIR (for local dev; nginx in prod)."""
        if not rel_path:
            rel_path = "index.html"
        # Prevent directory traversal
        safe = os.path.normpath(rel_path)
        if safe.startswith("..") or os.path.isabs(safe):
            self._send_empty(403)
            return
        fpath = os.path.join(STATIC_DIR, safe)
        if not os.path.isfile(fpath):
            self._send_empty(404)
            return
        mime, _ = mimetypes.guess_type(fpath)
        if not mime:
            mime = "application/octet-stream"
        with open(fpath, "rb") as f:
            data = f.read()
        self.send_response(200)
        self.send_header("Content-Type", mime)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "public, max-age=3600")
        self.end_headers()
        self.wfile.write(data)

    # ------------------------------------------------------------------
    # Internal utils
    # ------------------------------------------------------------------

    def _unique_code(self, conn: sqlite3.Connection, attempts_per_length: int = 10):
        """Generate a unique short code with progressive length increment.

        Starts at min_short_length and tries `attempts_per_length` random
        codes at each length.  On exhaustion, increments the length by 1
        and repeats, up to max_short_length.  Returns None only when the
        entire range is exhausted (extremely unlikely).
        """
        min_len = self.cfg["min_short_length"]
        max_len = self.cfg["max_short_length"]
        for length in range(min_len, max_len + 1):
            for _ in range(attempts_per_length):
                code = generate_code(length)
                exists = conn.execute(
                    "SELECT 1 FROM urls WHERE short_code = ?", (code,)
                ).fetchone()
                if not exists:
                    return code
        return None


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> None:
    global _rate_limiter

    if len(sys.argv) != 2:
        log.error("Usage: %s <config.json>", sys.argv[0])
        sys.exit(1)

    cfg = load_config(sys.argv[1])
    init_db(cfg["db_path"])
    cleanup_expired(cfg["db_path"])

    # Re-initialize rate limiter with config values
    _rate_limiter = RateLimiter(
        max_requests=cfg["rate_limit_requests"],
        window=cfg["rate_limit_window"],
    )

    Handler.cfg = cfg

    host, port = cfg["host"], int(cfg["port"])
    server = HTTPServer((host, port), Handler)

    log.info("URL Shortener listening on http://%s:%s", host, port)
    log.info("Base URL       : %s", cfg["base_url"])
    log.info("Base path      : %s", cfg["base_path"] or "/")
    log.info("DB path        : %s", cfg["db_path"])
    log.info("Short codes    : %s–%s chars", cfg["min_short_length"], cfg["max_short_length"])
    log.info("Retention days : %s", cfg["retention_days"])
    log.info("Rate limit     : %s req/%ss per IP", cfg["rate_limit_requests"], cfg["rate_limit_window"])

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        log.info("Shutting down.")
        server.shutdown()


if __name__ == "__main__":
    main()

