"""Channel audit logging for GreyMatter Solo.

Records every channel operation (sync, share, retract, review) to an
append-only audit log. Designed for:

- **Human-readable UX**: format_event() renders each event as a readable line
- **Replay capability**: replay() re-reads the log for unattended AI decisions
- **Granular verbosity**: every field change, every decision, every outcome

Extends the security_events table pattern but uses its own table
(channel_audit_log) for channel-specific fields like channel_id,
target_node, data_scope, and structured payloads.

Usage:
    from greymatter_core.channel_audit import ChannelAuditLogger

    logger = ChannelAuditLogger(db)
    logger.log_sync(channel_id="ch-personal", direction="push", ...)
    logger.log_share(channel_id="ch-team", target_node="node-2", ...)
    events = logger.replay(channel_id="ch-personal")
    for line in logger.format_events(events):
        print(line)
"""
from __future__ import annotations

import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any

from .db import Database, now_sql
from .ids import generate_id

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Constants and enums
# ---------------------------------------------------------------------------

class ChannelOperation(str, Enum):
    """Operations that can be performed on a channel."""
    SYNC = "sync"
    SHARE = "share"
    RETRACT = "retract"
    REVIEW = "review"


class AuditSeverity(str, Enum):
    """Severity levels for audit events."""
    INFO = "info"
    WARN = "warn"
    HIGH = "high"
    CRITICAL = "critical"


class AuditOutcome(str, Enum):
    """Outcome of an audited operation."""
    SUCCESS = "success"
    FAILED = "failed"
    PARTIAL = "partial"
    DENIED = "denied"
    PENDING = "pending"


CHANNEL_AUDIT_SCHEMA = """
CREATE TABLE IF NOT EXISTS channel_audit_log (
    id TEXT PRIMARY KEY,
    channel_id TEXT NOT NULL,
    operation TEXT NOT NULL,
    severity TEXT NOT NULL DEFAULT 'info',
    outcome TEXT NOT NULL DEFAULT 'success',
    actor TEXT NOT NULL DEFAULT '',
    actor_type TEXT NOT NULL DEFAULT 'system',
    target_node TEXT DEFAULT NULL,
    direction TEXT DEFAULT NULL,
    data_scope TEXT DEFAULT NULL,
    items_affected INTEGER DEFAULT 0,
    detail TEXT DEFAULT NULL,
    payload TEXT DEFAULT NULL,
    session_id TEXT DEFAULT NULL,
    node_id TEXT DEFAULT NULL,
    parent_event_id TEXT DEFAULT NULL,
    created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_channel_audit_channel
    ON channel_audit_log(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_channel_audit_operation
    ON channel_audit_log(operation, created_at);
CREATE INDEX IF NOT EXISTS idx_channel_audit_severity
    ON channel_audit_log(severity) WHERE severity != 'info';
CREATE INDEX IF NOT EXISTS idx_channel_audit_outcome
    ON channel_audit_log(outcome) WHERE outcome != 'success';
CREATE INDEX IF NOT EXISTS idx_channel_audit_actor
    ON channel_audit_log(actor) WHERE actor != '';
CREATE INDEX IF NOT EXISTS idx_channel_audit_parent
    ON channel_audit_log(parent_event_id) WHERE parent_event_id IS NOT NULL;
"""


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------

@dataclass
class AuditEvent:
    """A single channel audit event."""
    id: str
    channel_id: str
    operation: str
    severity: str = "info"
    outcome: str = "success"
    actor: str = ""
    actor_type: str = "system"
    target_node: str | None = None
    direction: str | None = None
    data_scope: str | None = None
    items_affected: int = 0
    detail: str | None = None
    payload: dict[str, Any] | None = None
    session_id: str | None = None
    node_id: str | None = None
    parent_event_id: str | None = None
    created_at: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dict for storage."""
        d = {
            "id": self.id,
            "channel_id": self.channel_id,
            "operation": self.operation,
            "severity": self.severity,
            "outcome": self.outcome,
            "actor": self.actor,
            "actor_type": self.actor_type,
            "target_node": self.target_node,
            "direction": self.direction,
            "data_scope": self.data_scope,
            "items_affected": self.items_affected,
            "detail": self.detail,
            "payload": json.dumps(self.payload) if self.payload else None,
            "session_id": self.session_id,
            "node_id": self.node_id,
            "parent_event_id": self.parent_event_id,
        }
        if self.created_at:
            d["created_at"] = self.created_at
        return d

    @classmethod
    def from_row(cls, row: dict[str, Any]) -> AuditEvent:
        """Create from a database row dict."""
        payload_raw = row.get("payload")
        payload = None
        if payload_raw:
            try:
                payload = json.loads(payload_raw)
            except (json.JSONDecodeError, TypeError):
                payload = {"raw": payload_raw}
        return cls(
            id=row["id"],
            channel_id=row["channel_id"],
            operation=row["operation"],
            severity=row.get("severity", "info"),
            outcome=row.get("outcome", "success"),
            actor=row.get("actor", ""),
            actor_type=row.get("actor_type", "system"),
            target_node=row.get("target_node"),
            direction=row.get("direction"),
            data_scope=row.get("data_scope"),
            items_affected=row.get("items_affected", 0),
            detail=row.get("detail"),
            payload=payload,
            session_id=row.get("session_id"),
            node_id=row.get("node_id"),
            parent_event_id=row.get("parent_event_id"),
            created_at=row.get("created_at"),
        )


# ---------------------------------------------------------------------------
# Human-readable formatting
# ---------------------------------------------------------------------------

_OPERATION_VERBS = {
    "sync": "synced",
    "share": "shared",
    "retract": "retracted",
    "review": "reviewed",
}

_SEVERITY_MARKERS = {
    "info": " ",
    "warn": "!",
    "high": "!!",
    "critical": "!!!",
}

_OUTCOME_SYMBOLS = {
    "success": "OK",
    "failed": "FAIL",
    "partial": "PARTIAL",
    "denied": "DENIED",
    "pending": "PEND",
}


def format_event(event: AuditEvent) -> str:
    """Format a single audit event as a human-readable line.

    Format:
        [2026-03-22 14:32:01]  OK  ch-personal  synced  push  12 items  agent:dispatch-1
        [2026-03-22 14:32:05] !!! DENIED ch-team  shared  -> node-2  Insufficient permissions

    Returns:
        A single formatted line with no trailing newline.
    """
    ts = event.created_at or "unknown"
    if len(ts) > 19:
        ts = ts[:19]

    severity = _SEVERITY_MARKERS.get(event.severity, " ")
    outcome = _OUTCOME_SYMBOLS.get(event.outcome, event.outcome.upper())
    verb = _OPERATION_VERBS.get(event.operation, event.operation)

    parts = [f"[{ts}]", f"{severity:>3}", f"{outcome:<7}", event.channel_id, verb]

    if event.direction:
        parts.append(event.direction)
    if event.target_node:
        parts.append(f"-> {event.target_node}")
    if event.items_affected > 0:
        parts.append(f"{event.items_affected} items")
    if event.actor:
        parts.append(f"{event.actor_type}:{event.actor}")
    if event.detail:
        parts.append(f"| {event.detail}")

    return "  ".join(parts)


def format_events(events: list[AuditEvent]) -> list[str]:
    """Format multiple audit events as human-readable lines."""
    return [format_event(e) for e in events]


def format_replay_report(events: list[AuditEvent]) -> str:
    """Format a replay report for review of unattended AI decisions.

    Groups events by operation type, highlights failures and denials,
    and provides a summary with counts.
    """
    if not events:
        return "No audit events to replay."

    lines: list[str] = []
    lines.append("=" * 72)
    lines.append("CHANNEL AUDIT REPLAY REPORT")
    lines.append(f"Events: {len(events)}")
    if events:
        lines.append(f"Period: {events[-1].created_at or '?'} to {events[0].created_at or '?'}")
    lines.append("=" * 72)

    # Summary counts
    by_op: dict[str, int] = {}
    by_outcome: dict[str, int] = {}
    ai_decisions: list[AuditEvent] = []

    for event in events:
        by_op[event.operation] = by_op.get(event.operation, 0) + 1
        by_outcome[event.outcome] = by_outcome.get(event.outcome, 0) + 1
        if event.actor_type in ("agent", "ai", "daemon"):
            ai_decisions.append(event)

    lines.append("")
    lines.append("SUMMARY")
    lines.append("-" * 40)
    for op, count in sorted(by_op.items()):
        lines.append(f"  {op:<12} {count:>5}")
    lines.append("")
    for outcome, count in sorted(by_outcome.items()):
        lines.append(f"  {outcome:<12} {count:>5}")

    # AI decisions section — the core replay target
    if ai_decisions:
        lines.append("")
        lines.append("AI/AGENT DECISIONS (requires human review)")
        lines.append("-" * 40)
        for event in ai_decisions:
            lines.append(format_event(event))
            if event.payload:
                for key, val in event.payload.items():
                    lines.append(f"    {key}: {val}")

    # Failures and denials
    problems = [e for e in events if e.outcome in ("failed", "denied")]
    if problems:
        lines.append("")
        lines.append("FAILURES / DENIALS")
        lines.append("-" * 40)
        for event in problems:
            lines.append(format_event(event))

    # Full timeline
    lines.append("")
    lines.append("FULL TIMELINE")
    lines.append("-" * 40)
    for event in events:
        lines.append(format_event(event))

    lines.append("")
    lines.append("=" * 72)
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Logger class
# ---------------------------------------------------------------------------

class ChannelAuditLogger:
    """Append-only audit logger for channel operations.

    All log methods are fail-silent by default (like security_audit.py) —
    they catch exceptions and log warnings rather than propagating, so
    audit logging never blocks the operation it records.

    For critical operations (retract, review with deny), set
    ``fail_silent=False`` at construction to propagate write errors.
    """

    def __init__(self, db: Database, *, fail_silent: bool = True):
        self._db = db
        self._fail_silent = fail_silent
        self._session_id = os.environ.get("CLAUDE_SESSION_ID", "")
        self._node_id = os.environ.get("GM_NODE_ID", "")

    # -- core write ----------------------------------------------------------

    def _write(self, event: AuditEvent) -> str | None:
        """Write a single audit event. Returns the event ID on success.

        Respects the fail_silent flag set at construction.
        """
        try:
            data = event.to_dict()
            cols = ", ".join(data.keys())
            placeholders = ", ".join("?" for _ in data)
            self._db.execute(
                f"INSERT INTO channel_audit_log ({cols}) VALUES ({placeholders})",
                tuple(data.values()),
            )
            logger.debug("channel audit: %s %s %s", event.operation, event.channel_id, event.outcome)
            return event.id
        except Exception:
            if self._fail_silent:
                logger.warning(
                    "Failed to write channel audit event: %s %s",
                    event.operation,
                    event.channel_id,
                    exc_info=True,
                )
                return None
            raise

    def _make_event(
        self,
        channel_id: str,
        operation: str,
        *,
        severity: str = "info",
        outcome: str = "success",
        actor: str = "",
        actor_type: str = "system",
        target_node: str | None = None,
        direction: str | None = None,
        data_scope: str | None = None,
        items_affected: int = 0,
        detail: str | None = None,
        payload: dict[str, Any] | None = None,
        parent_event_id: str | None = None,
    ) -> AuditEvent:
        """Construct an AuditEvent with auto-populated fields."""
        return AuditEvent(
            id=generate_id("cae"),
            channel_id=channel_id,
            operation=operation,
            severity=severity,
            outcome=outcome,
            actor=actor,
            actor_type=actor_type,
            target_node=target_node,
            direction=direction,
            data_scope=data_scope,
            items_affected=items_affected,
            detail=detail,
            payload=payload,
            session_id=self._session_id,
            node_id=self._node_id,
            parent_event_id=parent_event_id,
        )

    # -- typed log methods ---------------------------------------------------

    def log_sync(
        self,
        channel_id: str,
        direction: str,
        items_affected: int = 0,
        *,
        outcome: str = "success",
        target_node: str | None = None,
        data_scope: str | None = None,
        detail: str | None = None,
        payload: dict[str, Any] | None = None,
        actor: str = "",
        actor_type: str = "system",
        parent_event_id: str | None = None,
    ) -> str | None:
        """Log a channel sync operation.

        Args:
            channel_id: Which channel was synced.
            direction: 'push', 'pull', or 'bidirectional'.
            items_affected: Number of items synced.
            outcome: success/failed/partial.
            target_node: Remote node synced with.
            data_scope: What data was included (e.g. 'observations,knowledge').
            detail: Human-readable description.
            payload: Structured data (e.g. version numbers, conflict counts).
            actor: Who initiated (agent ID, user, daemon).
            actor_type: 'agent', 'user', 'system', 'daemon'.
            parent_event_id: Link to parent event for sub-operations.

        Returns:
            The event ID if written, None on failure (when fail_silent=True).
        """
        severity = "info"
        if outcome == "failed":
            severity = "high"
        elif outcome == "partial":
            severity = "warn"

        event = self._make_event(
            channel_id,
            ChannelOperation.SYNC.value,
            severity=severity,
            outcome=outcome,
            actor=actor,
            actor_type=actor_type,
            target_node=target_node,
            direction=direction,
            data_scope=data_scope,
            items_affected=items_affected,
            detail=detail,
            payload=payload,
            parent_event_id=parent_event_id,
        )
        return self._write(event)

    def log_share(
        self,
        channel_id: str,
        target_node: str,
        items_affected: int = 0,
        *,
        outcome: str = "success",
        data_scope: str | None = None,
        detail: str | None = None,
        payload: dict[str, Any] | None = None,
        actor: str = "",
        actor_type: str = "system",
        parent_event_id: str | None = None,
    ) -> str | None:
        """Log a channel share operation — making data available to another node.

        Args:
            channel_id: Which channel was shared.
            target_node: Who received the share.
            items_affected: Number of items shared.
            outcome: success/failed/denied.
            data_scope: What was shared (e.g. 'patterns,knowledge').
            detail: Human-readable description.
            payload: Structured data (e.g. item IDs, encryption status).
            actor: Who initiated.
            actor_type: 'agent', 'user', 'system', 'daemon'.
            parent_event_id: Link to parent event for sub-operations.

        Returns:
            The event ID if written, None on failure.
        """
        severity = "info"
        if outcome == "denied":
            severity = "high"
        elif outcome == "failed":
            severity = "high"

        event = self._make_event(
            channel_id,
            ChannelOperation.SHARE.value,
            severity=severity,
            outcome=outcome,
            actor=actor,
            actor_type=actor_type,
            target_node=target_node,
            direction="outbound",
            data_scope=data_scope,
            items_affected=items_affected,
            detail=detail,
            payload=payload,
            parent_event_id=parent_event_id,
        )
        return self._write(event)

    def log_retract(
        self,
        channel_id: str,
        items_affected: int = 0,
        *,
        outcome: str = "success",
        target_node: str | None = None,
        data_scope: str | None = None,
        detail: str | None = None,
        payload: dict[str, Any] | None = None,
        actor: str = "",
        actor_type: str = "system",
        parent_event_id: str | None = None,
    ) -> str | None:
        """Log a channel retract operation — withdrawing previously shared data.

        Retract is always severity=high because it modifies shared state.

        Args:
            channel_id: Which channel's data was retracted.
            items_affected: Number of items retracted.
            outcome: success/failed/partial.
            target_node: From which node data was retracted (None = all).
            data_scope: What was retracted.
            detail: Human-readable description.
            payload: Structured data (e.g. retracted item IDs, reason).
            actor: Who initiated.
            actor_type: 'agent', 'user', 'system', 'daemon'.
            parent_event_id: Link to parent event.

        Returns:
            The event ID if written, None on failure.
        """
        severity = "high"
        if outcome == "failed":
            severity = "critical"

        event = self._make_event(
            channel_id,
            ChannelOperation.RETRACT.value,
            severity=severity,
            outcome=outcome,
            actor=actor,
            actor_type=actor_type,
            target_node=target_node,
            direction="retract",
            data_scope=data_scope,
            items_affected=items_affected,
            detail=detail,
            payload=payload,
            parent_event_id=parent_event_id,
        )
        return self._write(event)

    def log_review(
        self,
        channel_id: str,
        *,
        outcome: str = "success",
        detail: str | None = None,
        payload: dict[str, Any] | None = None,
        actor: str = "",
        actor_type: str = "system",
        items_affected: int = 0,
        data_scope: str | None = None,
        parent_event_id: str | None = None,
    ) -> str | None:
        """Log a channel review — human or AI reviewing channel contents/decisions.

        Args:
            channel_id: Which channel was reviewed.
            outcome: success (approved) / failed (rejected) / pending.
            detail: Review notes or verdict summary.
            payload: Structured review data (e.g. findings, recommendations).
            actor: Who performed the review.
            actor_type: 'agent', 'user', 'system'.
            items_affected: How many items were reviewed.
            data_scope: What was reviewed.
            parent_event_id: Link to originating event being reviewed.

        Returns:
            The event ID if written, None on failure.
        """
        severity = "info"
        if outcome == "failed":
            severity = "warn"
        if actor_type in ("agent", "ai", "daemon"):
            # AI reviews are always at least warn — they need human attention
            severity = max(severity, "warn", key=lambda s: list(AuditSeverity).index(AuditSeverity(s)))

        event = self._make_event(
            channel_id,
            ChannelOperation.REVIEW.value,
            severity=severity,
            outcome=outcome,
            actor=actor,
            actor_type=actor_type,
            data_scope=data_scope,
            items_affected=items_affected,
            detail=detail,
            payload=payload,
            parent_event_id=parent_event_id,
        )
        return self._write(event)

    # -- cross-reference to security_events ----------------------------------

    def log_to_security_events(
        self,
        event: AuditEvent,
    ) -> None:
        """Mirror a channel audit event to the security_events table.

        High-severity channel operations (retract, denied shares) are also
        recorded in security_events for unified security dashboards.
        """
        try:
            self._db.execute(
                "INSERT INTO security_events "
                "(event_type, severity, pattern_name, tool_name, action_taken, detail) "
                "VALUES (?, ?, ?, ?, ?, ?)",
                (
                    f"channel_{event.operation}",
                    event.severity,
                    None,
                    f"channel:{event.channel_id}",
                    event.outcome,
                    event.detail or f"{event.operation} on {event.channel_id}",
                ),
            )
        except Exception:
            logger.warning(
                "Failed to mirror audit event to security_events",
                exc_info=True,
            )

    # -- query methods -------------------------------------------------------

    def get_events(
        self,
        *,
        channel_id: str | None = None,
        operation: str | None = None,
        severity: str | None = None,
        outcome: str | None = None,
        actor: str | None = None,
        since: str | None = None,
        until: str | None = None,
        limit: int = 100,
    ) -> list[AuditEvent]:
        """Query audit events with flexible filters.

        All parameters are optional. Events are returned newest-first.

        Args:
            channel_id: Filter by channel.
            operation: Filter by operation type.
            severity: Filter by minimum severity.
            outcome: Filter by outcome.
            actor: Filter by actor.
            since: ISO timestamp lower bound (inclusive).
            until: ISO timestamp upper bound (inclusive).
            limit: Maximum events to return.

        Returns:
            List of AuditEvent objects, newest first.
        """
        clauses: list[str] = []
        params: list[Any] = []

        if channel_id:
            clauses.append("channel_id = ?")
            params.append(channel_id)
        if operation:
            clauses.append("operation = ?")
            params.append(operation)
        if severity:
            clauses.append("severity = ?")
            params.append(severity)
        if outcome:
            clauses.append("outcome = ?")
            params.append(outcome)
        if actor:
            clauses.append("actor = ?")
            params.append(actor)
        if since:
            clauses.append("created_at >= ?")
            params.append(since)
        if until:
            clauses.append("created_at <= ?")
            params.append(until)

        where = " AND ".join(clauses) if clauses else "1=1"
        params.append(limit)

        rows = self._db.execute(
            f"SELECT * FROM channel_audit_log WHERE {where} "
            f"ORDER BY created_at DESC LIMIT ?",
            tuple(params),
        )
        return [AuditEvent.from_row(row) for row in rows]

    def replay(
        self,
        *,
        channel_id: str | None = None,
        since: str | None = None,
        until: str | None = None,
        ai_only: bool = False,
        limit: int = 500,
    ) -> list[AuditEvent]:
        """Replay audit events for review of unattended AI decisions.

        This is the primary entry point for humans reviewing what AI agents
        did while unattended. Returns events in chronological order
        (oldest first) for natural replay reading.

        Args:
            channel_id: Replay events for a specific channel.
            since: Start of replay window (ISO timestamp).
            until: End of replay window (ISO timestamp).
            ai_only: If True, only return events from AI/agent actors.
            limit: Maximum events to return.

        Returns:
            List of AuditEvent objects, oldest first (chronological).
        """
        clauses: list[str] = []
        params: list[Any] = []

        if channel_id:
            clauses.append("channel_id = ?")
            params.append(channel_id)
        if since:
            clauses.append("created_at >= ?")
            params.append(since)
        if until:
            clauses.append("created_at <= ?")
            params.append(until)
        if ai_only:
            clauses.append("actor_type IN ('agent', 'ai', 'daemon')")

        where = " AND ".join(clauses) if clauses else "1=1"
        params.append(limit)

        rows = self._db.execute(
            f"SELECT * FROM channel_audit_log WHERE {where} "
            f"ORDER BY created_at ASC LIMIT ?",
            tuple(params),
        )
        return [AuditEvent.from_row(row) for row in rows]

    def get_stats(
        self,
        *,
        channel_id: str | None = None,
    ) -> dict[str, Any]:
        """Get aggregate statistics for the audit dashboard.

        Args:
            channel_id: Optional — scope stats to a single channel.

        Returns:
            Dict with total_events, by_operation, by_outcome, by_severity,
            ai_decision_count, and recent_failures.
        """
        where = "1=1"
        params: tuple = ()
        if channel_id:
            where = "channel_id = ?"
            params = (channel_id,)

        total_row = self._db.execute_one(
            f"SELECT COUNT(*) AS cnt FROM channel_audit_log WHERE {where}",
            params,
        )
        total = total_row["cnt"] if total_row else 0

        by_operation: dict[str, int] = {}
        for row in self._db.execute(
            f"SELECT operation, COUNT(*) AS cnt FROM channel_audit_log "
            f"WHERE {where} GROUP BY operation",
            params,
        ):
            by_operation[row["operation"]] = row["cnt"]

        by_outcome: dict[str, int] = {}
        for row in self._db.execute(
            f"SELECT outcome, COUNT(*) AS cnt FROM channel_audit_log "
            f"WHERE {where} GROUP BY outcome",
            params,
        ):
            by_outcome[row["outcome"]] = row["cnt"]

        by_severity: dict[str, int] = {}
        for row in self._db.execute(
            f"SELECT severity, COUNT(*) AS cnt FROM channel_audit_log "
            f"WHERE {where} GROUP BY severity",
            params,
        ):
            by_severity[row["severity"]] = row["cnt"]

        ai_row = self._db.execute_one(
            f"SELECT COUNT(*) AS cnt FROM channel_audit_log "
            f"WHERE {where} AND actor_type IN ('agent', 'ai', 'daemon')",
            params,
        )
        ai_count = ai_row["cnt"] if ai_row else 0

        recent_failures = self._db.execute(
            f"SELECT * FROM channel_audit_log "
            f"WHERE {where} AND outcome IN ('failed', 'denied') "
            f"ORDER BY created_at DESC LIMIT 10",
            params,
        )

        return {
            "total_events": total,
            "by_operation": by_operation,
            "by_outcome": by_outcome,
            "by_severity": by_severity,
            "ai_decision_count": ai_count,
            "recent_failures": [AuditEvent.from_row(r).to_dict() for r in recent_failures],
        }

    def get_event_chain(self, event_id: str) -> list[AuditEvent]:
        """Get an event and all its child events (linked by parent_event_id).

        Useful for understanding a multi-step operation (e.g., a sync that
        triggered sub-events for each table synced).

        Args:
            event_id: The root event ID.

        Returns:
            List starting with the root event, followed by children
            in chronological order.
        """
        root_rows = self._db.execute(
            "SELECT * FROM channel_audit_log WHERE id = ?",
            (event_id,),
        )
        child_rows = self._db.execute(
            "SELECT * FROM channel_audit_log WHERE parent_event_id = ? "
            "ORDER BY created_at ASC",
            (event_id,),
        )
        events = [AuditEvent.from_row(r) for r in root_rows]
        events.extend(AuditEvent.from_row(r) for r in child_rows)
        return events
