# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License - see LICENSE file for details

"""
Universal FileStore for Familiar.

Provides file storage and indexing for all job classes. Files are stored
on disk organized by job_class/record_type/record_id/, with metadata
tracked in a SQLite index for fast queries.

Usage:
    from familiar.core.files import get_file_store

    store = get_file_store()
    record = store.store(
        data=pdf_bytes,
        filename="application.pdf",
        job_class="nonprofit",
        record_type="grants",
        record_id="abc123",
    )
    path = store.get_path(record.file_id)
"""

import hashlib
import logging
import mimetypes
import re
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

# Maximum file size (50 MB)
MAX_FILE_SIZE = 50 * 1024 * 1024

# Allowed MIME type prefixes
ALLOWED_MIME_PREFIXES = (
    "text/",
    "image/",
    "audio/",
    "video/",
    "application/pdf",
    "application/json",
    "application/xml",
    "application/zip",
    "application/gzip",
    "application/x-tar",
    "application/vnd.openxmlformats",  # docx, xlsx, pptx
    "application/vnd.ms-",  # doc, xls, ppt
    "application/vnd.oasis.opendocument",  # odt, ods, odp
    "application/octet-stream",  # fallback for unknown types
)

# Filename sanitization pattern — only allow safe characters
_SAFE_FILENAME_RE = re.compile(r"[^\w\s\-.]", re.ASCII)
_SAFE_ID_RE = re.compile(r"[^\w\-]", re.ASCII)

_SCHEMA = """
CREATE TABLE IF NOT EXISTS files (
    file_id     TEXT PRIMARY KEY,
    job_class   TEXT NOT NULL,
    record_type TEXT NOT NULL,
    record_id   TEXT NOT NULL,
    filename    TEXT NOT NULL,
    mime_type   TEXT,
    size_bytes  INTEGER NOT NULL,
    sha256      TEXT NOT NULL,
    stored_at   TEXT NOT NULL,
    description TEXT DEFAULT ''
);

CREATE INDEX IF NOT EXISTS idx_files_record
    ON files(record_type, record_id);
CREATE INDEX IF NOT EXISTS idx_files_job
    ON files(job_class);
CREATE INDEX IF NOT EXISTS idx_files_sha
    ON files(sha256);

CREATE TABLE IF NOT EXISTS file_mentions (
    file_id     TEXT NOT NULL,
    memory_id   TEXT NOT NULL,
    context     TEXT DEFAULT '',
    mentioned_at TEXT NOT NULL,
    PRIMARY KEY (file_id, memory_id),
    FOREIGN KEY (file_id) REFERENCES files(file_id) ON DELETE CASCADE
);
"""


def _utcnow() -> str:
    return datetime.now(timezone.utc).isoformat()


def _sanitize_filename(name: str) -> str:
    """Strip dangerous characters from a filename."""
    name = name.strip()
    # Remove path separators
    name = name.replace("/", "_").replace("\\", "_")
    # Remove non-ASCII and special characters, keep word chars, spaces, hyphens, dots
    name = _SAFE_FILENAME_RE.sub("", name)
    # Collapse whitespace
    name = re.sub(r"\s+", "_", name)
    # Limit length
    if len(name) > 200:
        stem = Path(name).stem[:190]
        suffix = Path(name).suffix[:10]
        name = stem + suffix
    return name or "unnamed"


def _sanitize_path_component(value: str) -> str:
    """Sanitize a value for use as a directory name."""
    return _SAFE_ID_RE.sub("_", value.strip())[:100] or "unknown"


@dataclass
class FileRecord:
    """Metadata for a stored file."""

    file_id: str
    job_class: str
    record_type: str
    record_id: str
    filename: str
    mime_type: str
    size_bytes: int
    sha256: str
    stored_at: str
    description: str = ""

    def to_dict(self) -> dict:
        return asdict(self)


class FileStore:
    """Universal file storage with SQLite index.

    Files are stored on disk at:
        files_dir/{job_class}/{record_type}/{record_id}/{file_id}_{filename}

    Metadata is tracked in SQLite at files_dir/index.db.
    Thread-safe via a reentrant lock.
    """

    def __init__(self, files_dir: Optional[Path] = None):
        if files_dir is None:
            from familiar.core.paths import FILES_DIR

            files_dir = FILES_DIR
        self._dir = files_dir
        self._dir.mkdir(parents=True, exist_ok=True)
        self._db_path = self._dir / "index.db"
        self._lock = threading.RLock()
        self._init_db()

    def _init_db(self):
        with self._lock:
            conn = sqlite3.connect(str(self._db_path))
            conn.executescript(_SCHEMA)
            # Run any pending migrations
            try:
                from familiar.core.migrations import run_migrations

                run_migrations(conn, "files")
            except Exception as e:
                logger.warning("File store migration check failed: %s", e)
            conn.close()

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(str(self._db_path))
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA foreign_keys=ON")
        return conn

    def _file_dir(self, job_class: str, record_type: str, record_id: str) -> Path:
        """Build the storage directory for a file."""
        return (
            self._dir
            / _sanitize_path_component(job_class)
            / _sanitize_path_component(record_type)
            / _sanitize_path_component(record_id)
        )

    def store(
        self,
        data: bytes,
        filename: str,
        job_class: str,
        record_type: str,
        record_id: str,
        description: str = "",
    ) -> FileRecord:
        """Store a file and index its metadata.

        Args:
            data: Raw file bytes.
            filename: Original filename.
            job_class: Job class key (e.g. "nonprofit", "social_worker").
            record_type: Record category (e.g. "grants", "cases").
            record_id: ID of the parent record.
            description: Optional human description.

        Returns:
            FileRecord with metadata.

        Raises:
            ValueError: If file exceeds size limit or has disallowed MIME type.
        """
        if len(data) > MAX_FILE_SIZE:
            raise ValueError(
                f"File too large: {len(data)} bytes (max {MAX_FILE_SIZE // (1024 * 1024)} MB)"
            )

        safe_name = _sanitize_filename(filename)
        mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"

        if not any(mime_type.startswith(p) for p in ALLOWED_MIME_PREFIXES):
            raise ValueError(f"File type not allowed: {mime_type}")

        file_id = str(uuid.uuid4())
        sha = hashlib.sha256(data).hexdigest()

        # Write to disk
        target_dir = self._file_dir(job_class, record_type, record_id)
        target_dir.mkdir(parents=True, exist_ok=True)
        disk_name = f"{file_id}_{safe_name}"
        target_path = target_dir / disk_name
        target_path.write_bytes(data)

        record = FileRecord(
            file_id=file_id,
            job_class=job_class,
            record_type=record_type,
            record_id=record_id,
            filename=safe_name,
            mime_type=mime_type,
            size_bytes=len(data),
            sha256=sha,
            stored_at=_utcnow(),
            description=description,
        )

        with self._lock:
            conn = self._connect()
            try:
                conn.execute(
                    "INSERT INTO files "
                    "(file_id, job_class, record_type, record_id, filename, "
                    "mime_type, size_bytes, sha256, stored_at, description) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                    (
                        record.file_id,
                        record.job_class,
                        record.record_type,
                        record.record_id,
                        record.filename,
                        record.mime_type,
                        record.size_bytes,
                        record.sha256,
                        record.stored_at,
                        record.description,
                    ),
                )
                conn.commit()
            finally:
                conn.close()

        logger.info(
            "Stored file %s (%s, %d bytes) for %s/%s",
            safe_name,
            mime_type,
            len(data),
            record_type,
            record_id,
        )

        # Index in MemoryTree for unified search (Sprint 10)
        try:
            from familiar.core.memory_tree import get_memory_tree

            tree = get_memory_tree()
            tree.store(
                title=f"File: {safe_name}",
                body=(
                    f"Filename: {safe_name}\n"
                    f"Type: {mime_type}\n"
                    f"Size: {len(data)} bytes\n"
                    f"Job class: {job_class}\n"
                    f"Record: {record_type}/{record_id}\n"
                    + (f"Description: {description}\n" if description else "")
                ),
                tier="long",
                tags=["file", job_class, mime_type.split("/")[0]],
                importance=0.4,
                meta={
                    "file_id": file_id,
                    "job_class": job_class,
                    "record_type": record_type,
                    "record_id": record_id,
                    "sha256": sha,
                    "source": "filestore",
                },
            )
        except Exception as e:
            logger.debug("MemoryTree file index skipped: %s", e)

        return record

    def get(self, file_id: str) -> Optional[FileRecord]:
        """Look up a file by ID."""
        with self._lock:
            conn = self._connect()
            try:
                row = conn.execute("SELECT * FROM files WHERE file_id = ?", (file_id,)).fetchone()
                if not row:
                    return None
                return FileRecord(**dict(row))
            finally:
                conn.close()

    def get_path(self, file_id: str) -> Optional[Path]:
        """Get the full filesystem path for a file."""
        record = self.get(file_id)
        if not record:
            return None
        target_dir = self._file_dir(record.job_class, record.record_type, record.record_id)
        disk_name = f"{record.file_id}_{record.filename}"
        path = target_dir / disk_name
        return path if path.exists() else None

    def list_for_record(
        self,
        record_type: str,
        record_id: str,
        job_class: Optional[str] = None,
    ) -> list[FileRecord]:
        """List all files attached to a record."""
        with self._lock:
            conn = self._connect()
            try:
                if job_class:
                    rows = conn.execute(
                        "SELECT * FROM files "
                        "WHERE record_type = ? AND record_id = ? "
                        "AND job_class = ? ORDER BY stored_at DESC",
                        (record_type, record_id, job_class),
                    ).fetchall()
                else:
                    rows = conn.execute(
                        "SELECT * FROM files "
                        "WHERE record_type = ? AND record_id = ? "
                        "ORDER BY stored_at DESC",
                        (record_type, record_id),
                    ).fetchall()
                return [FileRecord(**dict(r)) for r in rows]
            finally:
                conn.close()

    def list_for_job_class(self, job_class: str) -> list[FileRecord]:
        """List all files for a job class."""
        with self._lock:
            conn = self._connect()
            try:
                rows = conn.execute(
                    "SELECT * FROM files WHERE job_class = ? ORDER BY stored_at DESC",
                    (job_class,),
                ).fetchall()
                return [FileRecord(**dict(r)) for r in rows]
            finally:
                conn.close()

    def delete(self, file_id: str) -> bool:
        """Delete a file from disk and index."""
        path = self.get_path(file_id)
        with self._lock:
            conn = self._connect()
            try:
                result = conn.execute("DELETE FROM files WHERE file_id = ?", (file_id,))
                conn.execute("DELETE FROM file_mentions WHERE file_id = ?", (file_id,))
                conn.commit()
                deleted = result.rowcount > 0
            finally:
                conn.close()

        if path and path.exists():
            try:
                path.unlink()
                # Clean up empty parent directories
                parent = path.parent
                while parent != self._dir:
                    if not any(parent.iterdir()):
                        parent.rmdir()
                        parent = parent.parent
                    else:
                        break
            except OSError:
                pass

        if deleted:
            logger.info("Deleted file %s", file_id)
            # Invalidate MemoryTree entry for this file
            try:
                from familiar.core.memory_tree import get_memory_tree

                tree = get_memory_tree()
                results = tree.search("File: ", limit=100)
                for entry in results:
                    if entry.meta.get("file_id") == file_id:
                        tree.invalidate(entry.memory_id, reason="file deleted")
                        break
            except Exception:
                pass
        return deleted

    def verify_integrity(self, file_id: str) -> bool:
        """Re-hash a file and compare to stored SHA-256."""
        record = self.get(file_id)
        if not record:
            return False
        path = self.get_path(file_id)
        if not path:
            return False
        actual = hashlib.sha256(path.read_bytes()).hexdigest()
        return actual == record.sha256

    def add_mention(self, file_id: str, memory_id: str, context: str = "") -> None:
        """Link a file to an episodic memory."""
        with self._lock:
            conn = self._connect()
            try:
                conn.execute(
                    "INSERT OR IGNORE INTO file_mentions "
                    "(file_id, memory_id, context, mentioned_at) "
                    "VALUES (?, ?, ?, ?)",
                    (file_id, memory_id, context, _utcnow()),
                )
                conn.commit()
            finally:
                conn.close()

    def get_mentions(self, file_id: str) -> list[dict]:
        """Get all memory links for a file."""
        with self._lock:
            conn = self._connect()
            try:
                rows = conn.execute(
                    "SELECT memory_id, context, mentioned_at FROM file_mentions WHERE file_id = ?",
                    (file_id,),
                ).fetchall()
                return [dict(r) for r in rows]
            finally:
                conn.close()

    def search(
        self,
        query: str = "",
        job_class: Optional[str] = None,
        mime_prefix: Optional[str] = None,
        limit: int = 50,
    ) -> list[FileRecord]:
        """Search files by filename/description, optionally filtered."""
        conditions = []
        params: list = []

        if query:
            conditions.append("(filename LIKE ? OR description LIKE ?)")
            like = f"%{query}%"
            params.extend([like, like])
        if job_class:
            conditions.append("job_class = ?")
            params.append(job_class)
        if mime_prefix:
            conditions.append("mime_type LIKE ?")
            params.append(f"{mime_prefix}%")

        where = " WHERE " + " AND ".join(conditions) if conditions else ""
        sql = f"SELECT * FROM files{where} ORDER BY stored_at DESC LIMIT ?"
        params.append(limit)

        with self._lock:
            conn = self._connect()
            try:
                rows = conn.execute(sql, params).fetchall()
                return [FileRecord(**dict(r)) for r in rows]
            finally:
                conn.close()

    def stats(self) -> dict:
        """Return storage statistics."""
        with self._lock:
            conn = self._connect()
            try:
                row = conn.execute(
                    "SELECT COUNT(*) as count, "
                    "COALESCE(SUM(size_bytes), 0) as total_bytes "
                    "FROM files"
                ).fetchone()
                by_job = conn.execute(
                    "SELECT job_class, COUNT(*) as count, "
                    "SUM(size_bytes) as bytes FROM files "
                    "GROUP BY job_class"
                ).fetchall()
                return {
                    "total_files": row["count"],
                    "total_bytes": row["total_bytes"],
                    "by_job_class": {
                        r["job_class"]: {
                            "count": r["count"],
                            "bytes": r["bytes"],
                        }
                        for r in by_job
                    },
                }
            finally:
                conn.close()


# ── Singleton ──

_file_store: Optional[FileStore] = None
_file_store_lock = threading.Lock()


def get_file_store() -> FileStore:
    """Get or create the singleton FileStore instance."""
    global _file_store
    if _file_store is None:
        with _file_store_lock:
            if _file_store is None:
                _file_store = FileStore()
    return _file_store
