import { derived, type Readable } from 'svelte/store'
import { messages, chatFor, currentSession, type ChatMessage, type ToolCallResult } from './chat'
import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
import { liveExecutionOutputFor, type LiveExecutionOutput } from './execstream'
import type { PlanStep, Session } from '$lib/api'

export { type ToolCallResult }

export interface ActivityEntry {
  id: string
  type:
    | 'goal'
    | 'plan'
    | 'step_running'
    | 'step_done'
    | 'step_failed'
    | 'tool_running'
    | 'tool_done'
    | 'tool_error'
    | 'knowledge'
    | 'complete'
    | 'question'
    | 'error'
  description: string
  detail?: string
  args?: string
  timestamp: number
  toolName?: string
  stepSeq?: number
  indent?: boolean
  status: 'running' | 'done' | 'failed'
  // Command output streaming in while a `run` tool call is still executing.
  // Distinct from `detail`, which is only populated once the tool_result
  // arrives — for an auto-run that is the moment the command finishes.
  liveOutput?: string
  // Deep link to an artifact this entry references — a recorded knowledge doc
  // or a looked-up entity — so the operator can open it directly instead of
  // having to navigate there by hand. Rendered as a clickable chip in the
  // timeline (F5). `slug` is an entity slug (e.g. "document:nomos/…").
  link?: { kind: 'knowledge' | 'entity'; slug: string }
}

// Detail text is kept full-length (not hard-truncated to a preview snippet)
// so the expanded view has something worth pretty-printing — capped only as
// a safety net against pathological payloads (a full fleet dump, etc).
const DETAIL_MAX = 8000

function summarizeArgs(args: unknown): string | undefined {
  if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
  if (Object.keys(args).length === 0) return undefined
  try {
    return JSON.stringify(args)
  } catch {
    return undefined
  }
}

function stringifyResult(result: unknown): string {
  const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
  return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
}

// A knowledge doc slug as printed in upsert_knowledge's result text — mirrors
// cmd/nomos/store.go's knowledgeSlugRe (e.g. "document:nomos/some-finding").
const KNOWLEDGE_SLUG_RE = /[a-z]+:nomos\/[a-z0-9-]+/
// An entity slug looks like "type:name" (host:strong, lxc:caddy); a bare UUID
// or free text doesn't, so we only deep-link when it does.
const ENTITY_SLUG_RE = /^[a-z][a-z0-9_]*:[^\s]+$/

// entityLinkFromArgs pulls a navigable slug out of a get_entity-style call's
// args so its activity entry can link straight to that entity's window (F5).
function entityLinkFromArgs(args: unknown): ActivityEntry['link'] | undefined {
  if (!args || typeof args !== 'object') return undefined
  const slug = (args as Record<string, unknown>)?.slug_or_id
  if (typeof slug === 'string' && ENTITY_SLUG_RE.test(slug)) {
    return { kind: 'entity', slug }
  }
  return undefined
}

// knowledgeLinkFromResult extracts the created doc's slug from an
// upsert_knowledge result so the "Recorded: …" entry links to the doc (F5).
function knowledgeLinkFromResult(result: unknown): ActivityEntry['link'] | undefined {
  const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
  const m = s.match(KNOWLEDGE_SLUG_RE)
  return m ? { kind: 'knowledge', slug: m[0] } : undefined
}

// Pure derivation, parameterized so it can back both the global "current
// session" activityLog below and a per-session activityLogFor(sessionId) for
// a floating task window.
//
// Timestamps are REAL where the data has them and FROZEN where it doesn't:
// - persisted tool calls use their message's created_at (a true time);
// - steps use their started_at when present;
// - only genuinely-live entries (a tool call on the in-flight message that
//   has no created_at yet) fall back to wall-clock, and that value is frozen
//   into `frozen` on FIRST sight so a re-derive (the 3s poller re-sets
//   `messages` every tick) reads the same value instead of marching every
//   entry forward. Before this, every entry's time was
//   `now - (len - index) * 1000` — fabricated at render time and churning
//   every poll (P0.2). `frozen` is owned by the caller and lives across
//   re-derivations; pass a fresh Map for a purity test.
export function computeActivityLog(
  $msgs: ChatMessage[],
  $steps: PlanStep[],
  $task: Session | null,
  frozen: Map<string, number>
): ActivityEntry[] {
  const entries: ActivityEntry[] = []
  const now = Date.now()
  // freeze: prefer a real persisted time; else reuse a value already pinned
  // for this id; else pin wall-clock now and remember it.
  const freeze = (id: string, real?: number | null): number => {
    if (real && real > 0) return real
    const hit = frozen.get(id)
    if (hit !== undefined) return hit
    frozen.set(id, now)
    return now
  }
  const tsOf = (iso?: string): number | undefined =>
    iso ? new Date(iso).getTime() || undefined : undefined

  // Goal
  if ($task?.goal) {
    entries.push({
      id: 'goal',
      type: 'goal',
      description: $task.goal,
      timestamp: 0,
      status: 'done'
    })
  }

  // Plan steps
  for (const s of $steps) {
    if (s.status === 'pending') continue
    const stepLabel = s.title || `Step ${s.seq}`
    entries.push({
      id: s.id,
      type:
        s.status === 'running' ? 'step_running' : s.status === 'done' ? 'step_done' : 'step_failed',
      description: `Step ${s.seq}: ${stepLabel}`,
      detail: s.detail || undefined,
      timestamp: freeze(s.id, tsOf(s.started_at)),
      status: s.status === 'running' ? 'running' : s.status === 'done' ? 'done' : 'failed'
    })
  }

  // Tool calls (from messages). Tag each tool with the plan step that's
  // currently running when it fires.
  //
  // Generation awareness (plan 2026-08-03 F4): a re-proposed task persists
  // every generation's propose_plan/update_plan_step calls. Without scoping,
  // the timeline rendered N "Proposed plan" entries and inferred
  // currentStepSeq from superseded generations — tools landed under the wrong
  // (current-gen) step and it read as "several plans, some never run." So:
  // only the LAST propose_plan is the live plan; earlier ones collapse to a
  // single "Earlier plan revised" marker, and update_plan_step step-tracking
  // only applies to the current generation.
  let lastPlanMi = -1
  let lastPlanTi = -1
  let planCount = 0
  for (let mi = 0; mi < $msgs.length; mi++) {
    for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
      if ($msgs[mi].tools[ti].name === 'propose_plan') {
        planCount++
        lastPlanMi = mi
        lastPlanTi = ti
      }
    }
  }
  const revised = planCount > 1

  let currentStepSeq = 0
  let entryIdx = 0
  // No plan at all (plan-less Q&A) → treat the whole transcript as current.
  let sawCurrentPlan = planCount === 0
  let emittedRevised = false
  for (let mi = 0; mi < $msgs.length; mi++) {
    const msgTs = tsOf($msgs[mi].created_at)
    for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
      const t = $msgs[mi].tools[ti]
      const isLastPlan = mi === lastPlanMi && ti === lastPlanTi
      if (isLastPlan) sawCurrentPlan = true

      // Track current step ONLY from the current generation's
      // update_plan_step calls; a superseded generation's seqs would tag
      // tools with the wrong (current-gen) step.
      if (sawCurrentPlan && t.type === 'tool_use' && t.name === 'update_plan_step') {
        const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined
        const status = typeof t.args?.status === 'string' ? t.args.status : undefined
        if (s && status === 'running') currentStepSeq = s
      } else if (t.name === 'set_goal' || t.name === 'propose_plan' || t.name === 'complete_task') {
        currentStepSeq = 0
      }

      // Skip superseded-generation propose_plan entries; emit one collapsed
      // "revised" marker so a re-proposal stays visible without reading as a
      // second active plan.
      if (t.name === 'propose_plan' && !isLastPlan) {
        if (revised && !emittedRevised) {
          emittedRevised = true
          entries.push({
            id: `plan_revised_${mi}_${ti}`,
            type: 'plan',
            description: 'Earlier plan revised',
            timestamp: freeze(`plan_revised_${mi}_${ti}`, msgTs),
            status: 'done'
          })
        }
        continue
      }

      const label = toolActivityLabel(t)
      const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined
      const id = t.id ?? `tool_${mi}_${entryIdx++}`
      if (t.type === 'tool_use') {
        entries.push({
          id,
          type: 'tool_running',
          description: label,
          args: summarizeArgs(t.args),
          timestamp: freeze(id, msgTs),
          toolName: t.name,
          stepSeq: stepTag,
          indent: stepTag != null,
          status: 'running'
        })
      } else if (t.type === 'tool_result') {
        const running = entries.find(
          (e) => e.type === 'tool_running' && e.id === t.id && e.status === 'running'
        )
        if (running && t.error) {
          running.type = 'tool_error'
          running.status = 'failed'
          running.description = `${label}: ${t.error.slice(0, 80)}`
          running.detail = t.error
        } else if (running) {
          running.type = 'tool_done'
          running.status = 'done'
          running.detail = stringifyResult(t.result)
          if (t.name === 'get_entity' || t.name === 'get_entity_knowledge') {
            running.link = entityLinkFromArgs(t.args)
          }
        } else {
          // Historical/persisted tool calls arrive as one merged record (args
          // + result on the same object, see mergeToolCalls in chat.ts) rather
          // than a separate tool_use/tool_result pair — there's never a
          // "running" entry to attach to, so this branch has to build the
          // full entry itself. It used to fall back to the raw tool name
          // (e.g. "get_entity") instead of the humanized label here.
          entries.push({
            id,
            type: t.error ? 'tool_error' : 'tool_done',
            description: t.error ? `${label}: ${t.error.slice(0, 80)}` : label,
            detail: t.error ? t.error : stringifyResult(t.result),
            args: summarizeArgs(t.args),
            timestamp: freeze(id, msgTs),
            toolName: t.name,
            stepSeq: stepTag,
            indent: stepTag != null,
            status: t.error ? 'failed' : 'done',
            link:
              t.name === 'get_entity' || t.name === 'get_entity_knowledge'
                ? entityLinkFromArgs(t.args)
                : undefined
          })
        }
      }
    }
  }

  // Knowledge recorded — detect from upsert_knowledge tool results
  for (let mi = 0; mi < $msgs.length; mi++) {
    const msgTs = tsOf($msgs[mi].created_at)
    for (const t of $msgs[mi].tools) {
      if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) {
        const title = typeof t.args?.title === 'string' ? t.args.title : ''
        const kid = `knowledge_${mi}_${t.id ?? ''}`
        entries.push({
          id: kid,
          type: 'knowledge',
          description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
          timestamp: freeze(kid, msgTs),
          status: 'done',
          link: knowledgeLinkFromResult(t.result)
        })
      }
    }
  }

  // Task completion
  if ($task?.outcome) {
    entries.push({
      id: 'complete',
      type: 'complete',
      description: $task.summary || `Task ${$task.outcome}`,
      timestamp: freeze('complete'),
      status: $task.outcome === 'failure' ? 'failed' : 'done'
    })
  }

  // Note: approval entries were removed from activityLog (2026-07-15).
  // They were always `status: 'running'` and never transitioned to 'done'
  // (the derived store builds from tool-call text, not execution status),
  // which caused the AgentIndicator to latch onto a stale "Approval: ..."
  // entry and never clear — even after the session completed. Approvals
  // are tracked via the REST /approvals endpoint (context.ts, Ops.svelte)
  // and rendered as inline approval cards in the chat (or Ops page), not
  // in the activity log.

  // Sort oldest first
  entries.sort((a, b) => a.timestamp - b.timestamp)

  return entries
}

// A per-store freeze map: the first time a live entry (no real timestamp
// yet) is seen, its wall-clock time is pinned here so the 3s poller's
// re-derivation can't march it forward. Owned here, outside the derivation,
// so it survives re-runs. The per-session path has its own Map keyed by id.
const frozenTimestamps = new Map<string, number>()

// Live execution output for whichever session the global "current session"
// view is on — used to attach streaming `run` output to the global activityLog
// (the per-window activityLogFor has its own). Follows currentSession via a
// derived setup function so the subscription moves to the right session's
// store when the operator switches tasks.
const currentLiveOutput = derived(
  currentSession,
  ($sid, set) => {
    if (!$sid) {
      set(null)
      return
    }
    return liveExecutionOutputFor($sid).subscribe(set)
  },
  null as LiveExecutionOutput | null
)

export const activityLog = derived(
  [messages, planSteps, currentTask, currentLiveOutput],
  ([$msgs, $steps, $task, $live]) =>
    withLiveOutput(computeActivityLog($msgs, $steps, $task, frozenTimestamps), $live)
)

// Attach streaming output to the `run` entry that is currently executing.
// Nomos runs tools sequentially, so the last still-running run entry is the
// one the output belongs to.
function withLiveOutput(
  entries: ActivityEntry[],
  live: LiveExecutionOutput | null
): ActivityEntry[] {
  if (!live?.output) return entries
  for (let i = entries.length - 1; i >= 0; i--) {
    const e = entries[i]
    if (e.type === 'tool_running' && e.status === 'running' && e.toolName === 'run') {
      entries[i] = { ...e, liveOutput: live.output }
      break
    }
  }
  return entries
}

// One freeze map per session window (entry ids are UUIDs, but the synthetic
// 'goal'/'complete' ids collide across sessions, so each window keeps its own).
const sessionFrozenTimestamps = new Map<string, Map<string, number>>()
export function activityLogFor(sessionId: string): Readable<ActivityEntry[]> {
  const chat = chatFor(sessionId)
  const ws = workspaceFor(sessionId)
  const task = taskFor(sessionId)
  const live = liveExecutionOutputFor(sessionId)
  let frozen = sessionFrozenTimestamps.get(sessionId)
  if (!frozen) {
    frozen = new Map<string, number>()
    sessionFrozenTimestamps.set(sessionId, frozen)
  }
  return derived([chat.messages, ws.planSteps, task, live], ([$msgs, $steps, $task, $live]) =>
    withLiveOutput(computeActivityLog($msgs, $steps, $task, frozen), $live)
  )
}

// Humanized, past/present-tense description of what a tool call is doing
// ("Check execution", "Research: …") rather than its raw wire name. Exported
// so the chat's agent trace can read as a thinking log instead of an API log.
export function toolActivityLabel(t: ToolCallResult): string {
  const args = t.args ?? {}
  const str = (v: unknown): string => (typeof v === 'string' ? v : '')
  switch (t.name) {
    case 'set_goal':
      return 'Set goal'
    case 'propose_plan':
      return 'Proposed plan'
    case 'search_knowledge':
      return `Research: ${str(args.query)}`
    case 'get_entity':
      return `Lookup: ${str(args.slug_or_id)}`
    case 'get_entity_knowledge':
      return 'Check prior knowledge'
    case 'get_relations':
      return 'Check relationships'
    case 'list_lxcs':
      return 'List containers'
    case 'list_entities':
      return 'List entities'
    case 'get_health_summary':
      return 'Fleet health'
    case 'get_state_snapshot':
      return 'State snapshot'
    case 'run': {
      const purpose = str(args.purpose)
      const target = str(args.target)
      if (purpose) return purpose
      if (target) return `Run on ${target}`
      return 'Run command'
    }
    case 'get_execution_status':
      return 'Check execution'
    case 'update_plan_step':
      return 'Update plan'
    case 'upsert_knowledge':
      return 'Record knowledge'
    case 'complete_task':
      return 'Complete task'
    case 'ping_service':
      return 'Check service'
    case 'ask_operator':
      return 'Ask operator'
    // Unmapped tool (new/uncommon) — humanize the raw name rather than
    // showing it verbatim, e.g. "revoke_execution" -> "Revoke execution".
    default:
      return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
  }
}

// ── toolResultSummary ────────────────────────────────────────────────────
// A one-line, humanized summary of a tool's RESULT (the Claude-Code-style
// "exit 0 · <line>" / "host:hubris (healthy)" affordance) so each tool line
// in the inline trace reads as an outcome instead of a raw JSON blob. Empty
// for a still-running call (no result yet) or an errored one (the error is
// surfaced separately). Best-effort by tool name; the fallback is the first
// non-empty line of the stringified result, truncated — never blank (the
// expandable raw detail is always one click away).
function resultAsString(r: unknown): string {
  if (r == null) return ''
  if (typeof r === 'string') return r
  try {
    return JSON.stringify(r)
  } catch {
    return String(r)
  }
}
function firstLine(s: string, max = 80): string {
  const line = s
    .split(/\r?\n/)
    .map((l) => l.trim())
    .find((l) => l.length > 0) ?? ''
  return line.length > max ? `${line.slice(0, max - 1)}…` : line
}
function resultArray(r: unknown): unknown[] | null {
  if (Array.isArray(r)) return r
  if (r && typeof r === 'object') {
    const o = r as Record<string, unknown>
    for (const k of [
      'entities',
      'results',
      'relations',
      'steps',
      'items',
      'containers',
      'docs',
      'questions',
      'signals',
      'events',
      'patterns',
      'skills'
    ]) {
      if (Array.isArray(o[k])) return o[k] as unknown[]
    }
  }
  return null
}
function num(v: unknown): number | null {
  return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
  return `${n} ${n === 1 ? singular : pluralForm}`
}
export function toolResultSummary(t: ToolCallResult): string {
  if (t.type === 'tool_use') return '' // still running
  if (t.error) return '' // error surfaced separately
  const args = t.args ?? {}
  const str = (v: unknown): string => (typeof v === 'string' ? v : '')
  const obj = (r: unknown): Record<string, unknown> | null =>
    r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : null
  switch (t.name) {
    case 'run': {
      const s = resultAsString(t.result)
      const m = s.match(/exit (?:status )?(\d+)/i)
      const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok'
      const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60)
      return rest ? `${tag} · ${rest}` : tag
    }
    case 'get_entity': {
      const o = obj(t.result)
      const slug = str(o?.slug) || str(args.slug_or_id)
      const health = str(o?.health) || str(o?.state)
      return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found'
    }
    case 'get_relations': {
      const a = resultArray(t.result)
      return a ? plural(a.length, 'relation') : 'done'
    }
    case 'list_entities':
    case 'list_lxcs': {
      const a = resultArray(t.result)
      if (!a) return 'done'
      return t.name === 'list_lxcs'
        ? plural(a.length, 'container')
        : plural(a.length, 'entity', 'entities')
    }
    case 'get_health_summary': {
      const o = obj(t.result)
      const h = (o?.health && obj(o.health)) || o
      if (h) {
        const parts = ['healthy', 'degraded', 'down', 'unknown']
          .map((k) => {
            const n = num((h as Record<string, unknown>)[k])
            return n != null ? `${k} ${n}` : null
          })
          .filter((p): p is string => p != null)
        if (parts.length) return parts.join(' · ')
      }
      return 'done'
    }
    case 'get_state_snapshot': {
      const o = obj(t.result)
      const drift = num(o?.drift ?? o?.drift_count)
      return drift != null ? plural(drift, 'drift') : 'done'
    }
    case 'search_knowledge':
    case 'get_entity_knowledge':
    case 'get_patterns':
    case 'get_skills': {
      const a = resultArray(t.result)
      return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done'
    }
    case 'upsert_knowledge': {
      const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/)
      return m ? `recorded ${m[0]}` : 'recorded'
    }
    case 'update_plan_step': {
      const seq = num(args.seq)
      const status = str(args.status)
      if (seq != null && status) return `step ${seq} → ${status}`
      return status || 'updated'
    }
    case 'propose_plan': {
      const a = resultArray(t.result) ?? resultArray(args.steps)
      return a ? plural(a.length, 'step') : 'planned'
    }
    case 'set_goal':
      return 'goal set'
    case 'complete_task':
      return str(args.outcome) || 'complete'
    case 'ask_operator':
      return 'asked'
    case 'ping_service': {
      const s = resultAsString(t.result).toLowerCase()
      return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done'
    }
    case 'get_execution_status': {
      const o = obj(t.result)
      return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done'
    }
    default:
      return firstLine(resultAsString(t.result)) || 'done'
  }
}
