"""Upload, list and presign operations against the heavy-haul-agent bucket.

The bucket is private: nothing here sets an ACL, and reads are handed out as
short-lived presigned URLs instead.
"""

from __future__ import annotations

import sys
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Callable, Iterable, Optional

from botocore.exceptions import (
    BotoCoreError,
    ClientError,
    NoCredentialsError,
    PartialCredentialsError,
)

from classifier import Classification, classify_file
from s3_client import (
    ClassificationUnclearError,
    MissingCredentialsError,
    ObjectNotFoundError,
    S3IntegrationError,
    UploadFailedError,
    get_s3_client,
    verify_bucket,
)
from s3_config import (
    KNOWN_PREFIXES,
    OTHER_PREFIX,
    RAW_MEDIA_LIFECYCLE_NOTE,
    RAW_MEDIA_PREFIX,
    bucket_name,
)

PRESIGNED_URL_EXPIRY_SECONDS = 3600  # 1 hour


@dataclass(frozen=True)
class UploadResult:
    key: str
    bucket: str
    content_type: str
    classification: Classification
    skipped: bool = False
    note: str | None = None


@dataclass(frozen=True)
class StoredObject:
    key: str
    size: int
    last_modified: datetime

    @property
    def filename(self) -> str:
        return self.key.rsplit("/", 1)[-1]


# --- Confirmation -----------------------------------------------------------

# Evaluated at runtime, so it uses Optional[...] rather than `str | None`.
ConfirmCallback = Callable[[Classification], Optional[str]]


def prompt_for_prefix(classification: Classification) -> str | None:
    """Default confirmation: ask on the terminal, return the prefix to use.

    Returns None to skip the file. In a non-interactive shell there is nobody to
    ask, so the file is skipped rather than filed into a guessed folder.
    """
    if not sys.stdin.isatty():
        return None

    options = [classification.prefix, *classification.alternatives]
    for prefix in KNOWN_PREFIXES:
        if prefix not in options:
            options.append(prefix)

    print(f"\nUnclear where '{classification.path.name}' belongs ({classification.reason}).")
    print(f"Best guess: {classification.prefix}")
    for index, prefix in enumerate(options, start=1):
        print(f"  {index}) {prefix}")
    print("  s) skip this file")

    answer = input(f"Choose [1-{len(options)}, s] (default 1): ").strip().lower()
    if answer in {"s", "skip"}:
        return None
    if not answer:
        return options[0]
    if answer.isdigit() and 1 <= int(answer) <= len(options):
        return options[int(answer) - 1]

    print("Unrecognised choice — skipping.")
    return None


def skip_unclear(classification: Classification) -> None:
    """Confirmation callback for unattended runs: never ask, always skip."""
    return None


# --- Upload -----------------------------------------------------------------


def upload_file(
    path: str | Path,
    client=None,
    bucket: str | None = None,
    prefix: str | None = None,
    confirm: ConfirmCallback | None = prompt_for_prefix,
    dry_run: bool = False,
) -> UploadResult:
    """Classify a file, then upload it under the matching prefix.

    `prefix` overrides classification entirely. When classification is unclear
    and no override is given, `confirm` is asked; passing confirm=None makes an
    unclear file raise ClassificationUnclearError instead.
    """
    classification = classify_file(path)
    chosen_prefix = prefix or classification.prefix

    if prefix is None and not classification.confident:
        if confirm is None:
            raise ClassificationUnclearError(
                f"Cannot place '{classification.path.name}' confidently "
                f"({classification.reason}). Pass an explicit prefix."
            )
        chosen = confirm(classification)
        if chosen is None:
            return UploadResult(
                key="",
                bucket=bucket or bucket_name(),
                content_type=classification.content_type,
                classification=classification,
                skipped=True,
                note="skipped: classification not confirmed",
            )
        chosen_prefix = chosen

    if not chosen_prefix.endswith("/"):
        chosen_prefix += "/"

    key = f"{chosen_prefix}{classification.path.name}"
    bucket = bucket or bucket_name()
    note = RAW_MEDIA_LIFECYCLE_NOTE if chosen_prefix == RAW_MEDIA_PREFIX else None

    if dry_run:
        return UploadResult(
            key=key,
            bucket=bucket,
            content_type=classification.content_type,
            classification=classification,
            skipped=True,
            note="dry run: nothing uploaded" + (f". {note}" if note else ""),
        )

    client = client or get_s3_client()

    try:
        client.upload_file(
            str(classification.path),
            bucket,
            key,
            ExtraArgs={"ContentType": classification.content_type},
        )
    except (NoCredentialsError, PartialCredentialsError) as exc:
        raise MissingCredentialsError(
            "AWS credentials are missing or incomplete; see .env.example."
        ) from exc
    except ClientError as exc:
        code = exc.response.get("Error", {}).get("Code", "unknown")
        if code in {"NoSuchBucket", "404"}:
            verify_bucket(client, bucket)  # raises the precise bucket error
        raise UploadFailedError(
            f"Upload of '{classification.path.name}' to s3://{bucket}/{key} failed ({code}): {exc}"
        ) from exc
    except (BotoCoreError, OSError) as exc:
        raise UploadFailedError(
            f"Upload of '{classification.path.name}' to s3://{bucket}/{key} failed: {exc}"
        ) from exc

    return UploadResult(
        key=key,
        bucket=bucket,
        content_type=classification.content_type,
        classification=classification,
        note=note,
    )


def upload_files(
    paths: Iterable[str | Path],
    client=None,
    bucket: str | None = None,
    prefix: str | None = None,
    confirm: ConfirmCallback | None = prompt_for_prefix,
    dry_run: bool = False,
) -> list[UploadResult]:
    """Upload several files, reusing one client."""
    if client is None and not dry_run:
        client = get_s3_client()
    return [
        upload_file(
            path,
            client=client,
            bucket=bucket,
            prefix=prefix,
            confirm=confirm,
            dry_run=dry_run,
        )
        for path in paths
    ]


# --- List -------------------------------------------------------------------


def list_files(client=None, bucket: str | None = None) -> dict[str, list[StoredObject]]:
    """Every object in the bucket, grouped by its folder prefix.

    Keys that sit outside the known prefixes are grouped under their own leading
    folder, or under OTHER_PREFIX when they sit at the bucket root.
    """
    client = client or get_s3_client()
    bucket = verify_bucket(client, bucket)

    grouped: dict[str, list[StoredObject]] = defaultdict(list)

    try:
        paginator = client.get_paginator("list_objects_v2")
        for page in paginator.paginate(Bucket=bucket):
            for item in page.get("Contents", []):
                key = item["Key"]
                if key.endswith("/"):
                    continue  # folder placeholder object
                grouped[_group_for(key)].append(
                    StoredObject(
                        key=key,
                        size=item.get("Size", 0),
                        last_modified=item["LastModified"],
                    )
                )
    except (NoCredentialsError, PartialCredentialsError) as exc:
        raise MissingCredentialsError(
            "AWS credentials are missing or incomplete; see .env.example."
        ) from exc
    except (ClientError, BotoCoreError) as exc:
        raise S3IntegrationError(f"Could not list s3://{bucket}: {exc}") from exc

    for objects in grouped.values():
        objects.sort(key=lambda obj: obj.key)

    return dict(sorted(grouped.items()))


def _group_for(key: str) -> str:
    for prefix in KNOWN_PREFIXES:
        if key.startswith(prefix):
            return prefix
    if "/" in key:
        return key.rsplit("/", 1)[0] + "/"
    return OTHER_PREFIX


# --- Presigned URLs ---------------------------------------------------------


def presigned_url(
    key: str,
    client=None,
    bucket: str | None = None,
    expires_in: int = PRESIGNED_URL_EXPIRY_SECONDS,
) -> str:
    """Time-limited read URL for a private object. Defaults to 1 hour."""
    client = client or get_s3_client()
    bucket = bucket or bucket_name()

    try:
        client.head_object(Bucket=bucket, Key=key)
    except (NoCredentialsError, PartialCredentialsError) as exc:
        raise MissingCredentialsError(
            "AWS credentials are missing or incomplete; see .env.example."
        ) from exc
    except ClientError as exc:
        code = str(exc.response.get("Error", {}).get("Code", ""))
        status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
        if code in {"404", "NoSuchKey"} or status == 404:
            raise ObjectNotFoundError(f"No object at s3://{bucket}/{key}") from exc
        if code in {"NoSuchBucket"}:
            verify_bucket(client, bucket)
        raise S3IntegrationError(f"Could not read s3://{bucket}/{key}: {exc}") from exc
    except BotoCoreError as exc:
        raise S3IntegrationError(f"Could not read s3://{bucket}/{key}: {exc}") from exc

    try:
        return client.generate_presigned_url(
            "get_object",
            Params={"Bucket": bucket, "Key": key},
            ExpiresIn=expires_in,
        )
    except (BotoCoreError, ClientError) as exc:
        raise S3IntegrationError(
            f"Could not sign a URL for s3://{bucket}/{key}: {exc}"
        ) from exc
