/**
 * Shared palette + display-string primitives for any surface that renders
 * the Neo4j graph. Both the live `/graph` page in platform/ui and the
 * graph-viewer MCP plugin's PNG renderer import from here so a node looks
 * the same wherever it appears: same colour family per label, same short
 * canvas label per node.
 *
 * UI-only constants (filter-popover sets, zoom-tier wrap helpers,
 * agent-action label sets) stay in platform/ui/app/lib/graph-labels.ts and
 * platform/ui/app/graph/display-helpers.ts — they re-export the symbols
 * defined here. The two parity tests (this lib's __tests__ and the UI's
 * unchanged colour-stability tests) keep the surfaces in lockstep.
 *
 * Sources of truth:
 *   - Label list: .docs/neo4j.md "Schema (Phase 0)" + "Standards Compliance"
 *   - Brand colour for LocalBusiness: brand.json defaultColors.primary
 *     (resolved at runtime; this module supplies the schema-default fallback)
 */

/**
 * Label → display colour mapping. Closed list of node labels written by
 * the Maxy MCP write paths. A new label without an entry here renders in
 * FALLBACK_LABEL_COLOUR and is omitted from the legend.
 */
export const GRAPH_LABEL_COLOURS: Record<string, string> = {
  // Business identity — slate-blue family
  LocalBusiness: '#4F6B8A',
  Service: '#6B85A0',
  PriceSpecification: '#8AA0B8',
  OpeningHoursSpecification: '#A8BACE',

  // Estate-agent ontology. Listing/Property share the business slate-blue
  // family; Viewing/Offer are transactional events placed in a warm amber
  // family distinct from Agent and from the dusty-rose Task/Project/Event
  // hues so the legend reads them as a separate band.
  Listing: '#3F5670',
  Property: '#5C7A99',
  Viewing: '#D4A574',
  Offer: '#B08850',

  // External business entity — schema:Organization, written by memory-write
  // as the Supplier label. Warm tobacco — distinct hue from internal-business
  // slates so external entities are scannable at a glance.
  Organization: '#8A6B47',

  // People — terracotta family (warm, human)
  Person: '#B86E4A',
  UserProfile: '#D08960',
  Preference: '#DFA67A',
  AdminUser: '#8C5230',
  AccessGrant: '#66381F',

  // Knowledge — moss family (organic). :ConversationArchive (Task 397)
  // sits at the cooler/darker end of the same family so chat transcripts
  // read as adjacent to documents on the canvas without colliding with
  // them; the parent label is now the canvas discriminator, not a
  // property heuristic.
  KnowledgeDocument: '#6E8A5A',
  ConversationArchive: '#4F6B3E',
  Section: '#8AA876',
  Chunk: '#A6C194',
  DigitalDocument: '#5A7548',
  CreativeWork: '#B5C9A2',
  Question: '#C7D6B5',
  FAQPage: '#95B385',
  DefinedTerm: '#76906A',
  Review: '#3F5A2E',
  ImageObject: '#A0BB8C',

  // Conversational — muted plum/lavender family.
  // Pairwise-distinctness invariants enforced by the UI test
  // graph-labels.test.ts; UserMessage matches Preference (Person/family cue,
  // the one intentional duplicate in the registry).
  Conversation: '#6B5A85',
  AdminConversation: '#4F4070',
  PublicConversation: '#8A75A8',
  Message: '#6B7280',
  UserMessage: '#DFA67A',
  AssistantMessage: '#C9A876',
  ToolCall: '#B5ABCB',

  // Tasks / projects / events — dusty rose family
  Task: '#B0617A',
  Project: '#8E4A60',
  Event: '#C68095',

  // Workflows — muted teal
  Workflow: '#3A6F77',
  WorkflowStep: '#5A8E96',
  WorkflowRun: '#7AAAB2',
  StepResult: '#9CC4CB',

  // Email — muted moss, hue-adjacent to Knowledge but lower-saturation
  // and warmer; never co-rendered with KnowledgeDocument.
  Email: '#6F7F4A',
  EmailAccount: '#91A063',

  // Public-agent projection — burnished bronze sits between
  // people-terracotta and email-moss but shares neither hue.
  Agent: '#B8893D',
}

export const FALLBACK_LABEL_COLOUR = '#94A3B8'

export const ALL_GRAPH_LABELS: readonly string[] = Object.freeze(
  Object.keys(GRAPH_LABEL_COLOURS)
)

/**
 * Select the sublabel whose colour should drive the node hue. Walks the
 * label array from index 1 (skipping the base label at index 0) and
 * returns the first sublabel that has a registered colour; falls back to
 * labels[0]. Pure — drift-counting lives in resolveNodeColour.
 */
export function pickDisplayLabel(labels: readonly string[]): string | null {
  for (let i = 1; i < labels.length; i++) {
    if (Object.prototype.hasOwnProperty.call(GRAPH_LABEL_COLOURS, labels[i])) {
      return labels[i]
    }
  }
  return labels[0] ?? null
}

export function colourForLabel(label: string): string {
  return GRAPH_LABEL_COLOURS[label] ?? FALLBACK_LABEL_COLOUR
}

export interface ResolvedNodeColour {
  displayLabel: string | null
  colour: string
  driftCandidates: number
}

/**
 * Single-source colour resolver. driftCandidates counts registered
 * sublabels in labels[1..]; a node with two registered sublabels has
 * driftCandidates = 2 and its rendered hue is order-dependent.
 */
export function resolveNodeColour(labels: readonly string[]): ResolvedNodeColour {
  const displayLabel = pickDisplayLabel(labels)
  const colour = displayLabel ? colourForLabel(displayLabel) : FALLBACK_LABEL_COLOUR
  let driftCandidates = 0
  for (let i = 1; i < labels.length; i++) {
    if (Object.prototype.hasOwnProperty.call(GRAPH_LABEL_COLOURS, labels[i])) {
      driftCandidates++
    }
  }
  return { displayLabel, colour, driftCandidates }
}

// ---------------------------------------------------------------------------
// Display strings — short canvas label + untruncated display name.
// Verbatim from platform/ui/app/graph/display-helpers.ts at extraction time.
// UI helpers that wrap on zoom tier, pack into multi-line canvas labels,
// or compose tooltips stay in display-helpers.ts and call into these.
// ---------------------------------------------------------------------------

/**
 * Structural type used by display-string helpers. Mirrors the shape of
 * GraphNode without coupling to it.
 */
export interface GraphNodeLike {
  id: string
  labels: string[]
  properties: Record<string, unknown>
}

const ISO_MINUTE_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/
const ISO_SECOND_RE = /^\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}:\d{2})/

/**
 * YYYY-MM-DD HH:mm from an ISO 8601 prefix. Reads the prefix directly so
 * timezone-stored UTC timestamps render identically across operator
 * timezones; parsing to Date + reformatting would flip the day.
 */
export function formatRunTimestamp(iso: string): string | null {
  const m = iso.match(ISO_MINUTE_RE)
  if (!m) return null
  return `${m[1]} ${m[2]}`
}

/** HH:mm:ss from an ISO 8601 prefix. Same tz-stable contract. */
export function formatMessageTimestamp(iso: string): string | null {
  const m = iso.match(ISO_SECOND_RE)
  if (!m) return null
  return m[1]
}

/**
 * 24-char canvas label for a node. Label precedence per label family
 * (ToolCall.toolName, WorkflowRun startedAt formatted, WorkflowStep.label,
 * Person givenName+familyName compose, Agent displayName/slug, Message
 * role+timestamp), then a generic fallthrough across curated keys
 * (name, title, summary, givenName, subject, text) — :ConversationArchive
 * rows (Task 397) carry a stable `title` written by the writer so they pick
 * up the generic-fallback `title` key naturally. Finally a
 * Conversation-specific `Conv <id8>` fallback so unpopulated conversations
 * don't render as the literal label.
 */
export function pickShortLabel(node: GraphNodeLike): string {
  const props = node.properties
  const primaryLabel = node.labels[0]
  if (primaryLabel === 'ToolCall') {
    const v = props.toolName
    if (typeof v === 'string' && v.length > 0) {
      return v.length > 24 ? v.slice(0, 24) + '…' : v
    }
  } else if (primaryLabel === 'WorkflowRun') {
    const v = props.startedAt
    if (typeof v === 'string' && v.length > 0) {
      const ts = formatRunTimestamp(v)
      if (ts) return ts
    }
  } else if (primaryLabel === 'WorkflowStep') {
    const v = props.label
    if (typeof v === 'string' && v.length > 0) {
      return v.length > 24 ? v.slice(0, 24) + '…' : v
    }
  } else if (primaryLabel === 'Person') {
    const given = typeof props.givenName === 'string' ? props.givenName : ''
    const family = typeof props.familyName === 'string' ? props.familyName : ''
    const full = [given, family].filter((s) => s.length > 0).join(' ')
    if (full.length > 0) {
      return full.length > 24 ? full.slice(0, 24) + '…' : full
    }
  } else if (primaryLabel === 'Agent') {
    const dn = typeof props.displayName === 'string' ? props.displayName : ''
    const slug = typeof props.slug === 'string' ? props.slug : ''
    const v = dn.length > 0 ? dn : slug
    if (v.length > 0) {
      return v.length > 24 ? v.slice(0, 24) + '…' : v
    }
  } else if (primaryLabel === 'Message') {
    const role = typeof props.role === 'string' ? props.role : ''
    const roleShort =
      role === 'user' ? 'user'
      : role === 'assistant' ? 'asst'
      : role === 'system' ? 'sys'
      : role === 'tool' ? 'tool'
      : null
    const createdAt = typeof props.createdAt === 'string' ? props.createdAt : ''
    if (roleShort && createdAt) {
      const ts = formatMessageTimestamp(createdAt)
      if (ts) return `${roleShort} ${ts}`
    }
  }
  for (const k of ['name', 'title', 'summary', 'givenName', 'subject', 'text']) {
    const v = props[k]
    if (typeof v === 'string' && v.length > 0) {
      return v.length > 24 ? v.slice(0, 24) + '…' : v
    }
  }
  if (primaryLabel === 'Conversation') {
    const id = typeof props.sessionId === 'string' ? props.sessionId : null
    if (id) return `Conv ${id.slice(0, 8)}`
  }
  return primaryLabel ?? '…'
}

/**
 * Untruncated sibling of pickShortLabel. Used by side-panel headings and
 * tooltips where the 24-char canvas-overlap cap does not apply.
 */
export function pickDisplayName(node: GraphNodeLike): string {
  const props = node.properties
  const primaryLabel = node.labels[0]
  if (primaryLabel === 'ToolCall') {
    const v = props.toolName
    if (typeof v === 'string' && v.length > 0) return v
  } else if (primaryLabel === 'WorkflowRun') {
    const v = props.startedAt
    if (typeof v === 'string' && v.length > 0) {
      const ts = formatRunTimestamp(v)
      if (ts) return ts
    }
  } else if (primaryLabel === 'WorkflowStep') {
    const v = props.label
    if (typeof v === 'string' && v.length > 0) return v
  } else if (primaryLabel === 'Person') {
    const given = typeof props.givenName === 'string' ? props.givenName : ''
    const family = typeof props.familyName === 'string' ? props.familyName : ''
    const full = [given, family].filter((s) => s.length > 0).join(' ')
    if (full.length > 0) return full
  } else if (primaryLabel === 'Agent') {
    const dn = typeof props.displayName === 'string' ? props.displayName : ''
    const slug = typeof props.slug === 'string' ? props.slug : ''
    const v = dn.length > 0 ? dn : slug
    if (v.length > 0) return v
  }
  for (const k of ['name', 'title', 'summary', 'givenName', 'subject', 'text']) {
    const v = props[k]
    if (typeof v === 'string' && v.length > 0) return v
  }
  if (primaryLabel === 'Conversation') {
    const id = typeof props.sessionId === 'string' ? props.sessionId : null
    if (id) return `Conv ${id.slice(0, 8)}`
  }
  return primaryLabel ?? '…'
}
