// packages/db/src/neo4j-sync.ts
// Projection sync: push Prisma records into Neo4j after writes.
// These are fire-and-forget projections — failures are logged but never
// allowed to bubble up to the caller. Postgres is always the authority.

import { runCypher } from "./neo4j";
import { NETWORK_RELATIONSHIP_TYPES } from "./neo4j-schema";

/** Upsert a DigitalProduct node and wire its Portfolio + TaxonomyNode edges. */
export async function syncDigitalProduct(dp: {
  productId: string;
  name: string;
  lifecycleStage: string;
  lifecycleStatus: string;
  portfolioSlug?: string | null;
  taxonomyNodeId?: string | null;
}): Promise<void> {
  // Upsert the node
  await runCypher(
    `MERGE (dp:DigitalProduct {productId: $productId})
     SET dp.name           = $name,
         dp.lifecycleStage = $lifecycleStage,
         dp.lifecycleStatus= $lifecycleStatus,
         dp.syncedAt       = datetime()`,
    dp,
  );

  // BELONGS_TO Portfolio
  if (dp.portfolioSlug) {
    await runCypher(
      `MATCH (dp:DigitalProduct {productId: $productId})
       MERGE (p:Portfolio {slug: $slug})
       MERGE (dp)-[:BELONGS_TO]->(p)`,
      { productId: dp.productId, slug: dp.portfolioSlug },
    );
  }

  // CATEGORIZED_AS TaxonomyNode (nodeId stored on the Prisma TaxonomyNode)
  if (dp.taxonomyNodeId) {
    await runCypher(
      `MATCH (dp:DigitalProduct {productId: $productId})
       MERGE (tn:TaxonomyNode {pgId: $taxonomyNodeId})
       MERGE (dp)-[:CATEGORIZED_AS]->(tn)`,
      { productId: dp.productId, taxonomyNodeId: dp.taxonomyNodeId },
    );
  }
}

/**
 * Apply IT4IT value stream labels to a DigitalProduct node.
 * Labels: :S2P (Strategy to Portfolio), :R2D (Requirement to Deploy),
 *         :R2F (Request to Fulfill), :D2C (Detect to Correct)
 * See neo4j-schema.ts for label definitions.
 */
export async function syncIT4ITLabels(
  productId: string,
  labels: Array<"S2P" | "R2D" | "R2F" | "D2C">,
): Promise<void> {
  if (labels.length === 0) return;
  for (const label of labels) {
    await runCypher(
      `MATCH (dp:DigitalProduct {productId: $productId})
       CALL apoc.create.addLabels(dp, [$label]) YIELD node
       RETURN node`,
      { productId, label },
    ).catch((err: unknown) => {
      // APOC not installed — label skipped
      console.warn(`[neo4j-sync] IT4IT label ${label} failed for ${productId}:`, err);
    });
  }
}

/** Upsert a TaxonomyNode node and its CHILD_OF parent edge. */
export async function syncTaxonomyNode(tn: {
  nodeId: string;
  name: string;
  pgId: string;
  parentNodeId?: string | null;
}): Promise<void> {
  await runCypher(
    `MERGE (n:TaxonomyNode {nodeId: $nodeId})
     SET n.name    = $name,
         n.pgId    = $pgId,
         n.syncedAt= datetime()`,
    tn,
  );

  if (tn.parentNodeId) {
    await runCypher(
      `MATCH (child:TaxonomyNode  {nodeId: $nodeId})
       MERGE (parent:TaxonomyNode {nodeId: $parentNodeId})
       MERGE (child)-[:CHILD_OF]->(parent)`,
      { nodeId: tn.nodeId, parentNodeId: tn.parentNodeId },
    );
  }
}

/** Upsert a Portfolio node. */
export async function syncPortfolio(p: {
  slug: string;
  name: string;
}): Promise<void> {
  await runCypher(
    `MERGE (p:Portfolio {slug: $slug})
     SET p.name     = $name,
         p.syncedAt = datetime()`,
    p,
  );
}

export async function syncDocumentNode(doc: {
  documentId: string;
  title: string;
  currentState?: string | null;
  documentKind?: string | null;
}): Promise<void> {
  await runCypher(
    `MERGE (doc:Document {documentId: $documentId})
     SET doc.title = $title,
         doc.currentState = $currentState,
         doc.documentKind = $documentKind,
         doc.syncedAt = datetime()`,
    {
      documentId: doc.documentId,
      title: doc.title,
      currentState: doc.currentState ?? null,
      documentKind: doc.documentKind ?? null,
    },
  );
}

export async function syncDocumentReference(ref: {
  sourceDocumentId: string;
  sourceTitle: string;
  sourceState?: string | null;
  sourceKind?: string | null;
  sourceVersionId?: string | null;
  targetDocumentId: string;
  targetTitle: string;
  targetState?: string | null;
  targetKind?: string | null;
  targetVersionId?: string | null;
  refType: string;
  anchor?: string | null;
}): Promise<void> {
  await runCypher(
    `MERGE (source:Document {documentId: $sourceDocumentId})
     SET source.title = $sourceTitle,
         source.currentState = $sourceState,
         source.documentKind = $sourceKind,
         source.syncedAt = datetime()
     MERGE (target:Document {documentId: $targetDocumentId})
     SET target.title = $targetTitle,
         target.currentState = $targetState,
         target.documentKind = $targetKind,
         target.syncedAt = datetime()
     MERGE (source)-[edge:DOC_REFERENCES {
       refType: $refType,
       sourceVersionId: $sourceVersionId,
       targetVersionId: $targetVersionId
     }]->(target)
     SET edge.anchor = $anchor,
         edge.syncedAt = datetime()`,
    {
      sourceDocumentId: ref.sourceDocumentId,
      sourceTitle: ref.sourceTitle,
      sourceState: ref.sourceState ?? null,
      sourceKind: ref.sourceKind ?? null,
      sourceVersionId: ref.sourceVersionId ?? null,
      targetDocumentId: ref.targetDocumentId,
      targetTitle: ref.targetTitle,
      targetState: ref.targetState ?? null,
      targetKind: ref.targetKind ?? null,
      targetVersionId: ref.targetVersionId ?? null,
      refType: ref.refType,
      anchor: ref.anchor ?? null,
    },
  );
}

export interface InfraCIExtendedProps {
  baseUrl?: string;
  gpu?: string;
  vramGb?: number | null;
  modelCount?: number;
  // OSI layer context (Phase 1 — multi-layer topology graph)
  osiLayer?: number;
  osiLayerName?: string;
  networkAddress?: string;
  networkMask?: string;
  protocolFamily?: string;
  parentCiId?: string;
  // Customer-estate scope (Phase: customer topology isolation).
  // scopeKey defaults to "organization:internal" via the InventoryEntity column
  // default; customerAccountId/customerSiteId are null for MSP-internal nodes.
  scopeKey?: string;
  customerAccountId?: string | null;
  customerSiteId?: string | null;
}

/** Upsert an InfraCI node. */
export async function syncInfraCI(
  ci: {
    ciId: string;
    name: string;
    ciType: string;   // server | container | database | service | network
    status: string;   // operational | degraded | offline
    portfolioSlug?: string | null;
  },
  extendedProps?: InfraCIExtendedProps,
): Promise<void> {
  const setClauses = [
    "ci.name = $name",
    "ci.ciType = $ciType",
    "ci.status = $status",
    "ci.syncedAt = datetime()",
  ];
  const params: Record<string, unknown> = {
    ciId: ci.ciId,
    name: ci.name,
    ciType: ci.ciType,
    status: ci.status,
  };

  if (extendedProps) {
    if (extendedProps.baseUrl !== undefined) {
      setClauses.push("ci.baseUrl = $baseUrl");
      params.baseUrl = extendedProps.baseUrl;
    }
    if (extendedProps.gpu !== undefined) {
      setClauses.push("ci.gpu = $gpu");
      params.gpu = extendedProps.gpu;
    }
    if (extendedProps.vramGb !== undefined) {
      setClauses.push("ci.vramGb = $vramGb");
      params.vramGb = extendedProps.vramGb;
    }
    if (extendedProps.modelCount !== undefined) {
      setClauses.push("ci.modelCount = $modelCount");
      params.modelCount = extendedProps.modelCount;
    }
    if (extendedProps.osiLayer !== undefined) {
      setClauses.push("ci.osiLayer = $osiLayer");
      params.osiLayer = extendedProps.osiLayer;
    }
    if (extendedProps.osiLayerName !== undefined) {
      setClauses.push("ci.osiLayerName = $osiLayerName");
      params.osiLayerName = extendedProps.osiLayerName;
    }
    if (extendedProps.networkAddress !== undefined) {
      setClauses.push("ci.networkAddress = $networkAddress");
      params.networkAddress = extendedProps.networkAddress;
    }
    if (extendedProps.networkMask !== undefined) {
      setClauses.push("ci.networkMask = $networkMask");
      params.networkMask = extendedProps.networkMask;
    }
    if (extendedProps.protocolFamily !== undefined) {
      setClauses.push("ci.protocolFamily = $protocolFamily");
      params.protocolFamily = extendedProps.protocolFamily;
    }
    if (extendedProps.parentCiId !== undefined) {
      setClauses.push("ci.parentCiId = $parentCiId");
      params.parentCiId = extendedProps.parentCiId;
    }
    if (extendedProps.scopeKey !== undefined) {
      setClauses.push("ci.scopeKey = $scopeKey");
      params.scopeKey = extendedProps.scopeKey;
    }
    if (extendedProps.customerAccountId !== undefined) {
      setClauses.push("ci.customerAccountId = $customerAccountId");
      params.customerAccountId = extendedProps.customerAccountId;
    }
    if (extendedProps.customerSiteId !== undefined) {
      setClauses.push("ci.customerSiteId = $customerSiteId");
      params.customerSiteId = extendedProps.customerSiteId;
    }
  }

  await runCypher(
    `MERGE (ci:InfraCI {ciId: $ciId})
     SET ${setClauses.join(", ")}`,
    params,
  );

  if (ci.portfolioSlug) {
    await runCypher(
      `MATCH (ci:InfraCI {ciId: $ciId})
       MERGE (p:Portfolio {slug: $portfolioSlug})
       MERGE (ci)-[:BELONGS_TO]->(p)`,
      { ciId: ci.ciId, portfolioSlug: ci.portfolioSlug },
    );
  }
}

/** Create or replace a DEPENDS_ON relationship between two nodes.
 *  fromLabel / toLabel: "DigitalProduct" | "InfraCI"
 *  fromId / toId: the unique key value (productId or ciId respectively)
 */
export async function syncDependsOn(dep: {
  fromLabel: "DigitalProduct" | "InfraCI";
  fromId: string;
  toLabel: "InfraCI";
  toId: string;
  role?: string;   // e.g. "database" | "runtime" | "network"
  since?: string;  // ISO date string
}): Promise<void> {
  const fromKey = dep.fromLabel === "DigitalProduct" ? "productId" : "ciId";
  await runCypher(
    `MATCH (from:${dep.fromLabel} {${fromKey}: $fromId})
     MATCH (to:InfraCI           {ciId: $toId})
     MERGE (from)-[r:DEPENDS_ON]->(to)
     SET r.role     = $role,
         r.since    = $since,
         r.syncedAt = datetime()`,
    { fromId: dep.fromId, toId: dep.toId, role: dep.role ?? null, since: dep.since ?? null },
  );
}

// ─── EA Modeling sync ─────────────────────────────────────────────────────────

/** Upsert an EaElement node with dual labels (:EaElement:NeoLabel).
 *  Also creates EA_REPRESENTS edges to any bridge entities that are set.
 *  Note: infraCiKey is stored as a scalar property on the node — there is
 *  no EA_REPRESENTS bridge edge to InfraCI (the key is denormalised for
 *  direct lookup in queries without requiring an extra hop). */
/** Default OSI layer for known entity types. */
const DEFAULT_OSI_LAYER: Record<string, number> = {
  host: 3,
  container: 7,
  runtime: 7,
  database: 7,
  service: 7,
  network: 3,
  "ai-inference": 7,
  network_interface: 3,
  subnet: 3,
  gateway: 3,
  docker_host: 3,
  router: 3,
  network_client: 3,
  network_device: 3,
  switch: 2,
  access_point: 2,
  vlan: 2,
};

const OSI_LAYER_NAMES: Record<number, string> = {
  1: "physical",
  2: "data_link",
  3: "network",
  4: "transport",
  5: "session",
  6: "presentation",
  7: "application",
};

export async function syncInventoryEntityAsInfraCI(entity: {
  entityKey: string;
  name: string;
  entityType: string;
  status: string;
  portfolioSlug?: string | null;
  properties?: Record<string, unknown> | null;
}): Promise<void> {
  const ciType =
    entity.entityType === "host" ? "server"
    : entity.entityType === "container" ? "container"
    : entity.entityType === "runtime" ? "service"
    : entity.entityType;
  const operationalStatus =
    entity.status === "stale" ? "degraded"
    : entity.status === "inactive" ? "offline"
    : "operational";

  // Resolve OSI layer: explicit from entity properties, else default by type
  const props = entity.properties ?? {};
  const osiLayer = (props.osiLayer as number | undefined)
    ?? DEFAULT_OSI_LAYER[entity.entityType];
  const osiLayerName = (props.osiLayerName as string | undefined)
    ?? (osiLayer != null ? OSI_LAYER_NAMES[osiLayer] : undefined);

  const extendedProps: InfraCIExtendedProps = {};
  if (osiLayer != null) extendedProps.osiLayer = osiLayer;
  if (osiLayerName != null) extendedProps.osiLayerName = osiLayerName;
  if (props.networkAddress != null) extendedProps.networkAddress = props.networkAddress as string;
  if (props.networkMask != null) extendedProps.networkMask = props.networkMask as string;
  if (props.protocolFamily != null) extendedProps.protocolFamily = props.protocolFamily as string;
  if (props.parentCiId != null) extendedProps.parentCiId = props.parentCiId as string;
  if (props.scopeKey != null) extendedProps.scopeKey = props.scopeKey as string;
  if (props.customerAccountId !== undefined) {
    extendedProps.customerAccountId = props.customerAccountId as string | null;
  }
  if (props.customerSiteId !== undefined) {
    extendedProps.customerSiteId = props.customerSiteId as string | null;
  }

  await syncInfraCI(
    {
      ciId: entity.entityKey,
      name: entity.name,
      ciType,
      status: operationalStatus,
      portfolioSlug: entity.portfolioSlug ?? null,
    },
    Object.keys(extendedProps).length > 0 ? extendedProps : undefined,
  );
}

/** Relationship types that get their own typed Neo4j edge instead of DEPENDS_ON. */
const TYPED_EDGE_RELATIONSHIP_TYPES = new Set([
  ...NETWORK_RELATIONSHIP_TYPES.map((t) => t.toUpperCase()),
  "MONITORS",
]);

export async function syncInventoryRelationship(rel: {
  fromEntityKey: string;
  toEntityKey: string;
  relationshipType: string;
}): Promise<void> {
  const neoType = rel.relationshipType.toUpperCase();

  if (TYPED_EDGE_RELATIONSHIP_TYPES.has(neoType)) {
    await runCypher(
      `MATCH (from:InfraCI {ciId: $fromId})
       MATCH (to:InfraCI {ciId: $toId})
       MERGE (from)-[r:${neoType}]->(to)
       SET r.syncedAt = datetime()`,
      { fromId: rel.fromEntityKey, toId: rel.toEntityKey },
    );
    return;
  }

  await syncDependsOn({
    fromLabel: "InfraCI",
    fromId: rel.fromEntityKey,
    toLabel: "InfraCI",
    toId: rel.toEntityKey,
    role: rel.relationshipType,
  });
}

export async function syncEaElement(element: {
  id: string;
  neoLabel: string;         // from EaElementType.neoLabel
  notationSlug: string;     // from EaElementType.notation.slug
  elementTypeSlug: string;  // from EaElementType.slug
  name: string;
  lifecycleStage: string;
  lifecycleStatus: string;
  infraCiKey?: string | null;
  digitalProductId?: string | null;
  portfolioSlug?: string | null;
  taxonomyNodeId?: string | null;
}): Promise<void> {
  // Upsert the node — Cypher MERGE requires exact label set; we use apoc.merge.node
  // for the dual-label pattern. Fall back to a parameterised label approach.
  await runCypher(
    `MERGE (n:EaElement {elementId: $id})
     SET n.notationId      = $notationSlug,
         n.elementType     = $elementTypeSlug,
         n.name            = $name,
         n.lifecycleStage  = $lifecycleStage,
         n.lifecycleStatus = $lifecycleStatus,
         n.infraCiKey      = $infraCiKey,
         n.syncedAt        = datetime()`,
    {
      id: element.id,
      notationSlug: element.notationSlug,
      elementTypeSlug: element.elementTypeSlug,
      name: element.name,
      lifecycleStage: element.lifecycleStage,
      lifecycleStatus: element.lifecycleStatus,
      infraCiKey: element.infraCiKey ?? null,
    },
  );

  // Add the type-specific label via APOC (safe if APOC not available — node still functions with :EaElement)
  await runCypher(
    `MATCH (n:EaElement {elementId: $id})
     CALL apoc.create.addLabels(n, [$neoLabel]) YIELD node
     RETURN node`,
    { id: element.id, neoLabel: element.neoLabel },
  ).catch((err: unknown) => {
    // Any error (e.g. APOC not installed) — type-specific label skipped; :EaElement label still present
    console.warn('[neo4j-sync] APOC dual-label failed for element', element.id, ':', err);
  });

  // EA_REPRESENTS → DigitalProduct
  if (element.digitalProductId) {
    await runCypher(
      `MATCH (ea:EaElement {elementId: $id})
       MATCH (dp:DigitalProduct {productId: $digitalProductId})
       MERGE (ea)-[:EA_REPRESENTS]->(dp)`,
      { id: element.id, digitalProductId: element.digitalProductId },
    );
  }

  // EA_REPRESENTS → Portfolio
  if (element.portfolioSlug != null) {
    await runCypher(
      `MATCH (ea:EaElement {elementId: $id})
       MATCH (p:Portfolio {slug: $portfolioSlug})
       MERGE (ea)-[:EA_REPRESENTS]->(p)`,
      { id: element.id, portfolioSlug: element.portfolioSlug },
    );
  }

  // EA_REPRESENTS → TaxonomyNode
  if (element.taxonomyNodeId) {
    await runCypher(
      `MATCH (ea:EaElement {elementId: $id})
       MATCH (tn:TaxonomyNode {pgId: $taxonomyNodeId})
       MERGE (ea)-[:EA_REPRESENTS]->(tn)`,
      { id: element.id, taxonomyNodeId: element.taxonomyNodeId },
    );
  }
}

/** Upsert an EaRelationship edge between two EaElement nodes. */
export async function syncEaRelationship(rel: {
  id: string;
  fromElementId: string;
  toElementId: string;
  neoType: string;      // from EaRelationshipType.neoType
  notationSlug: string;
  relationshipTypeSlug: string;
}): Promise<void> {
  await runCypher(
    `MATCH (from:EaElement {elementId: $fromId})
     MATCH (to:EaElement   {elementId: $toId})
     MERGE (from)-[r:${rel.neoType} {relationshipId: $id}]->(to)
     SET r.notationId       = $notationSlug,
         r.relationshipType = $relationshipTypeSlug,
         r.syncedAt         = datetime()`,
    {
      id: rel.id,
      fromId: rel.fromElementId,
      toId: rel.toElementId,
      notationSlug: rel.notationSlug,
      relationshipTypeSlug: rel.relationshipTypeSlug,
    },
  );
}

/** Remove an EaElement node and all its EA edges. */
export async function deleteEaElement(elementId: string): Promise<void> {
  await runCypher(
    `MATCH (n:EaElement {elementId: $elementId})
     DETACH DELETE n`,
    { elementId },
  );
}

/** Remove a single EaRelationship edge by its relationshipId property. */
export async function deleteEaRelationship(relationshipId: string): Promise<void> {
  await runCypher(
    // No rel-type filter — matches any type by relationshipId (low volume, intentional)
    `MATCH ()-[r {relationshipId: $relationshipId}]->()
     DELETE r`,
    { relationshipId },
  );
}
