"""
cortex/tools/export_import.py — Memory export/import utilities.

Supports exporting Cortex memories to JSON, CSV, Markdown, and SQLite formats,
and importing from evaOS JSON format or Mem0 JSONL migration exports.

Export formats:
    - JSON: Full structured dump (claims + entities + cornerstones)
    - CSV: Tabular claims export for spreadsheet analysis
    - Markdown: Human-readable grouped by category

Import formats:
    - evaOS: Native JSON format (round-trip safe)
    - Mem0: JSONL export format (one record per line)

Usage (programmatic):
    storage = SQLiteAdapter(db_path)
    await storage.initialize()

    # Export
    await export_json(storage, owner_id="eva", output_path="backup.json")
    await export_csv(storage, owner_id="eva", output_path="claims.csv")
    await export_markdown(storage, owner_id="eva", output_path="memories.md")

    # Import
    result = await import_json(storage, input_path="backup.json", owner_id="eva")
    result = await import_mem0(storage, input_path="mem0_export.jsonl", owner_id="eva", dry_run=True)
    print(f"Imported {result.imported}, skipped {result.skipped}")
"""
from __future__ import annotations

import csv
import hashlib
import io
import json
import logging
import re
import shutil
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------

@dataclass
class ImportResult:
    """Summary of an import operation."""
    imported: int = 0
    skipped: int = 0
    errors: List[str] = field(default_factory=list)
    dry_run: bool = False

    def __str__(self) -> str:
        mode = " [DRY RUN]" if self.dry_run else ""
        return (
            f"ImportResult{mode}: imported={self.imported}, "
            f"skipped={self.skipped}, errors={len(self.errors)}"
        )


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

_EXPORT_VERSION = "1.0"


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="milliseconds")


def _claim_hash(subject: str, predicate: str, object_value: str) -> str:
    """Deterministic hash for dedup — same triple always hashes identically."""
    key = f"{subject.strip().lower()}|{predicate.strip().lower()}|{object_value.strip().lower()}"
    return hashlib.sha256(key.encode()).hexdigest()


def _claim_to_dict(claim: Any) -> Dict[str, Any]:
    """Serialize a SemanticClaim dataclass to a plain dict."""
    fields = [
        "id", "owner_id", "subject", "predicate", "object_value",
        "memory_type", "subject_entity_id", "object_entity_id",
        "datatype", "confidence", "sensitivity", "scope",
        "valid_from", "valid_to", "status", "superseded_by",
        "last_accessed", "access_count", "source_session",
        "created_at", "updated_at",
    ]
    # category is present in DB but not in the SemanticClaim dataclass fields —
    # try to read it gracefully (may exist as a dynamic attribute).
    d = {}
    for f in fields:
        d[f] = getattr(claim, f, None)
    # Capture category if it exists on the object
    if hasattr(claim, "category"):
        d["category"] = claim.category
    return d


def _entity_to_dict(entity: Any) -> Dict[str, Any]:
    fields = [
        "id", "owner_id", "canonical_name", "entity_type", "description",
        "metadata_json", "first_seen", "last_seen", "mention_count",
        "created_at", "updated_at",
    ]
    return {f: getattr(entity, f, None) for f in fields}


def _cornerstone_to_dict(cs: Any) -> Dict[str, Any]:
    fields = [
        "id", "owner_id", "label", "content", "golden_copy", "golden_hash",
        "current_hash", "drift_score", "last_drift_check", "token_count",
        "injection_order", "sealed_at", "sealed_by", "revision",
        "revision_history", "status", "created_at", "updated_at",
    ]
    return {f: getattr(cs, f, None) for f in fields}


# ---------------------------------------------------------------------------
# Fetch all claims for an owner (all statuses for export completeness)
# ---------------------------------------------------------------------------

async def _fetch_all_claims(storage: Any, owner_id: str) -> List[Any]:
    """Fetch every claim for owner, regardless of status."""
    conn = await storage._get_conn()
    async with conn.execute(
        "SELECT * FROM semantic_claim WHERE owner_id = ? ORDER BY created_at ASC",
        (owner_id,),
    ) as cur:
        rows = await cur.fetchall()
    from cortex.storage.sqlite_adapter import _row_to_claim  # type: ignore[import]
    return [_row_to_claim(r) for r in rows]


async def _fetch_all_entities(storage: Any, owner_id: str) -> List[Any]:
    """Fetch every entity for owner."""
    conn = await storage._get_conn()
    async with conn.execute(
        "SELECT * FROM entity WHERE owner_id = ? ORDER BY created_at ASC",
        (owner_id,),
    ) as cur:
        rows = await cur.fetchall()
    from cortex.storage.sqlite_adapter import _row_to_entity  # type: ignore[import]
    return [_row_to_entity(r) for r in rows]


async def _fetch_existing_hashes(storage: Any, owner_id: str) -> set:
    """Return a set of claim_hash values for all existing active claims."""
    claims = await _fetch_all_claims(storage, owner_id)
    return {
        _claim_hash(c.subject, c.predicate, c.object_value)
        for c in claims
    }


# ---------------------------------------------------------------------------
# Export functions
# ---------------------------------------------------------------------------

async def export_json(
    storage: Any,
    owner_id: str,
    output_path: str,
) -> int:
    """Export all claims, entities, and cornerstones to a JSON file.

    Returns the number of claims exported.

    Output schema:
        {
          "version": "1.0",
          "exported_at": "<ISO 8601>",
          "owner_id": "<owner>",
          "claims": [...],
          "entities": [...],
          "cornerstones": [...]
        }
    """
    claims = await _fetch_all_claims(storage, owner_id)
    entities = await _fetch_all_entities(storage, owner_id)
    cornerstones = await storage.get_cornerstones(owner_id=owner_id, status="active")

    payload = {
        "version": _EXPORT_VERSION,
        "exported_at": _now_iso(),
        "owner_id": owner_id,
        "claims": [_claim_to_dict(c) for c in claims],
        "entities": [_entity_to_dict(e) for e in entities],
        "cornerstones": [_cornerstone_to_dict(cs) for cs in cornerstones],
    }

    out = Path(output_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
    return len(claims)


async def export_csv(
    storage: Any,
    owner_id: str,
    output_path: str,
) -> int:
    """Export all claims to a CSV file.

    Columns: id, subject, predicate, object_value, category, status,
             confidence, created_at
    Returns the number of rows written.
    """
    claims = await _fetch_all_claims(storage, owner_id)

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

    fieldnames = [
        "id", "subject", "predicate", "object_value",
        "category", "status", "confidence", "created_at",
    ]

    with out.open("w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=fieldnames)
        writer.writeheader()
        for c in claims:
            d = _claim_to_dict(c)
            writer.writerow({k: d.get(k, "") for k in fieldnames})

    return len(claims)


async def export_markdown(
    storage: Any,
    owner_id: str,
    output_path: str,
) -> int:
    """Export claims as human-readable Markdown, grouped by category.

    Returns the number of claims written.
    """
    claims = await _fetch_all_claims(storage, owner_id)

    # Group by category
    grouped: Dict[str, List[Any]] = {}
    for claim in claims:
        cat = getattr(claim, "category", None) or "misc"
        grouped.setdefault(cat, []).append(claim)

    lines: List[str] = [
        "# Cortex Memory Export",
        "",
        f"**Owner:** `{owner_id}`  ",
        f"**Exported:** {_now_iso()}  ",
        f"**Total claims:** {len(claims)}",
        "",
    ]

    for category in sorted(grouped.keys()):
        items = grouped[category]
        lines.append(f"## {category.title()} ({len(items)})")
        lines.append("")
        for c in items:
            status_badge = f"[{c.status}]" if c.status != "active" else ""
            conf = f"{c.confidence:.0%}"
            lines.append(
                f"- **{c.subject}** {c.predicate} *{c.object_value}* "
                f"— conf {conf} {status_badge}".rstrip()
            )
        lines.append("")

    out = Path(output_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text("\n".join(lines), encoding="utf-8")
    return len(claims)


async def export_sqlite(
    storage: Any,
    output_path: str,
) -> str:
    """Export the entire SQLite database to a backup file.

    Uses the Python sqlite3 backup API for a consistent, safe copy that
    works even while the database is in use (WAL mode compatible).

    Args:
        storage:     Initialized StorageAdapter instance (must be SQLiteAdapter).
        output_path: Destination path for the backup .db file.

    Returns:
        The output path on success.

    Raises:
        RuntimeError: If storage is not a SQLiteAdapter or backup fails.
    """
    db_path = getattr(storage, "_db_path", None)
    if db_path is None or db_path == ":memory:":
        raise RuntimeError("SQLite export requires an on-disk database (not :memory:).")

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

    # Use the sqlite3 backup API for a consistent snapshot
    try:
        source_conn = sqlite3.connect(db_path)
        dest_conn = sqlite3.connect(str(out))
        source_conn.backup(dest_conn)
        dest_conn.close()
        source_conn.close()
        logger.info("SQLite backup written to %s", output_path)
    except Exception as exc:
        raise RuntimeError(f"SQLite backup failed: {exc}") from exc

    return str(out)


# ---------------------------------------------------------------------------
# Import functions
# ---------------------------------------------------------------------------

async def _fetch_existing_entity_keys(storage: Any, owner_id: str) -> set:
    """Return a set of (canonical_name_lower, owner_id) for all existing entities."""
    entities = await _fetch_all_entities(storage, owner_id)
    return {
        (getattr(e, "canonical_name", "").strip().lower(), owner_id)
        for e in entities
    }


async def _fetch_existing_cornerstone_labels(storage: Any, owner_id: str) -> set:
    """Return a set of label strings for all existing cornerstones."""
    cornerstones = await storage.get_cornerstones(owner_id=owner_id, status="active")
    return {getattr(cs, "label", "").strip().lower() for cs in cornerstones}


async def import_json(
    storage: Any,
    input_path: str,
    owner_id: str,
    dry_run: bool = False,
    on_conflict: str = "skip",
) -> ImportResult:
    """Import claims, entities, and cornerstones from an evaOS JSON export file.

    Supports full round-trip: exports produced by ``export_json`` can be
    re-imported to recreate all data.

    Dedup rules:
        - Claims: by subject+predicate+object_value hash.
        - Entities: by canonical_name + owner_id.
        - Cornerstones: by label (case-insensitive).

    Args:
        storage:     Initialized StorageAdapter instance.
        input_path:  Path to the JSON export file.
        owner_id:    Target owner for imported data.
        dry_run:     If True, validate and count without writing.
        on_conflict: Conflict resolution strategy:
                     - "skip" (default): skip duplicates silently.
                     - "overwrite": replace existing data with import data.
                     - "merge": keep existing, fill in missing fields only.

    Returns:
        ImportResult with counts and any error messages.
    """
    if on_conflict not in ("skip", "overwrite", "merge"):
        raise ValueError(f"on_conflict must be 'skip', 'overwrite', or 'merge'; got {on_conflict!r}")

    result = ImportResult(dry_run=dry_run)

    data = json.loads(Path(input_path).read_text(encoding="utf-8"))
    claims_data: List[Dict[str, Any]] = data.get("claims", [])
    entities_data: List[Dict[str, Any]] = data.get("entities", [])
    cornerstones_data: List[Dict[str, Any]] = data.get("cornerstones", [])

    existing_hashes = await _fetch_existing_hashes(storage, owner_id)

    # --- Import claims ---
    for raw in claims_data:
        subject = (raw.get("subject") or "").strip()
        predicate = (raw.get("predicate") or "").strip()
        object_value = (raw.get("object_value") or "").strip()

        if not subject or not predicate or not object_value:
            result.errors.append(f"Skipping claim with empty fields: {raw.get('id')}")
            result.skipped += 1
            continue

        h = _claim_hash(subject, predicate, object_value)
        if h in existing_hashes:
            if on_conflict == "skip" or on_conflict == "merge":
                result.skipped += 1
                continue
            # overwrite: fall through and create (the old one stays but
            # a new version is added — true overwrite would require update_claim
            # which may not exist; this is safe and auditable)

        if not dry_run:
            try:
                await storage.create_claim(
                    subject=subject,
                    predicate=predicate,
                    object_value=object_value,
                    owner_id=owner_id,
                    memory_type=raw.get("memory_type", "long-term"),
                    confidence=float(raw.get("confidence") or 0.5),
                    sensitivity=raw.get("sensitivity", "normal"),
                    scope=raw.get("scope", "user"),
                    status=raw.get("status", "active"),
                    category=raw.get("category", "misc"),
                    datatype=raw.get("datatype", "text"),
                )
                existing_hashes.add(h)
                result.imported += 1
            except Exception as exc:
                result.errors.append(f"Failed to import claim '{subject} {predicate}': {exc}")
                result.skipped += 1
        else:
            existing_hashes.add(h)  # track in dry-run to count unique
            result.imported += 1

    # --- Import entities ---
    existing_entity_keys = await _fetch_existing_entity_keys(storage, owner_id)

    for raw in entities_data:
        canonical_name = (raw.get("canonical_name") or "").strip()
        if not canonical_name:
            result.errors.append(f"Skipping entity with empty canonical_name: {raw.get('id')}")
            result.skipped += 1
            continue

        key = (canonical_name.lower(), owner_id)
        if key in existing_entity_keys:
            if on_conflict == "skip" or on_conflict == "merge":
                result.skipped += 1
                continue
            # overwrite: create a new entity (old remains; true overwrite
            # would need update_entity)

        if not dry_run:
            try:
                await storage.create_entity(
                    canonical_name=canonical_name,
                    entity_type=raw.get("entity_type", "unknown"),
                    owner_id=owner_id,
                    description=raw.get("description"),
                    metadata_json=raw.get("metadata_json"),
                )
                existing_entity_keys.add(key)
                result.imported += 1
            except Exception as exc:
                result.errors.append(f"Failed to import entity '{canonical_name}': {exc}")
                result.skipped += 1
        else:
            existing_entity_keys.add(key)
            result.imported += 1

    # --- Import cornerstones ---
    existing_cs_labels = await _fetch_existing_cornerstone_labels(storage, owner_id)

    for raw in cornerstones_data:
        label = (raw.get("label") or "").strip()
        content = (raw.get("content") or "").strip()
        if not label or not content:
            result.errors.append(f"Skipping cornerstone with empty label/content: {raw.get('id')}")
            result.skipped += 1
            continue

        if label.lower() in existing_cs_labels:
            if on_conflict == "skip" or on_conflict == "merge":
                result.skipped += 1
                continue

        if not dry_run:
            try:
                await storage.seal_cornerstone(
                    label=label,
                    content=content,
                    owner_id=owner_id,
                    sealed_by=raw.get("sealed_by", "import"),
                )
                existing_cs_labels.add(label.lower())
                result.imported += 1
            except Exception as exc:
                result.errors.append(f"Failed to import cornerstone '{label}': {exc}")
                result.skipped += 1
        else:
            existing_cs_labels.add(label.lower())
            result.imported += 1

    return result


def _infer_subject_predicate(memory_text: str) -> tuple[str, str]:
    """Heuristically infer subject and predicate from a Mem0 memory string.

    Examples:
        "Andrew prefers dark mode" → ("Andrew", "prefers")
        "The user likes coffee" → ("user", "likes")
        "Eva is an AI assistant" → ("Eva", "is")
        "Dark mode is preferred" → ("memory", "is")
    """
    if not memory_text:
        return ("user", "remembers")

    # Try to match "Subject verb ..." patterns
    patterns = [
        # "Name verb ..."
        r"^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(is|are|has|have|likes?|loves?|hates?|prefers?|uses?|knows?|wants?|needs?|works?|lives?|built?|created?|runs?|manages?|owns?)\b",
        # "The user/agent verb ..."
        r"^(?:The\s+)?(\w+)\s+(is|are|has|have|likes?|loves?|prefers?|uses?|knows?|wants?|needs?|works?)\b",
    ]

    text = memory_text.strip()
    for pattern in patterns:
        m = re.match(pattern, text, re.IGNORECASE)
        if m:
            return (m.group(1), m.group(2).lower())

    # Fallback: use first word as subject, "remembers" as predicate
    words = text.split()
    if len(words) >= 2:
        return (words[0], "remembers")
    return ("user", "remembers")


async def import_mem0(
    storage: Any,
    input_path: str,
    owner_id: str,
    dry_run: bool = False,
) -> ImportResult:
    """Import claims from a Mem0 JSONL export file.

    Mem0 format: one JSON object per line with fields:
        id, memory, metadata, created_at, updated_at

    Mapping:
        memory       → object_value
        subject      → inferred from memory text
        predicate    → inferred from memory text
        metadata.*   → mapped to category/confidence where available

    Args:
        storage:    Initialized StorageAdapter instance.
        input_path: Path to the JSONL export file.
        owner_id:   Target owner for imported claims.
        dry_run:    If True, validate and count without writing.

    Returns:
        ImportResult with counts and any error messages.
    """
    result = ImportResult(dry_run=dry_run)

    lines = Path(input_path).read_text(encoding="utf-8").strip().splitlines()
    if not dry_run:
        existing_hashes = await _fetch_existing_hashes(storage, owner_id)
    else:
        existing_hashes = await _fetch_existing_hashes(storage, owner_id)

    for line_no, line in enumerate(lines, 1):
        line = line.strip()
        if not line:
            continue

        try:
            raw = json.loads(line)
        except json.JSONDecodeError as exc:
            result.errors.append(f"Line {line_no}: JSON parse error: {exc}")
            result.skipped += 1
            continue

        memory_text: str = (raw.get("memory") or "").strip()
        if not memory_text:
            result.errors.append(f"Line {line_no}: empty memory field")
            result.skipped += 1
            continue

        object_value = memory_text
        subject, predicate = _infer_subject_predicate(memory_text)

        # Extract optional metadata fields
        metadata = raw.get("metadata") or {}
        category = metadata.get("category", "imported")
        confidence_raw = metadata.get("confidence") or metadata.get("score")
        try:
            confidence = float(confidence_raw) if confidence_raw is not None else 0.7
        except (ValueError, TypeError):
            confidence = 0.7
        # Clamp confidence to [0, 1]
        confidence = max(0.0, min(1.0, confidence))

        h = _claim_hash(subject, predicate, object_value)
        if h in existing_hashes:
            result.skipped += 1
            continue

        if not dry_run:
            try:
                await storage.create_claim(
                    subject=subject,
                    predicate=predicate,
                    object_value=object_value,
                    owner_id=owner_id,
                    memory_type="long-term",
                    confidence=confidence,
                    sensitivity="normal",
                    scope="user",
                    status="active",
                    category=category,
                    datatype="text",
                )
                existing_hashes.add(h)
                result.imported += 1
            except Exception as exc:
                result.errors.append(f"Line {line_no}: import failed: {exc}")
                result.skipped += 1
        else:
            existing_hashes.add(h)
            result.imported += 1

    return result
