import { reduceConflictingFacts } from './partial-graph/reducers.js';
import { validateReducerConfig, getAllowedRelationsForLayer } from './partial-graph/layer-registry.js';

export class PartialGraphContext {
  constructor(arbiter, partialGraph) {
    this.arbiter = arbiter;
    // The caller's view of the world at a moment in time: the partial graph
    // may carry that time. When present it becomes the evaluation clock for
    // this check, so a rerun that replays the same partial graph reproduces
    // the same decision (TTL gates, proof expiry, decay all honor it).
    this.now = (partialGraph && partialGraph.now !== undefined && partialGraph.now !== null)
      ? partialGraph.now
      : null;
    const policy = arbiter?.partialGraphPolicy || null;
    this.nodeIdByKey = new Map();
    this.keyByNodeId = new Map();
    this._nextTempId = -1;

    this._relationsFromSrc = new Map();
    this._relationsToDst = new Map();
    this._relationsByName = new Map();
    this._directRelations = new Map();
    this._challengeProofs = new Map();
    this._reducerAudit = [];
    const requestConflictMode = partialGraph?.options?.conflict_mode || partialGraph?.options?.conflictMode || null;
    const policyConflictMode = policy?.conflict_mode || 'deterministic';
    const effectiveConflictMode = requestConflictMode || policyConflictMode;
    this._strictConflicts = effectiveConflictMode === 'strict' || !!(partialGraph?.options?.strictConflicts);
    this._relationReducers = this._resolveReducerConfig({
      ...(policy?.reducers || {}),
      ...(partialGraph?.options?.reducers || {})
    });

    if (!partialGraph) return;
    this._ingest(partialGraph);
  }

  _ingest(partialGraph) {
    const nodes = Array.isArray(partialGraph.nodes) ? partialGraph.nodes : [];
    const relations = Array.isArray(partialGraph.relations) ? partialGraph.relations : [];
    const challenges = Array.isArray(partialGraph.challenges)
      ? partialGraph.challenges
      : (Array.isArray(partialGraph.challengeProofs) ? partialGraph.challengeProofs : []);

    const maxNodes = this.arbiter?.partialGraphPolicy?.maxNodes || 1000;
    const maxRelations = this.arbiter?.partialGraphPolicy?.maxRelations || 2000;
    if (nodes.length > maxNodes) {
      throw new Error(`Partial graph exceeds max nodes: ${nodes.length} > ${maxNodes}`);
    }
    if (relations.length > maxRelations) {
      throw new Error(`Partial graph exceeds max relations: ${relations.length} > ${maxRelations}`);
    }

    const reserved = this.arbiter?.partialGraphPolicy?.reservedRelations || [
      'sessionIsValid', 'hasType', 'token_type'
    ];
    const reservedSet = new Set(reserved);

    for (const node of nodes) {
      if (!node || !node.key) continue;
      this._resolveNodeId(node.key);
    }

    for (const rel of relations) {
      if (!rel || !rel.src || !rel.dst || !rel.relation) continue;
      if (reservedSet.has(rel.relation)) continue;
      const layerName = rel.layer_name || rel.layer || null;
      if (layerName) {
        const allowlist = getAllowedRelationsForLayer(layerName);
        if (allowlist && !allowlist.includes(rel.relation)) {
          if (this._strictConflicts) {
            throw new Error(`partial_graph_allowlist_violation:${rel.relation}@${layerName}`);
          }
          this._reducerAudit.push({
            type: 'allowlist_reject',
            relation: rel.relation,
            layer: layerName,
            reason: 'relation_not_in_layer_allowlist'
          });
          // Lenient (default) mode: accept the fact anyway so downstream
          // reducers and authorization checks still observe it — the audit
          // entry records the conformance note. Strict mode throws above.
        }
      }
      this._addRelation(rel);
    }

    for (const proof of challenges) {
      if (!proof || !proof.name || !proof.subject) continue;
      this._addChallengeProof(proof);
    }
  }

  _resolveReducerConfig(reducerConfig) {
    const validation = validateReducerConfig(reducerConfig || null);
    if (validation.invalid.length > 0) {
      this._reducerAudit.push({
        type: 'invalid_reducer_config',
        invalid: validation.invalid
      });
    }
    return validation.valid;
  }

  _resolveNodeId(key) {
    if (this.nodeIdByKey.has(key)) return this.nodeIdByKey.get(key);
    const existing = this.arbiter.nodeIdByKey.get(key);
    if (existing !== undefined) {
      this.nodeIdByKey.set(key, existing);
      this.keyByNodeId.set(existing, key);
      return existing;
    }
    const tempId = this._nextTempId--;
    this.nodeIdByKey.set(key, tempId);
    this.keyByNodeId.set(tempId, key);
    return tempId;
  }

  _relationKey(srcId, relation, dstId) {
    return `${srcId}|${relation}|${dstId}`;
  }

  _challengeKey(name, subjectId) {
    return `${name}|${subjectId}`;
  }

  _addChallengeProof(proof) {
    const subjectId = this._resolveNodeId(proof.subject);
    // The partial graph carries the caller's clock; fall back to the wall
    // clock only when the caller provided no time at all.
    const now = this.now !== null && this.now !== undefined ? this.now : Date.now();
    // 0 is a valid timestamp (epoch-issued / already-expired); `||` would
    // replace it with the wall clock or null, making an epoch-issued proof
    // the most recent one and an epoch-expired proof never expire.
    const issuedAt = proof.issuedAt ?? proof.issued_at ?? proof.timestamp ?? now;
    const expiresAt = proof.expiresAt ?? proof.expires_at ?? null;
    const record = {
      name: proof.name,
      subject: subjectId,
      issuedAt,
      expiresAt,
      source: proof.source || 'partial',
      proofId: proof.proofId ?? proof.proof_id ?? null,
      metadata: proof.metadata || null
    };
    const key = this._challengeKey(record.name, subjectId);
    if (!this._challengeProofs.has(key)) this._challengeProofs.set(key, []);
    this._challengeProofs.get(key).push(record);
  }

  _addRelation(rel) {
    const srcId = this._resolveNodeId(rel.src);
    const dstId = this._resolveNodeId(rel.dst);
    const relation = rel.relation;
    const now = this.now !== null && this.now !== undefined ? this.now : Date.now();
    const relationObj = {
      src: srcId,
      rel: relation,
      dst: dstId,
      possibility: rel.possibility !== undefined ? rel.possibility : 1.0,
      reliability: rel.reliability !== undefined ? rel.reliability : 1.0,
      value: rel.value,
      attributes: rel.attributes || null,
      layer_name: rel.layer_name || rel.layer || 'partial',
      source_class: rel.source_class || rel.layer_name || rel.layer || 'partial',
      reducer_applied: null,
      updated_last_at: rel.updated_last_at !== undefined ? rel.updated_last_at : now,
      changed_last_at: rel.changed_last_at !== undefined ? rel.changed_last_at : now,
      source: 'partial'
    };

    const directKey = this._relationKey(srcId, relation, dstId);
    const existing = this._directRelations.get(directKey) || null;
    const reducer = this._relationReducers[relation] || null;
    if (existing) {
      const reduced = reduceConflictingFacts([existing, relationObj], reducer);
      if (!reduced.fact) {
        this._directRelations.delete(directKey);
        this._removeIndexedRelation(existing);
        this._reducerAudit.push({
          type: 'relation_conflict_absent',
          relation,
          src: rel.src,
          dst: rel.dst,
          reducer,
          audit: reduced.audit
        });
        return;
      }

      const changed =
        reduced.fact.possibility !== existing.possibility ||
        reduced.fact.reliability !== existing.reliability ||
        reduced.fact.value !== existing.value ||
        reduced.fact.updated_last_at !== existing.updated_last_at ||
        reduced.fact.changed_last_at !== existing.changed_last_at ||
        reduced.fact.layer_name !== existing.layer_name ||
        reduced.fact.source_class !== existing.source_class ||
        JSON.stringify(reduced.fact.attributes || null) !== JSON.stringify(existing.attributes || null);

      if (changed) {
        reduced.fact.reducer_applied = reducer || null;
        this._removeIndexedRelation(existing);
        this._indexRelation(reduced.fact);
        this._directRelations.set(directKey, reduced.fact);
      } else if (reducer) {
        existing.reducer_applied = reducer;
      }

      this._reducerAudit.push({
        type: 'relation_conflict_resolved',
        relation,
        src: rel.src,
        dst: rel.dst,
        reducer,
        audit: reduced.audit
      });

      if (this._strictConflicts && reduced.audit?.conflict && !reducer) {
        throw new Error(`partial_graph_conflict_without_reducer:${relation}`);
      }
      return;
    }

    this._directRelations.set(directKey, relationObj);
    this._indexRelation(relationObj);
  }

  _indexRelation(relationObj) {
    const fromKey = `${relationObj.src}|${relationObj.rel}`;
    const toKey = `${relationObj.dst}|${relationObj.rel}`;
    const byNameKey = relationObj.rel;

    if (!this._relationsFromSrc.has(fromKey)) this._relationsFromSrc.set(fromKey, []);
    if (!this._relationsToDst.has(toKey)) this._relationsToDst.set(toKey, []);
    if (!this._relationsByName.has(byNameKey)) this._relationsByName.set(byNameKey, []);

    this._relationsFromSrc.get(fromKey).push(relationObj);
    this._relationsToDst.get(toKey).push(relationObj);
    this._relationsByName.get(byNameKey).push(relationObj);
  }

  _removeIndexedRelation(relationObj) {
    const fromKey = `${relationObj.src}|${relationObj.rel}`;
    const toKey = `${relationObj.dst}|${relationObj.rel}`;
    const byNameKey = relationObj.rel;

    const removeOne = (map, key, item) => {
      const list = map.get(key);
      if (!list || list.length === 0) return;
      for (let i = 0; i < list.length; i++) {
        if (list[i] === item) {
          list.splice(i, 1);
          break;
        }
      }
      if (list.length === 0) map.delete(key);
    };

    removeOne(this._relationsFromSrc, fromKey, relationObj);
    removeOne(this._relationsToDst, toKey, relationObj);
    removeOne(this._relationsByName, byNameKey, relationObj);
  }

  getRelationsFromSrc(srcId, relation) {
    return this._relationsFromSrc.get(`${srcId}|${relation}`) || [];
  }

  getRelationsToDst(dstId, relation) {
    return this._relationsToDst.get(`${dstId}|${relation}`) || [];
  }

  getRelationsByName(relation) {
    return this._relationsByName.get(relation) || [];
  }

  getDirectRelation(srcId, relation, dstId) {
    return this._directRelations.get(this._relationKey(srcId, relation, dstId)) || null;
  }

  getChallengeProof(name, subjectId, withinMs = null, now = Date.now()) {
    const key = this._challengeKey(name, subjectId);
    const proofs = this._challengeProofs.get(key) || [];
    if (!proofs.length) return null;
    let best = null;
    for (const proof of proofs) {
      // expiresAt=0 is a valid timestamp (already expired) — a truthy check
      // would treat it as "no expiry" and return stale proofs.
      if (proof.expiresAt != null && proof.expiresAt !== undefined && proof.expiresAt <= now) continue;
      if (withinMs !== null && withinMs !== undefined) {
        if (now - proof.issuedAt > withinMs) continue;
      }
      if (!best || proof.issuedAt > best.issuedAt) {
        best = proof;
      }
    }
    return best;
  }

  getReducerAudit() {
    return this._reducerAudit;
  }
}
