"""Immutable audit logging for OCLAWMA.

Provides tamper-evident audit logs for compliance requirements.
Uses chained hashes to detect modifications to the log history.

Example:
    >>> from oclawma.audit import AuditLogger, AuditAction
    >>> logger = AuditLogger("~/.oclawma/audit.db")
    >>> logger.log(AuditAction.CREATE, user="admin", job_id=123, details={"task": "backup"})
    >>> logs = logger.query(job_id=123)
    >>> logger.export_json("audit_export.json")
"""

from __future__ import annotations

import csv
import hashlib
import json
import logging
import sqlite3
import threading
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any, Protocol

logger = logging.getLogger(__name__)


class AuditAction(str, Enum):
    """Audit actions that can be logged."""

    CREATE = "create"
    UPDATE = "update"
    DELETE = "delete"
    CANCEL = "cancel"
    COMPLETE = "complete"
    FAIL = "fail"


class AuditError(Exception):
    """Raised when there's an error with audit logging."""

    pass


class TamperDetectionError(AuditError):
    """Raised when tampering is detected in the audit log."""

    pass


class RetentionPolicy:
    """Configuration for audit log retention policies.

    Attributes:
        max_age_days: Maximum age of logs in days (None = no limit)
        max_entries: Maximum number of entries (None = no limit)
        archive_path: Path to archive old entries (None = delete)
    """

    def __init__(
        self,
        max_age_days: int | None = None,
        max_entries: int | None = None,
        archive_path: str | Path | None = None,
    ) -> None:
        self.max_age_days = max_age_days
        self.max_entries = max_entries
        self.archive_path = Path(archive_path) if archive_path else None


@dataclass
class AuditEvent:
    """Represents a single audit log event.

    Attributes:
        id: Unique identifier for the event
        timestamp: When the event occurred
        action: The action that was performed (AuditAction)
        user: The user who performed the action
        job_id: The job ID associated with the action (optional)
        details: Additional details about the action
        hash: Chained hash for tamper detection
        previous_hash: Hash of the previous event in the chain
    """

    id: int | None = None
    timestamp: datetime = field(default_factory=datetime.utcnow)
    action: AuditAction = AuditAction.CREATE
    user: str = "system"
    job_id: int | None = None
    details: dict[str, Any] = field(default_factory=dict)
    hash: str = ""
    previous_hash: str = ""

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "id": self.id,
            "timestamp": self.timestamp.isoformat() if self.timestamp else None,
            "action": self.action.value,
            "user": self.user,
            "job_id": self.job_id,
            "details": self.details,
            "hash": self.hash,
            "previous_hash": self.previous_hash,
        }

    @classmethod
    def from_db_row(cls, row: sqlite3.Row) -> AuditEvent:
        """Create from database row."""
        return cls(
            id=row["id"],
            timestamp=datetime.fromisoformat(row["timestamp"]),
            action=AuditAction(row["action"]),
            user=row["user"],
            job_id=row["job_id"],
            details=json.loads(row["details"]) if row["details"] else {},
            hash=row["hash"],
            previous_hash=row["previous_hash"],
        )


class AuditEventHandler(Protocol):
    """Protocol for audit event handlers."""

    def __call__(self, event: AuditEvent) -> None: ...


class AuditLogger:
    """Immutable, append-only audit logger with tamper detection.

    Features:
    - Append-only SQLite storage
    - Chained hashes for tamper detection
    - Configurable retention policies
    - Export to JSON/CSV
    - Query by action, user, job_id, date range

    The hash chain works by including the previous event's hash in the
    calculation of the current event's hash. This creates a chain where
    any modification to a historical record will invalidate all subsequent
    hashes.

    Example:
        >>> logger = AuditLogger("/var/log/audit.db")
        >>> logger.log(AuditAction.CREATE, user="admin", job_id=1)
        >>> logger.verify_integrity()  # Raises TamperDetectionError if tampered
        >>> logger.export_csv("audit.csv")
    """

    _schema = """
    CREATE TABLE IF NOT EXISTS audit_events (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        timestamp TEXT NOT NULL,
        action TEXT NOT NULL,
        user TEXT NOT NULL,
        job_id INTEGER,
        details TEXT,
        hash TEXT NOT NULL,
        previous_hash TEXT NOT NULL
    );

    CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_events(timestamp);
    CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
    CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_events(user);
    CREATE INDEX IF NOT EXISTS idx_audit_job_id ON audit_events(job_id);

    -- Table for storing retention policy configuration
    CREATE TABLE IF NOT EXISTS audit_config (
        key TEXT PRIMARY KEY,
        value TEXT
    );
    """

    def __init__(
        self,
        db_path: str | Path = "~/.oclawma/audit.db",
        retention_policy: RetentionPolicy | None = None,
        on_event: AuditEventHandler | None = None,
    ) -> None:
        """Initialize the audit logger.

        Args:
            db_path: Path to SQLite database
            retention_policy: Optional retention policy configuration
            on_event: Optional callback for each logged event
        """
        self.db_path = Path(db_path).expanduser()
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self.retention_policy = retention_policy or RetentionPolicy()
        self.on_event = on_event
        self._lock = threading.RLock()
        self._conn: sqlite3.Connection | None = None
        self._init_db()

    def _get_connection(self) -> sqlite3.Connection:
        """Get or create database connection."""
        if self._conn is None:
            self._conn = sqlite3.connect(str(self.db_path))
            self._conn.row_factory = sqlite3.Row
        return self._conn

    def _init_db(self) -> None:
        """Initialize database schema."""
        conn = self._get_connection()
        conn.executescript(self._schema)
        conn.commit()

    def _get_last_hash(self) -> str:
        """Get the hash of the most recent event."""
        conn = self._get_connection()
        cursor = conn.execute("SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1")
        row = cursor.fetchone()
        return row["hash"] if row else "0" * 64  # Genesis hash

    def _calculate_hash(
        self,
        timestamp: datetime,
        action: AuditAction,
        user: str,
        job_id: int | None,
        details: dict[str, Any],
        previous_hash: str,
    ) -> str:
        """Calculate the hash for an event.

        The hash includes all event fields plus the previous hash,
        creating a chain that detects any modification.
        """
        data = {
            "timestamp": timestamp.isoformat(),
            "action": action.value,
            "user": user,
            "job_id": job_id,
            "details": json.dumps(details, sort_keys=True),
            "previous_hash": previous_hash,
        }
        # Create deterministic string representation
        hash_input = json.dumps(data, sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(hash_input.encode()).hexdigest()

    def log(
        self,
        action: AuditAction,
        user: str = "system",
        job_id: int | None = None,
        details: dict[str, Any] | None = None,
    ) -> AuditEvent:
        """Log an audit event.

        Args:
            action: The action that was performed
            user: The user who performed the action
            job_id: Optional job ID associated with the action
            details: Optional additional details

        Returns:
            The logged AuditEvent

        Raises:
            AuditError: If logging fails
        """
        with self._lock:
            try:
                timestamp = datetime.utcnow()
                previous_hash = self._get_last_hash()
                details = details or {}

                event_hash = self._calculate_hash(
                    timestamp, action, user, job_id, details, previous_hash
                )

                conn = self._get_connection()
                cursor = conn.execute(
                    """
                    INSERT INTO audit_events
                    (timestamp, action, user, job_id, details, hash, previous_hash)
                    VALUES (?, ?, ?, ?, ?, ?, ?)
                    """,
                    (
                        timestamp.isoformat(),
                        action.value,
                        user,
                        job_id,
                        json.dumps(details),
                        event_hash,
                        previous_hash,
                    ),
                )
                conn.commit()

                event = AuditEvent(
                    id=cursor.lastrowid,
                    timestamp=timestamp,
                    action=action,
                    user=user,
                    job_id=job_id,
                    details=details,
                    hash=event_hash,
                    previous_hash=previous_hash,
                )

                # Call event handler if configured
                if self.on_event:
                    try:
                        self.on_event(event)
                    except Exception as e:
                        logger.warning(f"Audit event handler failed: {e}")

                # Apply retention policy
                self._apply_retention_policy()

                logger.debug(f"Logged audit event: {action.value} by {user}")
                return event

            except sqlite3.Error as e:
                raise AuditError(f"Failed to log audit event: {e}") from e

    def query(
        self,
        action: AuditAction | None = None,
        user: str | None = None,
        job_id: int | None = None,
        since: datetime | None = None,
        until: datetime | None = None,
        limit: int | None = None,
        offset: int = 0,
    ) -> list[AuditEvent]:
        """Query audit events.

        Args:
            action: Filter by action type
            user: Filter by user
            job_id: Filter by job ID
            since: Filter events after this timestamp
            until: Filter events before this timestamp
            limit: Maximum number of results
            offset: Number of results to skip

        Returns:
            List of matching AuditEvent objects
        """
        conn = self._get_connection()

        conditions = []
        params: list[Any] = []

        if action:
            conditions.append("action = ?")
            params.append(action.value)
        if user:
            conditions.append("user = ?")
            params.append(user)
        if job_id is not None:
            conditions.append("job_id = ?")
            params.append(job_id)
        if since:
            conditions.append("timestamp >= ?")
            params.append(since.isoformat())
        if until:
            conditions.append("timestamp <= ?")
            params.append(until.isoformat())

        where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""

        query = f"SELECT * FROM audit_events {where_clause} ORDER BY id DESC"

        if limit is not None:
            query += f" LIMIT {limit} OFFSET {offset}"

        cursor = conn.execute(query, params)
        return [AuditEvent.from_db_row(row) for row in cursor.fetchall()]

    def get_event(self, event_id: int) -> AuditEvent | None:
        """Get a specific audit event by ID.

        Args:
            event_id: The event ID

        Returns:
            The AuditEvent or None if not found
        """
        conn = self._get_connection()
        cursor = conn.execute("SELECT * FROM audit_events WHERE id = ?", (event_id,))
        row = cursor.fetchone()
        return AuditEvent.from_db_row(row) if row else None

    def verify_integrity(self) -> tuple[bool, list[int]]:
        """Verify the integrity of the audit log.

        Checks that all hashes are valid and the chain is unbroken.

        Returns:
            Tuple of (is_valid, list_of_tampered_event_ids)

        Raises:
            TamperDetectionError: If tampering is detected (optional behavior)
        """
        conn = self._get_connection()
        cursor = conn.execute("SELECT * FROM audit_events ORDER BY id ASC")
        rows = cursor.fetchall()

        tampered_ids = []
        previous_hash = "0" * 64

        for row in rows:
            # Verify hash chain
            if row["previous_hash"] != previous_hash:
                tampered_ids.append(row["id"])
                continue

            # Recalculate hash
            calculated_hash = self._calculate_hash(
                timestamp=datetime.fromisoformat(row["timestamp"]),
                action=AuditAction(row["action"]),
                user=row["user"],
                job_id=row["job_id"],
                details=json.loads(row["details"]) if row["details"] else {},
                previous_hash=row["previous_hash"],
            )

            if calculated_hash != row["hash"]:
                tampered_ids.append(row["id"])

            previous_hash = row["hash"]

        is_valid = len(tampered_ids) == 0
        return is_valid, tampered_ids

    def assert_integrity(self) -> None:
        """Assert the integrity of the audit log.

        Raises:
            TamperDetectionError: If tampering is detected
        """
        is_valid, tampered_ids = self.verify_integrity()
        if not is_valid:
            raise TamperDetectionError(f"Tampering detected in audit log at events: {tampered_ids}")

    def get_stats(self) -> dict[str, Any]:
        """Get audit log statistics.

        Returns:
            Dictionary with statistics
        """
        conn = self._get_connection()

        # Total count
        cursor = conn.execute("SELECT COUNT(*) as count FROM audit_events")
        total = cursor.fetchone()["count"]

        # Count by action
        cursor = conn.execute("SELECT action, COUNT(*) as count FROM audit_events GROUP BY action")
        by_action = {row["action"]: row["count"] for row in cursor.fetchall()}

        # Date range
        cursor = conn.execute(
            "SELECT MIN(timestamp) as min_ts, MAX(timestamp) as max_ts FROM audit_events"
        )
        row = cursor.fetchone()
        date_range = (
            {
                "oldest": row["min_ts"],
                "newest": row["max_ts"],
            }
            if row["min_ts"]
            else {"oldest": None, "newest": None}
        )

        return {
            "total_events": total,
            "by_action": by_action,
            "date_range": date_range,
            "db_path": str(self.db_path),
            "retention_policy": {
                "max_age_days": self.retention_policy.max_age_days,
                "max_entries": self.retention_policy.max_entries,
            },
        }

    def export_json(
        self,
        output_path: str | Path,
        since: datetime | None = None,
        until: datetime | None = None,
    ) -> int:
        """Export audit log to JSON.

        Args:
            output_path: Path to output file
            since: Export events after this timestamp
            until: Export events before this timestamp

        Returns:
            Number of events exported
        """
        events = self.query(since=since, until=until)
        data = {
            "exported_at": datetime.utcnow().isoformat(),
            "event_count": len(events),
            "events": [event.to_dict() for event in events],
        }

        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)

        with open(output_path, "w") as f:
            json.dump(data, f, indent=2)

        return len(events)

    def export_csv(
        self,
        output_path: str | Path,
        since: datetime | None = None,
        until: datetime | None = None,
    ) -> int:
        """Export audit log to CSV.

        Args:
            output_path: Path to output file
            since: Export events after this timestamp
            until: Export events before this timestamp

        Returns:
            Number of events exported
        """
        events = self.query(since=since, until=until)

        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)

        with open(output_path, "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(
                ["id", "timestamp", "action", "user", "job_id", "details", "hash", "previous_hash"]
            )
            for event in events:
                writer.writerow(
                    [
                        event.id,
                        event.timestamp.isoformat(),
                        event.action.value,
                        event.user,
                        event.job_id,
                        json.dumps(event.details),
                        event.hash,
                        event.previous_hash,
                    ]
                )

        return len(events)

    def _apply_retention_policy(self) -> None:
        """Apply retention policy to clean up old events."""
        if not self.retention_policy:
            return

        conn = self._get_connection()
        deleted_count = 0

        # Apply max_age_days policy
        if self.retention_policy.max_age_days:
            cutoff = datetime.utcnow() - timedelta(days=self.retention_policy.max_age_days)

            # Archive if path is set
            if self.retention_policy.archive_path:
                self._archive_old_events(cutoff)

            cursor = conn.execute(
                "DELETE FROM audit_events WHERE timestamp < ?", (cutoff.isoformat(),)
            )
            deleted_count += cursor.rowcount

        # Apply max_entries policy
        if self.retention_policy.max_entries:
            cursor = conn.execute("SELECT COUNT(*) as count FROM audit_events")
            total = cursor.fetchone()["count"]

            if total > self.retention_policy.max_entries:
                excess = total - self.retention_policy.max_entries

                # Archive if path is set
                if self.retention_policy.archive_path:
                    cursor = conn.execute(
                        "SELECT * FROM audit_events ORDER BY id ASC LIMIT ?", (excess,)
                    )
                    to_archive = [dict(row) for row in cursor.fetchall()]
                    self._archive_rows(to_archive)

                cursor = conn.execute(
                    """DELETE FROM audit_events WHERE id IN (
                        SELECT id FROM audit_events ORDER BY id ASC LIMIT ?
                    )""",
                    (excess,),
                )
                deleted_count += cursor.rowcount

        if deleted_count > 0:
            conn.commit()
            logger.info(f"Retention policy deleted {deleted_count} old audit events")

    def _archive_old_events(self, cutoff: datetime) -> None:
        """Archive events older than cutoff."""
        conn = self._get_connection()
        cursor = conn.execute(
            "SELECT * FROM audit_events WHERE timestamp < ?", (cutoff.isoformat(),)
        )
        rows = [dict(row) for row in cursor.fetchall()]
        self._archive_rows(rows)

    def _archive_rows(self, rows: list[dict[str, Any]]) -> None:
        """Archive rows to archive database."""
        if not self.retention_policy or not self.retention_policy.archive_path:
            return

        self.retention_policy.archive_path.parent.mkdir(parents=True, exist_ok=True)

        archive_conn = sqlite3.connect(str(self.retention_policy.archive_path))
        archive_conn.executescript(self._schema)

        for row in rows:
            archive_conn.execute(
                """
                INSERT INTO audit_events
                (id, timestamp, action, user, job_id, details, hash, previous_hash)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    row["id"],
                    row["timestamp"],
                    row["action"],
                    row["user"],
                    row["job_id"],
                    row["details"],
                    row["hash"],
                    row["previous_hash"],
                ),
            )

        archive_conn.commit()
        archive_conn.close()
        logger.info(f"Archived {len(rows)} audit events")

    def close(self) -> None:
        """Close the database connection."""
        if self._conn:
            self._conn.close()
            self._conn = None

    def __enter__(self) -> AuditLogger:
        """Context manager entry."""
        return self

    def __exit__(self, *args: Any) -> None:
        """Context manager exit."""
        self.close()


# Global instance for convenience
_global_audit_logger: AuditLogger | None = None


def configure_global_audit_logger(
    db_path: str | Path = "~/.oclawma/audit.db",
    retention_policy: RetentionPolicy | None = None,
) -> AuditLogger:
    """Configure the global audit logger instance.

    Args:
        db_path: Path to SQLite database
        retention_policy: Optional retention policy configuration

    Returns:
        The configured AuditLogger
    """
    global _global_audit_logger
    _global_audit_logger = AuditLogger(db_path, retention_policy)
    return _global_audit_logger


def get_global_audit_logger() -> AuditLogger | None:
    """Get the global audit logger instance.

    Returns:
        The global AuditLogger or None if not configured
    """
    return _global_audit_logger


def log_audit(
    action: AuditAction,
    user: str = "system",
    job_id: int | None = None,
    details: dict[str, Any] | None = None,
) -> AuditEvent | None:
    """Log an audit event using the global audit logger.

    Args:
        action: The action that was performed
        user: The user who performed the action
        job_id: Optional job ID associated with the action
        details: Optional additional details

    Returns:
        The logged AuditEvent or None if no global logger configured
    """
    if _global_audit_logger is None:
        logger.warning("No global audit logger configured, audit event not logged")
        return None
    return _global_audit_logger.log(action, user, job_id, details)
