"""Per-brain file store — user-uploaded binaries beside the vault.

The vault holds *notes*: markdown, wikilinked, versioned in git. It is the wrong
home for a 40 MB scan, a slide deck, or a photo — those bloat every clone and
every durability bundle while carrying no graph value. This module gives each
brain a **file namespace of its own** in the same S3-compatible bucket the
object-store durability backend already uses:

    <bucket>/files/<tenant>/<path>          user files (this module)
    <bucket>/tenants/<tenant>/vault.bundle  the vault snapshot (durability.py)

Isolation is the same rule and the same failure mode as the bundle: the tenant
segment is **required and validated**, so one brain can neither read nor write
another's files, and a path can never escape its own prefix. Credentials are the
same host-level env pair — one bucket, one credential set, N brains.

Deliberately NOT git: these objects are content-addressed by path and replaced in
place, so a 40 MB file re-uploaded ten times costs 40 MB, not 400. The vault
keeps a *reference* (the `alexandria://file/<path>` URI a tool returns), which is
what belongs in a note.
"""
from __future__ import annotations

import os
import posixpath
import re

_TENANT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
MAX_INLINE_BYTES = 5 * 1024 * 1024    # a tool-call payload ceiling, not a store limit
DEFAULT_URL_TTL = 3600                # presigned link lifetime (seconds)
URI_SCHEME = "alexandria://file/"     # what a note embeds; resolve via file_url


class FileStoreError(RuntimeError):
    """Configuration or storage failure, phrased for the calling agent."""


def safe_key(path: str) -> str:
    """A user path → one safe key segment chain, or raise.

    Rejects absolutes, ``..``, and empty components rather than normalizing them
    away: silently rewriting a caller's path is how a file ends up somewhere the
    caller didn't mean (and, with a shared bucket, possibly in another brain's
    prefix)."""
    raw = str(path or "").strip().replace("\\", "/")
    if not raw:
        raise FileStoreError("a file path is required")
    if raw.startswith("/") or ":" in raw.split("/")[0] and raw.split("/")[0].endswith(":"):
        raise FileStoreError(f"{path!r} must be a relative path (no leading '/')")
    parts = [p for p in raw.split("/")]
    if any(p in ("", ".", "..") for p in parts):
        raise FileStoreError(
            f"{path!r} must not contain empty, '.' or '..' components — a file "
            "lives under this brain's own prefix and nowhere else")
    if len(raw) > 900:
        raise FileStoreError("path too long (max 900 characters)")
    return "/".join(parts)


class FileStore:
    """S3-compatible object store scoped to one brain's ``files/<tenant>/`` prefix.

    Configured by the same env the durability backend reads, so a rack that can
    persist vaults can already store files — no second credential set:
    ``OBJECT_STORE_BUCKET`` (required), ``OBJECT_STORE_ENDPOINT`` (S3-compatibles;
    omit for AWS), ``OBJECT_STORE_ACCESS_KEY`` / ``OBJECT_STORE_SECRET_KEY``
    (omit for the ambient AWS chain), ``OBJECT_STORE_REGION``, ``VAULT_TENANT``
    (required — the isolation segment), ``FILESTORE_PREFIX`` (default ``files/``).
    ``client_factory`` is injectable for tests."""

    def __init__(self, env=None, client_factory=None):
        env = os.environ if env is None else env
        self._client_factory = client_factory
        self.bucket = (env.get("OBJECT_STORE_BUCKET") or "").strip()
        tenant = (env.get("VAULT_TENANT") or "").strip()
        self.endpoint = (env.get("OBJECT_STORE_ENDPOINT") or "").strip() or None
        self.region = (env.get("OBJECT_STORE_REGION") or "auto").strip()
        self.access_key = (env.get("OBJECT_STORE_ACCESS_KEY") or "").strip() or None
        self.secret_key = (env.get("OBJECT_STORE_SECRET_KEY") or "").strip() or None
        fps = env.get("OBJECT_STORE_FORCE_PATH_STYLE")
        self.force_path_style = (
            fps.strip().lower() in ("1", "true", "yes", "on") if fps is not None
            else bool(self.endpoint and "r2.cloudflarestorage.com" in self.endpoint))
        prefix = env.get("FILESTORE_PREFIX", "files/")
        self.configured = bool(self.bucket and tenant)
        self.reason = ""
        if not self.bucket:
            self.reason = ("this brain has no file store: $OBJECT_STORE_BUCKET is unset")
        elif not _TENANT_RE.match(tenant):
            self.configured = False
            self.reason = ("this brain has no file store: $VAULT_TENANT is unset or not a "
                           "single safe path segment — it is what isolates this brain's "
                           "files from every other brain in the bucket")
        self.tenant = tenant
        self.root = f"{prefix}{tenant}/" if self.configured else ""

    # ---- plumbing -------------------------------------------------------------
    def _require(self):
        if not self.configured:
            raise FileStoreError(self.reason or "file store is not configured")

    def _client(self):
        self._require()
        if self._client_factory is not None:
            return self._client_factory()
        try:
            import boto3            # the durability-s3 extra
        except ImportError as e:
            raise FileStoreError(
                "the file store needs boto3 — install the `durability-s3` extra") from e
        kw = {"region_name": self.region}
        if self.endpoint:
            kw["endpoint_url"] = self.endpoint
        if self.access_key and self.secret_key:
            kw.update(aws_access_key_id=self.access_key,
                      aws_secret_access_key=self.secret_key)
        if self.force_path_style:
            from botocore.config import Config
            kw["config"] = Config(s3={"addressing_style": "path"})
        return boto3.client("s3", **kw)

    def key_for(self, path: str) -> str:
        self._require()
        return self.root + safe_key(path)

    @staticmethod
    def uri_for(path: str) -> str:
        return URI_SCHEME + safe_key(path)

    # ---- verbs ----------------------------------------------------------------
    def put(self, path: str, data: bytes, content_type: str = "") -> dict:
        key = self.key_for(path)
        extra = {"ContentType": content_type} if content_type else {}
        try:
            self._client().put_object(Bucket=self.bucket, Key=key, Body=data, **extra)
        except FileStoreError:
            raise
        except Exception as e:
            raise FileStoreError(f"upload failed: {e}") from e
        return {"path": safe_key(path), "uri": self.uri_for(path),
                "bytes": len(data), "content_type": content_type or None}

    def list(self, prefix: str = "", limit: int = 200) -> dict:
        self._require()
        sub = self.root + (safe_key(prefix) + "/" if str(prefix or "").strip() else "")
        try:
            resp = self._client().list_objects_v2(
                Bucket=self.bucket, Prefix=sub, MaxKeys=max(1, min(int(limit or 200), 1000)))
        except FileStoreError:
            raise
        except Exception as e:
            raise FileStoreError(f"list failed: {e}") from e
        items = []
        for o in resp.get("Contents") or []:
            rel = o["Key"][len(self.root):]
            items.append({"path": rel, "uri": URI_SCHEME + rel, "bytes": o.get("Size"),
                          "modified": (o.get("LastModified").isoformat()
                                       if o.get("LastModified") else None)})
        return {"items": items, "total": len(items),
                "truncated": bool(resp.get("IsTruncated"))}

    def head(self, path: str) -> dict:
        key = self.key_for(path)
        try:
            h = self._client().head_object(Bucket=self.bucket, Key=key)
        except FileStoreError:
            raise
        except Exception as e:
            raise FileStoreError(f"not found: {safe_key(path)} ({e.__class__.__name__})") from e
        return {"path": safe_key(path), "uri": self.uri_for(path),
                "bytes": h.get("ContentLength"),
                "content_type": h.get("ContentType"),
                "modified": (h.get("LastModified").isoformat()
                             if h.get("LastModified") else None)}

    def get(self, path: str, max_bytes: int = MAX_INLINE_BYTES) -> bytes:
        key = self.key_for(path)
        try:
            obj = self._client().get_object(Bucket=self.bucket, Key=key)
            body = obj["Body"].read(max_bytes + 1)
        except FileStoreError:
            raise
        except Exception as e:
            raise FileStoreError(f"not found: {safe_key(path)} ({e.__class__.__name__})") from e
        if len(body) > max_bytes:
            raise FileStoreError(
                f"{safe_key(path)} is larger than the {max_bytes:,}-byte inline limit — "
                "use file_url for a download link instead of pulling it through the tool "
                "channel")
        return body

    def url(self, path: str, expires: int = DEFAULT_URL_TTL) -> dict:
        """A short-lived presigned GET — how a big file is *reached* rather than
        pumped through the tool channel (and what a note's link resolves to)."""
        key = self.key_for(path)
        ttl = max(60, min(int(expires or DEFAULT_URL_TTL), 7 * 86400))
        try:
            link = self._client().generate_presigned_url(
                "get_object", Params={"Bucket": self.bucket, "Key": key}, ExpiresIn=ttl)
        except FileStoreError:
            raise
        except Exception as e:
            raise FileStoreError(f"could not sign a link: {e}") from e
        return {"path": safe_key(path), "url": link, "expires_in": ttl}

    def delete(self, path: str) -> dict:
        key = self.key_for(path)
        try:
            self._client().delete_object(Bucket=self.bucket, Key=key)
        except FileStoreError:
            raise
        except Exception as e:
            raise FileStoreError(f"delete failed: {e}") from e
        return {"path": safe_key(path), "deleted": True}
