#!/usr/bin/env node
/**
 * OpenClaw Memory — Context Graph + Decision Log
 *
 * Entity-Relation-Observation graph model.
 * Migrated from ECC `ecc2/src/session/mod.rs` (ContextGraphEntity/Observation/Relation)
 *          + DecisionLogEntry
 *
 * Integrates with OpenClaw's existing three-tier memory system:
 *   MEMORY.md (stable core) ← pinned Entity
 *   memory/vault.md (dynamic) ← ContextGraph + Observations
 *   memory/daily/ (logs) ← DecisionLog + Activity
 *
 * Usage:
 *   const { ContextGraph, DecisionLog } = require('./context-graph');
 */

const fs = require('fs');
const path = require('path');

// ─── Types ───────────────────────────────────────────────────────

/**
 * Priority levels for observations.
 */
const ObservationPriority = Object.freeze({
  LOW:      'low',
  NORMAL:   'normal',
  HIGH:     'high',
  CRITICAL: 'critical',
});

const PRIORITY_WEIGHT = {
  [ObservationPriority.LOW]:      1,
  [ObservationPriority.NORMAL]:   2,
  [ObservationPriority.HIGH]:     3,
  [ObservationPriority.CRITICAL]: 5,
};

// ─── ContextGraph Class ──────────────────────────────────────────

/**
 * Entity-Relation-Observation graph.
 *
 * ECC: ContextGraphEntity, ContextGraphObservation, ContextGraphRelation
 * (ecc2/src/session/mod.rs:440-500)
 */
class ContextGraph {
  constructor(options = {}) {
    this.storePath = options.storePath || path.join(
      process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, '.openclaw'),
      'context-graph.json'
    );
    this.maxObservationsPerEntity = options.maxObservationsPerEntity || 12;
    this.entities = new Map();     // id → Entity
    this.relations = [];           // Relation[]
    this._load();
  }

  /**
   * Create or get an entity.
   */
  entity(id, info = {}) {
    if (this.entities.has(id)) {
      const existing = this.entities.get(id);
      // Merge updates
      if (info.name) existing.name = info.name;
      if (info.type) existing.type = info.type;
      if (info.path) existing.path = info.path;
      if (info.summary) existing.summary = info.summary;
      Object.assign(existing.metadata, info.metadata || {});
      return existing;
    }

    const entity = {
      id,
      type: info.type || 'unknown',       // file, function, decision, project, tool, concept
      name: info.name || id,
      path: info.path || null,
      sessionId: info.sessionId || null,
      summary: info.summary || '',
      metadata: info.metadata || {},
      observations: [],
      pinned: info.pinned || false,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };

    this.entities.set(id, entity);
    return entity;
  }

  /**
   * Add an observation to an entity.
   * ECC: context_graph_add_observation
   */
  observe(entityId, observation) {
    const entity = this.entity(entityId);

    const obs = {
      id: `obs_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,
      entityId,
      type: observation.type || 'note',    // completion_summary, incident_note, decision, update
      priority: observation.priority || ObservationPriority.NORMAL,
      pinned: observation.pinned || false,
      summary: observation.summary || '',
      details: observation.details || {},
      timestamp: observation.timestamp || new Date().toISOString(),
    };

    entity.observations.push(obs);
    entity.updatedAt = obs.timestamp;

    // Compact if over limit
    this._compact(entity);

    return obs;
  }

  /**
   * Add a relation between two entities.
   * ECC: ContextGraphRelation
   */
  relate(fromEntityId, toEntityId, relationType, metadata = {}) {
    // Upsert: remove existing relation of same type
    this.relations = this.relations.filter(r =>
      !(r.from === fromEntityId && r.to === toEntityId && r.type === relationType)
    );

    this.relations.push({
      from: fromEntityId,
      to: toEntityId,
      type: relationType,               // references, defines, depends_on, contains, calls, relates_to
      metadata,
      createdAt: new Date().toISOString(),
    });
  }

  /**
   * Recall entities matching a query.
   * ECC: context_graph_recall (ecc2/src/session/store.rs)
   *
   * Simple token-match scoring (FTS5-like without SQLite).
   */
  recall(query, options = {}) {
    const { limit = 10, minScore = 0, entityType } = options;

    // Tokenize query
    const queryTokens = query.toLowerCase().split(/[\s\W]+/).filter(t => t.length > 1);

    const results = [];

    for (const [id, entity] of this.entities) {
      if (entityType && entity.type !== entityType) continue;

      // Search in name, path, summary, observation summaries
      const searchText = [
        entity.name,
        entity.path || '',
        entity.summary,
        ...entity.observations.map(o => o.summary),
      ].join(' ').toLowerCase();

      let score = 0;
      const matched = [];

      for (const token of queryTokens) {
        if (searchText.includes(token)) {
          score += 1;
          matched.push(token);
        }
      }

      // Bonus for pinned entities
      if (entity.pinned) score *= 1.5;

      // Bonus for entity type match
      if (entityType && entity.type === entityType) score *= 2;

      if (score >= minScore) {
        const maxObsPriority = entity.observations.length > 0
          ? Math.max(...entity.observations.map(o => PRIORITY_WEIGHT[o.priority] || 1))
          : 0;

        results.push({
          entity: {
            id: entity.id,
            type: entity.type,
            name: entity.name,
            path: entity.path,
            summary: entity.summary,
            pinned: entity.pinned,
          },
          score,
          matchedTerms: matched,
          observationCount: entity.observations.length,
          maxObservationPriority: maxObsPriority,
          hasPinnedObservation: entity.observations.some(o => o.pinned),
        });
      }
    }

    // Sort by score DESC
    results.sort((a, b) => b.score - a.score);

    return results.slice(0, limit);
  }

  /**
   * Get neighbors of an entity (graph traversal).
   */
  neighbors(entityId, depth = 1) {
    const visited = new Set([entityId]);
    let frontier = [entityId];
    const resultNodes = [];
    const resultEdges = [];

    for (let d = 0; d < depth; d++) {
      const nextFrontier = [];

      for (const fid of frontier) {
        const entity = this.entities.get(fid);
        if (entity && !resultNodes.find(n => n.id === fid)) {
          resultNodes.push({
            id: entity.id,
            type: entity.type,
            name: entity.name,
            summary: entity.summary,
          });
        }

        // Find all relations involving this entity
        for (const rel of this.relations) {
          if (rel.from === fid && !visited.has(rel.to)) {
            visited.add(rel.to);
            nextFrontier.push(rel.to);
            resultEdges.push(rel);
          }
          if (rel.to === fid && !visited.has(rel.from)) {
            visited.add(rel.from);
            nextFrontier.push(rel.from);
            resultEdges.push(rel);
          }
        }
      }

      frontier = nextFrontier;
    }

    return { nodes: resultNodes, edges: resultEdges };
  }

  /**
   * Compact observations: keep only N most recent, preserving pinned.
   * ECC: compact_observations (ecc2/src/session/store.rs)
   */
  _compact(entity) {
    const limit = this.maxObservationsPerEntity;
    if (entity.observations.length <= limit) return;

    // Separate pinned from unpinned
    const pinned = entity.observations.filter(o => o.pinned);
    const unpinned = entity.observations
      .filter(o => !o.pinned)
      .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));

    // Keep N most recent unpinned + all pinned
    entity.observations = [...pinned, ...unpinned.slice(0, limit - pinned.length)];

    // Sort by timestamp
    entity.observations.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
  }

  /**
   * Persist to disk.
   */
  save() {
    const dir = path.dirname(this.storePath);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

    const data = {
      entities: Object.fromEntries(this.entities),
      relations: this.relations,
      savedAt: new Date().toISOString(),
    };

    fs.writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf8');
  }

  _load() {
    try {
      if (fs.existsSync(this.storePath)) {
        const data = JSON.parse(fs.readFileSync(this.storePath, 'utf8'));
        if (data.entities) {
          this.entities = new Map(Object.entries(data.entities));
        }
        if (data.relations) {
          this.relations = data.relations;
        }
      }
    } catch {
      // Start fresh on corruption
    }
  }

  /**
   * Get stats.
   */
  stats() {
    return {
      entityCount: this.entities.size,
      relationCount: this.relations.length,
      observationCount: [...this.entities.values()]
        .reduce((sum, e) => sum + e.observations.length, 0),
      byType: [...this.entities.values()].reduce((acc, e) => {
        acc[e.type] = (acc[e.type] || 0) + 1;
        return acc;
      }, {}),
    };
  }
}

// ─── Decision Log ────────────────────────────────────────────────

/**
 * Structured decision recording.
 * ECC: DecisionLogEntry (ecc2/src/session/mod.rs:392-400)
 */
class DecisionLog {
  constructor(options = {}) {
    this.storePath = options.storePath || path.join(
      process.env.OPENCLAW_WORKSPACE || path.join(process.env.HOME, '.openclaw', 'workspace'),
      'memory', 'registers', 'decisions.json'
    );
    this.decisions = [];
    this._load();
  }

  /**
   * Record a decision.
   *
   * @param {object} decision
   * @param {string} decision.decision — What was decided
   * @param {string[]} decision.alternatives — What was considered
   * @param {string} decision.reasoning — Why this choice
   * @param {string} decision.context — Session/task context
   * @param {string[]} decision.tags — Categorization tags
   */
  record({ decision, alternatives = [], reasoning = '', context = '', tags = [] }) {
    const entry = {
      id: `d_${Date.now()}`,
      decision,
      alternatives,
      reasoning,
      context,
      tags,
      timestamp: new Date().toISOString(),
      sessionId: context || 'unknown',
    };

    this.decisions.push(entry);
    this._save();

    return entry;
  }

  /**
   * Query decisions by tags, keywords, or recency.
   */
  query({ tags, keyword, limit = 20, since } = {}) {
    let results = [...this.decisions];

    if (tags && tags.length > 0) {
      results = results.filter(d => d.tags.some(t => tags.includes(t)));
    }

    if (keyword) {
      const kw = keyword.toLowerCase();
      results = results.filter(d =>
        d.decision.toLowerCase().includes(kw) ||
        d.reasoning.toLowerCase().includes(kw) ||
        d.tags.some(t => t.toLowerCase().includes(kw))
      );
    }

    if (since) {
      const sinceDate = new Date(since);
      results = results.filter(d => new Date(d.timestamp) >= sinceDate);
    }

    // Newest first
    results.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));

    return results.slice(0, limit);
  }

  /**
   * Get recent decisions.
   */
  recent(limit = 10) {
    return this.query({ limit });
  }

  /**
   * Search for similar past decisions (Jaccard).
   */
  findSimilar(text, limit = 5) {
    const words = new Set(text.toLowerCase().split(/\W+/).filter(Boolean));

    const scored = this.decisions.map(d => {
      const dWords = new Set(d.decision.toLowerCase().split(/\W+/).filter(Boolean));
      const intersection = [...words].filter(w => dWords.has(w)).length;
      const union = new Set([...words, ...dWords]).size;
      return { entry: d, score: union === 0 ? 0 : intersection / union };
    });

    scored.sort((a, b) => b.score - a.score);
    return scored.filter(s => s.score > 0).slice(0, limit);
  }

  _save() {
    const dir = path.dirname(this.storePath);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(this.storePath, JSON.stringify(this.decisions, null, 2), 'utf8');
  }

  _load() {
    try {
      if (fs.existsSync(this.storePath)) {
        this.decisions = JSON.parse(fs.readFileSync(this.storePath, 'utf8'));
      }
    } catch {
      this.decisions = [];
    }
  }

  stats() {
    return {
      total: this.decisions.length,
      byTag: this.decisions.reduce((acc, d) => {
        for (const tag of d.tags) acc[tag] = (acc[tag] || 0) + 1;
        return acc;
      }, {}),
      recentDays: this.decisions.filter(
        d => Date.now() - new Date(d.timestamp).getTime() < 7 * 86400000
      ).length,
    };
  }
}

// ─── Module exports ──────────────────────────────────────────────

module.exports = {
  ContextGraph,
  DecisionLog,
  ObservationPriority,
};
