#!/usr/bin/env python3
"""Upload and verify public blog images in the configured Cloudflare R2 bucket.

The program deliberately uses the local AWS CLI rather than persisting credentials
in an AWS profile. It reads a narrowly-defined dotenv file without executing it.
"""

from __future__ import annotations

import argparse
import datetime as dt
import json
import mimetypes
import os
from dataclasses import dataclass
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
from typing import Mapping
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlparse
from urllib.request import Request, urlopen


DEFAULT_CACHE_CONTROL = "public, max-age=31536000, immutable"
PLACEHOLDER_PREFIXES = ("replace-with", "your-", "changeme", "change-me")
ENV_LINE = re.compile(r"(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)")
YEAR = re.compile(r"[0-9]{4}")
EXTRA_IMAGE_TYPES = {
    ".avif": "image/avif",
    ".heic": "image/heic",
    ".heif": "image/heif",
    ".svg": "image/svg+xml",
    ".webp": "image/webp",
}


class UserError(RuntimeError):
    """An expected configuration, input, or remote-service error."""


@dataclass(frozen=True)
class R2Config:
    bucket: str
    endpoint: str
    region: str
    access_key_id: str
    secret_access_key: str
    public_base_url: str
    cache_control: str


def fail(message: str) -> None:
    raise UserError(message)


def is_placeholder(value: str) -> bool:
    return not value or value.lower().startswith(PLACEHOLDER_PREFIXES)


def read_dotenv(path: Path) -> dict[str, str]:
    """Read simple KEY=VALUE lines without sourcing or evaluating the file."""
    if not path.is_file():
        fail(
            f"Configuration file not found: {path}. "
            "Copy .env.example to .env and fill the R2 credentials locally."
        )

    values: dict[str, str] = {}
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except OSError as error:
        fail(f"Could not read the configuration file ({error.__class__.__name__}).")

    for line_number, raw_line in enumerate(lines, start=1):
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        match = ENV_LINE.fullmatch(line)
        if not match:
            fail(
                f"Invalid .env syntax on line {line_number}. "
                "Use KEY=value lines and do not put inline comments after values."
            )
        key, value = match.groups()
        if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
            value = value[1:-1]
        values[key] = value
    return values


def value_from(values: Mapping[str, str], *names: str) -> str:
    for name in names:
        value = values.get(name, "")
        if value:
            return value
    return ""


def load_config(env_file: Path, *, require_credentials: bool) -> R2Config:
    values = read_dotenv(env_file)
    account_id = value_from(values, "CLOUDFLARE_ACCOUNT_ID", "R2_ACCOUNT_ID")
    endpoint = values.get("R2_ENDPOINT", "").rstrip("/")
    if not endpoint and account_id:
        endpoint = f"https://{account_id}.r2.cloudflarestorage.com"

    bucket = values.get("R2_BUCKET", "")
    public_base_url = values.get("R2_PUBLIC_BASE_URL", "").rstrip("/")
    region = values.get("R2_REGION", "auto") or "auto"
    cache_control = values.get("R2_CACHE_CONTROL", DEFAULT_CACHE_CONTROL) or DEFAULT_CACHE_CONTROL
    access_key_id = values.get("R2_ACCESS_KEY_ID", "")
    secret_access_key = values.get("R2_SECRET_ACCESS_KEY", "")

    missing = []
    if not bucket:
        missing.append("R2_BUCKET")
    if not endpoint:
        missing.append("R2_ENDPOINT (or CLOUDFLARE_ACCOUNT_ID)")
    if not public_base_url:
        missing.append("R2_PUBLIC_BASE_URL")
    if require_credentials:
        if is_placeholder(access_key_id):
            missing.append("R2_ACCESS_KEY_ID")
        if is_placeholder(secret_access_key):
            missing.append("R2_SECRET_ACCESS_KEY")
    if missing:
        fail("Missing local configuration: " + ", ".join(missing) + ".")

    endpoint_parts = urlparse(endpoint)
    public_parts = urlparse(public_base_url)
    if endpoint_parts.scheme != "https" or not endpoint_parts.netloc:
        fail("R2_ENDPOINT must be an HTTPS URL.")
    if public_parts.scheme != "https" or not public_parts.netloc:
        fail("R2_PUBLIC_BASE_URL must be an HTTPS URL.")

    return R2Config(
        bucket=bucket,
        endpoint=endpoint,
        region=region,
        access_key_id=access_key_id,
        secret_access_key=secret_access_key,
        public_base_url=public_base_url,
        cache_control=cache_control,
    )


def image_content_type(path: Path, requested: str | None) -> str:
    content_type = requested or EXTRA_IMAGE_TYPES.get(path.suffix.lower())
    if not content_type:
        content_type, _ = mimetypes.guess_type(path.name)
    if not content_type or not content_type.lower().startswith("image/"):
        fail(
            "Could not determine an image MIME type. "
            "Use a recognised image extension or pass --content-type image/<type>."
        )
    return content_type


def filename_for(source: Path, requested_name: str | None) -> str:
    name = requested_name or source.name
    if not name or name in {".", ".."} or "/" in name or "\\" in name or "\x00" in name:
        fail("The object filename must be one plain filename, without path separators.")
    if not Path(name).suffix or name.endswith("."):
        fail("The object filename must include an image filename extension.")
    if any(ord(character) < 32 for character in name):
        fail("The object filename cannot contain control characters.")
    return name


def make_key(year: str, name: str) -> str:
    if not YEAR.fullmatch(year):
        fail("--year must be exactly four digits, for example 2026.")
    key = f"{year}/{name}"
    if len(key.encode("utf-8")) > 1024:
        fail("The R2 object key exceeds the 1024-byte limit.")
    return key


def validate_existing_key(key: str) -> str:
    if not key or key.startswith("/") or "\\" in key or "\x00" in key:
        fail("The object key must be a relative R2 key.")
    parts = key.split("/")
    if any(part in {"", ".", ".."} for part in parts):
        fail("The object key cannot contain empty, . or .. path segments.")
    return key


def public_url(config: R2Config, key: str) -> str:
    encoded_key = "/".join(quote(part, safe="-._~") for part in key.split("/"))
    return f"{config.public_base_url}/{encoded_key}"


def aws_environment(config: R2Config) -> dict[str, str]:
    environment = os.environ.copy()
    # Credentials remain process-local; do not rely on or write an AWS profile.
    for name in ("AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_SESSION_TOKEN"):
        environment.pop(name, None)
    environment.update(
        {
            "AWS_ACCESS_KEY_ID": config.access_key_id,
            "AWS_SECRET_ACCESS_KEY": config.secret_access_key,
            "AWS_DEFAULT_REGION": config.region,
            "AWS_EC2_METADATA_DISABLED": "true",
            "AWS_PAGER": "",
            "AWS_CLI_AUTO_PROMPT": "off",
        }
    )
    return environment


def run_aws(config: R2Config, arguments: list[str]) -> subprocess.CompletedProcess[str]:
    aws = shutil.which("aws")
    if not aws:
        fail("AWS CLI is required. Install it, then run this command again.")
    command = [
        aws,
        "--no-cli-pager",
        "--endpoint-url",
        config.endpoint,
        "--region",
        config.region,
        *arguments,
    ]
    return subprocess.run(
        command,
        env=aws_environment(config),
        check=False,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
    )


def is_not_found(result: subprocess.CompletedProcess[str]) -> bool:
    output = f"{result.stdout}\n{result.stderr}".lower()
    return "(404)" in output or "not found" in output or "nosuchkey" in output


def remote_object_exists(config: R2Config, key: str) -> bool:
    result = run_aws(
        config,
        ["s3api", "head-object", "--bucket", config.bucket, "--key", key],
    )
    if result.returncode == 0:
        return True
    if is_not_found(result):
        return False
    fail(
        "Could not check the destination object. Confirm the R2 endpoint, bucket, "
        "and an R2 Object Read & Write token; no upload was attempted."
    )


def read_remote_metadata(config: R2Config, key: str) -> dict[str, object]:
    result = run_aws(
        config,
        [
            "s3api",
            "head-object",
            "--bucket",
            config.bucket,
            "--key",
            key,
            "--query",
            "{ContentType:ContentType,CacheControl:CacheControl,ContentLength:ContentLength,ETag:ETag}",
            "--output",
            "json",
        ],
    )
    if result.returncode != 0:
        fail("R2 did not confirm the uploaded object metadata.")
    try:
        metadata = json.loads(result.stdout)
    except json.JSONDecodeError:
        fail("R2 returned unreadable object metadata.")
    if not isinstance(metadata, dict):
        fail("R2 returned unexpected object metadata.")
    return metadata


def verify_cdn(url: str, expected_content_type: str) -> tuple[int, str | None, str | None]:
    last_error = "unknown response"
    for attempt in range(3):
        request = Request(url, method="HEAD", headers={"User-Agent": "Blog-R2-Uploader/1.0"})
        try:
            with urlopen(request, timeout=20) as response:  # noqa: S310 - URL comes from local config.
                status = response.status
                content_type = response.headers.get("Content-Type")
                cache_control = response.headers.get("Cache-Control")
                if 200 <= status < 300:
                    actual_type = (content_type or "").split(";", 1)[0].lower()
                    expected_type = expected_content_type.split(";", 1)[0].lower()
                    if actual_type and actual_type != expected_type:
                        fail(
                            "The CDN returned a different Content-Type than the uploaded object. "
                            "The object was kept; inspect the CDN configuration before embedding it."
                        )
                    return status, content_type, cache_control
                last_error = f"HTTP {status}"
        except HTTPError as error:
            last_error = f"HTTP {error.code}"
        except URLError as error:
            last_error = error.reason.__class__.__name__
        if attempt < 2:
            time.sleep(1)
    fail(
        "The object was uploaded but the configured public CDN URL could not be verified "
        f"({last_error}). Check the bucket custom domain, then run the verify command."
    )


def markdown_alt(value: str) -> str:
    return value.replace("\\", "\\\\").replace("]", "\\]").replace("\r", " ").replace("\n", " ")


def print_result(
    *, key: str, url: str, content_type: str, metadata: Mapping[str, object] | None, alt: str
) -> None:
    print(f"Object key: {key}")
    if metadata:
        size = metadata.get("ContentLength", "unknown")
        remote_type = metadata.get("ContentType") or content_type
        cache_control = metadata.get("CacheControl") or "<not set>"
        print(f"R2 metadata: {size} bytes; {remote_type}; Cache-Control: {cache_control}")
    else:
        print(f"Content-Type: {content_type}")
    print(f"CDN URL: {url}")
    print(f"Markdown: ![{markdown_alt(alt)}]({url})")


def command_doctor(args: argparse.Namespace) -> None:
    config = load_config(args.env_file, require_credentials=True)
    result = run_aws(
        config,
        [
            "s3api",
            "list-objects-v2",
            "--bucket",
            config.bucket,
            "--max-keys",
            "1",
            "--query",
            "KeyCount",
            "--output",
            "text",
        ],
    )
    if result.returncode != 0:
        fail(
            "R2 credential check failed. Confirm the endpoint, bucket name, and a bucket-scoped "
            "R2 Object Read & Write token."
        )
    print("R2 API credentials and bucket read access verified.")
    print(f"Configured public CDN: {config.public_base_url}")


def command_upload(args: argparse.Namespace) -> None:
    source = args.file.expanduser()
    if not source.is_file():
        fail("The input must be an existing regular image file.")
    source = source.resolve()
    name = filename_for(source, args.name)
    content_type = image_content_type(source, args.content_type)
    year = args.year or str(dt.datetime.now().astimezone().year)
    key = make_key(year, name)
    config = load_config(args.env_file, require_credentials=not args.dry_run)
    url = public_url(config, key)
    alt = args.alt or Path(name).stem
    cache_control = args.cache_control or config.cache_control

    if args.dry_run:
        print("Dry run: no R2 or CDN request was made.")
        print_result(key=key, url=url, content_type=content_type, metadata=None, alt=alt)
        print(f"Planned Cache-Control: {cache_control}")
        return

    exists = remote_object_exists(config, key)
    if exists and not args.overwrite:
        fail(
            f"R2 already contains {key}. Rename the file or use --overwrite deliberately; "
            "no upload was made."
        )
    if exists:
        print("Warning: deliberately overwriting an existing key; CDN/browser caches can serve its old bytes.")

    result = run_aws(
        config,
        [
            "s3",
            "cp",
            str(source),
            f"s3://{config.bucket}/{key}",
            "--content-type",
            content_type,
            "--cache-control",
            cache_control,
            "--only-show-errors",
            "--no-progress",
        ],
    )
    if result.returncode != 0:
        fail(
            "R2 upload failed. Check the endpoint, bucket, local file, and token permissions; "
            "credentials were not printed."
        )

    metadata = read_remote_metadata(config, key)
    remote_type = str(metadata.get("ContentType") or "").split(";", 1)[0].lower()
    expected_type = content_type.split(";", 1)[0].lower()
    if remote_type != expected_type:
        fail(
            "R2 stored an unexpected Content-Type. The object was kept; inspect its metadata before embedding it."
        )

    if args.skip_cdn_check:
        print_result(key=key, url=url, content_type=content_type, metadata=metadata, alt=alt)
        print("CDN check skipped by request.")
        return

    status, cdn_type, cdn_cache = verify_cdn(url, content_type)
    print_result(key=key, url=url, content_type=content_type, metadata=metadata, alt=alt)
    print(f"CDN check: HTTP {status}; {cdn_type or '<no Content-Type>'}; Cache-Control: {cdn_cache or '<not set>'}")


def command_verify(args: argparse.Namespace) -> None:
    key = validate_existing_key(args.key)
    config = load_config(args.env_file, require_credentials=True)
    metadata = read_remote_metadata(config, key)
    content_type = str(metadata.get("ContentType") or "application/octet-stream")
    url = public_url(config, key)
    if args.skip_cdn_check:
        print_result(key=key, url=url, content_type=content_type, metadata=metadata, alt=Path(key).stem)
        print("CDN check skipped by request.")
        return
    status, cdn_type, cdn_cache = verify_cdn(url, content_type)
    print_result(key=key, url=url, content_type=content_type, metadata=metadata, alt=Path(key).stem)
    print(f"CDN check: HTTP {status}; {cdn_type or '<no Content-Type>'}; Cache-Control: {cdn_cache or '<not set>'}")


def add_env_option(parser: argparse.ArgumentParser, default_env: Path) -> None:
    parser.add_argument(
        "--env-file",
        type=Path,
        default=default_env,
        help="local dotenv file (default: repository .env)",
    )


def build_parser(default_env: Path) -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Upload, verify, and diagnose this Blog's Cloudflare R2-backed CDN images."
    )
    commands = parser.add_subparsers(dest="command", required=True)

    doctor = commands.add_parser("doctor", help="verify local R2 credentials and bucket read access")
    add_env_option(doctor, default_env)
    doctor.set_defaults(handler=command_doctor)

    upload = commands.add_parser("upload", help="upload one image as YYYY/filename.ext")
    upload.add_argument("file", type=Path, help="local image file")
    add_env_option(upload, default_env)
    upload.add_argument("--year", help="four-digit R2 key prefix; defaults to the current local year")
    upload.add_argument("--name", help="complete destination filename including its extension")
    upload.add_argument("--alt", help="alt text used in the printed Markdown snippet")
    upload.add_argument("--content-type", help="override the inferred image MIME type")
    upload.add_argument("--cache-control", help="override R2_CACHE_CONTROL for this object")
    upload.add_argument("--overwrite", action="store_true", help="allow replacement of an existing key")
    upload.add_argument("--dry-run", action="store_true", help="validate the plan without credentials or network requests")
    upload.add_argument("--skip-cdn-check", action="store_true", help="do not HEAD the public CDN URL after upload")
    upload.set_defaults(handler=command_upload)

    verify = commands.add_parser("verify", help="check an existing R2 key and its public CDN URL")
    verify.add_argument("key", help="existing relative R2 key, for example 2026/photo.png")
    add_env_option(verify, default_env)
    verify.add_argument("--skip-cdn-check", action="store_true", help="only verify R2 metadata")
    verify.set_defaults(handler=command_verify)

    return parser


def main() -> int:
    repository_root = Path(__file__).resolve().parent.parent
    parser = build_parser(repository_root / ".env")
    args = parser.parse_args()
    try:
        args.handler(args)
    except UserError as error:
        print(f"Error: {error}", file=sys.stderr)
        return 2
    except KeyboardInterrupt:
        print("Interrupted.", file=sys.stderr)
        return 130
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
