#!/usr/bin/env node

import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
const LINEAR_API_URL = 'https://api.linear.app/graphql'
const SOURCE_MARKER = 'Source: x-bookmark-capture-sync'
const WORK_TYPES = ['blog_post', 'small_experiment', 'implementation_task', 'research_note']

const args = process.argv.slice(2)
const command = args[0]
const flags = parseFlags(args.slice(1))

main().catch((error) => {
  console.error(error instanceof Error ? error.message : String(error))
  process.exitCode = 1
})

async function main() {
  if (!command || command === '--help' || command === 'help') {
    printHelp()
    return
  }

  if (command === 'fetch') {
    await fetchCandidates(readFetchOptions())
    return
  }

  if (command === 'apply') {
    await applyDecisions(readApplyOptions())
    return
  }

  if (command === 'prompt') {
    await printAgentPrompt(readPromptOptions())
    return
  }

  throw new Error(`Unknown command: ${command}`)
}

function printHelp() {
  console.log(`Linear X Bookmark Agent Triage

Commands:
  fetch [options]                 Export Linear bookmark capture candidates as JSON
  prompt --candidates <file>      Print a Hermes/Codex review prompt for candidates JSON
  apply --decisions <file>        Apply agent decisions back to Linear

Options:
  --limit <n>                     Candidate limit. Defaults to LINEAR_TRIAGE_LIMIT or 10.
  --candidates <file>             Candidates JSON file from fetch.
  --decisions <file>              Decisions JSON file from the agent.
  --dry-run                       Validate and print planned apply operations without writing.

Required environment:
  LINEAR_API_KEY
  LINEAR_TEAM_ID

Optional:
  LINEAR_TRIAGE_STATE_PATH        Defaults to .linear-x-bookmark-agent-triage/state.json
  LINEAR_TRIAGE_LIMIT             Defaults to 10
`)
}

async function printAgentPrompt({ candidatesPath }) {
  if (!candidatesPath) {
    throw new Error('Missing --candidates <file>.')
  }

  const candidatesJson = JSON.parse(await fs.readFile(path.resolve(candidatesPath), 'utf8'))
  console.log(`You are reviewing Linear Triage issues created from X bookmarks.
Use Shape Up thinking to turn every candidate into a Linear task decision. Do not fetch X, do not post to X, and do not invent source material beyond the candidate JSON.
For each candidate, choose exactly one action:
- convert: this should become an active task.
- question: the idea is promising but needs a clarifying question.
- park: keep it as a low-priority capture for later.
For each candidate, choose exactly one workType:
- blog_post
- small_experiment
- implementation_task
- research_note
Return only valid JSON with this shape:
{
  "decisions": [
    {
      "issueId": "linear-issue-id",
      "action": "convert",
      "workType": "blog_post",
      "score": 8,
      "comment": "Brief explanation for the Linear audit comment.",
      "title": "Updated Linear issue title",
      "description": "Updated Linear issue description",
      "shapeUp": {
        "problem": "What problem, question, or opportunity this bookmark points at.",
        "appetite": "Small, medium, or large. Include a short reason.",
        "pitch": "The shaped direction if this is worth doing.",
        "rabbitHoles": ["Risks or traps to avoid."],
        "noGos": ["Explicit things not to do."],
        "nextAction": "The next concrete action."
      }
    }
  ]
}
Rules:
- Keep every item as a Linear task decision, even if the action is question or park.
- Use score 1-10.
- For convert, include title, description, and all shapeUp fields.
- For question, include a title, a question-focused description, and all shapeUp fields.
- For park, include all shapeUp fields and explain why it is not worth active work yet.
- Prefer small experiments over large implementation tasks when uncertainty is high.
- Prefer blog_post when the value is teaching, explaining, or documenting a reusable workflow.
- Prefer research_note when the bookmark needs more source validation before work begins.
Candidate JSON:
${JSON.stringify(candidatesJson, null, 2)}
`)
}

async function fetchCandidates({ limit }) {
  const env = requireEnv(['LINEAR_API_KEY', 'LINEAR_TEAM_ID'])
  const state = await loadState()
  const processed = new Set(state.processedIssueIds || [])
  const issues = await readBookmarkIssues(env, limit)
  const candidates = issues
    .filter((issue) => !processed.has(issue.id))
    .map((issue) => ({
      issueId: issue.id,
      identifier: issue.identifier,
      url: issue.url,
      title: issue.title,
      description: issue.description || '',
      createdAt: issue.createdAt,
      updatedAt: issue.updatedAt,
    }))

  console.log(JSON.stringify({
    source: 'linear-x-bookmark-agent-triage',
    sourceMarker: SOURCE_MARKER,
    generatedAt: new Date().toISOString(),
    candidates,
  }, null, 2))
}

async function applyDecisions({ decisionsPath, dryRun }) {
  if (!decisionsPath) {
    throw new Error('Missing --decisions <file>.')
  }

  const env = dryRun ? {} : requireEnv(['LINEAR_API_KEY'])
  const state = await loadState()
  normalizeState(state)
  const decisions = await readDecisions(decisionsPath)
  const planned = []

  for (const decision of decisions) {
    validateDecision(decision)
    planned.push({
      issueId: decision.issueId,
      action: decision.action,
      workType: decision.workType,
      score: decision.score,
      updatesIssue: ['convert', 'question'].includes(decision.action) && Boolean(decision.title || decision.description || decision.shapeUp),
    })

    if (dryRun) {
      continue
    }

    if (['convert', 'question'].includes(decision.action) && (decision.title || decision.description || decision.shapeUp)) {
      await updateIssue(env, decision)
    }

    await createComment(env, decision)
    state.processedIssueIds = unique([...state.processedIssueIds, decision.issueId])
    state.processedIssues[decision.issueId] = {
      action: decision.action,
      workType: decision.workType,
      score: decision.score,
      processedAt: new Date().toISOString(),
    }
    state.lastApplyAt = new Date().toISOString()
    await saveState(state)
  }

  console.log(JSON.stringify({
    dryRun,
    planned,
    applied: dryRun ? 0 : planned.length,
  }, null, 2))
}

async function readBookmarkIssues(env, limit) {
  const query = `
    query BookmarkCaptureIssues($teamId: ID!, $limit: Int!, $sourceMarker: String!) {
      issues(
        first: $limit
        filter: {
          team: { id: { eq: $teamId } }
          description: { contains: $sourceMarker }
        }
        orderBy: createdAt
      ) {
        nodes {
          id
          identifier
          url
          title
          description
          createdAt
          updatedAt
        }
      }
    }
  `
  const payload = await linearGraphql(env, query, {
    teamId: env.LINEAR_TEAM_ID,
    limit,
    sourceMarker: SOURCE_MARKER,
  })

  return payload.data?.issues?.nodes || []
}

async function updateIssue(env, decision) {
  const query = `
    mutation UpdateBookmarkIssue($id: String!, $input: IssueUpdateInput!) {
      issueUpdate(id: $id, input: $input) {
        success
      }
    }
  `
  const input = {}

  if (decision.title) {
    input.title = decision.title
  }

  const description = formatIssueDescription(decision)

  if (description) {
    input.description = description
  }

  await linearGraphql(env, query, {
    id: decision.issueId,
    input,
  })
}

async function createComment(env, decision) {
  const query = `
    mutation CreateBookmarkDecisionComment($input: CommentCreateInput!) {
      commentCreate(input: $input) {
        success
      }
    }
  `
  await linearGraphql(env, query, {
    input: {
      issueId: decision.issueId,
      body: formatDecisionComment(decision),
    },
  })
}

async function linearGraphql(env, query, variables) {
  const response = await fetch(LINEAR_API_URL, {
    method: 'POST',
    headers: {
      authorization: env.LINEAR_API_KEY,
      'content-type': 'application/json',
    },
    body: JSON.stringify({ query, variables }),
  })
  const payload = await response.json().catch(() => null)

  if (!response.ok || payload?.errors?.length) {
    throw new Error(`Linear GraphQL request failed: ${response.status} ${JSON.stringify(payload)}`)
  }

  return payload
}

function formatDecisionComment(decision) {
  const lines = [
    `Agent triage decision: ${decision.action}`,
    `Work type: ${decision.workType}`,
    `Score: ${decision.score}/10`,
    ``,
    decision.comment,
  ]

  return lines.filter(Boolean).join('\n')
}

function formatIssueDescription(decision) {
  if (!decision.description && !decision.shapeUp) {
    return null
  }

  const shape = decision.shapeUp || {}
  const lines = [
    decision.description,
    ``,
    `---`,
    ``,
    `## Shape Up triage`,
    ``,
    `**Work type:** ${decision.workType}`,
    `**Action:** ${decision.action}`,
    `**Score:** ${decision.score}/10`,
    ``,
    shape.problem ? `**Problem:** ${shape.problem}` : null,
    shape.appetite ? `**Appetite:** ${shape.appetite}` : null,
    shape.pitch ? `**Pitch:** ${shape.pitch}` : null,
    formatList('Rabbit holes', shape.rabbitHoles),
    formatList('No-gos', shape.noGos),
    shape.nextAction ? `**Next action:** ${shape.nextAction}` : null,
  ]

  return lines.filter(Boolean).join('\n')
}

function formatList(label, values) {
  if (!Array.isArray(values) || values.length === 0) {
    return null
  }

  return [`**${label}:**`, ...values.map((value) => `- ${value}`)].join('\n')
}

async function readDecisions(filePath) {
  const parsed = JSON.parse(await fs.readFile(path.resolve(filePath), 'utf8'))
  return Array.isArray(parsed) ? parsed : parsed.decisions || []
}

function validateDecision(decision) {
  if (!decision || typeof decision !== 'object') {
    throw new Error('Decision must be an object.')
  }

  if (!decision.issueId) {
    throw new Error('Decision is missing issueId.')
  }

  if (!['convert', 'question', 'park'].includes(decision.action)) {
    throw new Error(`Unsupported decision action: ${decision.action}`)
  }

  if (!WORK_TYPES.includes(decision.workType)) {
    throw new Error(`Unsupported workType for ${decision.issueId}: ${decision.workType}`)
  }

  if (!Number.isInteger(decision.score) || decision.score < 1 || decision.score > 10) {
    throw new Error(`Decision for ${decision.issueId} must include score 1-10.`)
  }

  if (!decision.comment || typeof decision.comment !== 'string') {
    throw new Error(`Decision for ${decision.issueId} must include a comment.`)
  }

  if (['convert', 'question'].includes(decision.action)) {
    if (!decision.title || typeof decision.title !== 'string') {
      throw new Error(`Decision for ${decision.issueId} must include a title for ${decision.action}.`)
    }

    if (!decision.description || typeof decision.description !== 'string') {
      throw new Error(`Decision for ${decision.issueId} must include a description for ${decision.action}.`)
    }
  }

  validateShapeUp(decision)
}

function validateShapeUp(decision) {
  if (!decision.shapeUp || typeof decision.shapeUp !== 'object') {
    throw new Error(`Decision for ${decision.issueId} must include shapeUp.`)
  }

  for (const field of ['problem', 'appetite', 'pitch', 'nextAction']) {
    if (!decision.shapeUp[field] || typeof decision.shapeUp[field] !== 'string') {
      throw new Error(`Decision for ${decision.issueId} must include shapeUp.${field}.`)
    }
  }

  for (const field of ['rabbitHoles', 'noGos']) {
    if (!Array.isArray(decision.shapeUp[field])) {
      throw new Error(`Decision for ${decision.issueId} must include shapeUp.${field} as an array.`)
    }
  }
}

function readFetchOptions() {
  return {
    limit: parseLimit(flags.limit || process.env.LINEAR_TRIAGE_LIMIT || 10),
  }
}

function readApplyOptions() {
  return {
    decisionsPath: flags.decisions,
    dryRun: Boolean(flags.dryRun),
  }
}

function readPromptOptions() {
  return {
    candidatesPath: flags.candidates,
  }
}

function parseLimit(value) {
  const limit = Number(value)

  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
    throw new Error('--limit must be an integer between 1 and 100.')
  }

  return limit
}

function parseFlags(flagArgs) {
  const parsed = {}

  for (let index = 0; index < flagArgs.length; index += 1) {
    const arg = flagArgs[index]

    if (!arg.startsWith('--')) {
      throw new Error(`Unexpected argument: ${arg}`)
    }

    const name = arg.slice(2)

    if (name === 'dry-run') {
      parsed[toCamelCase(name)] = true
      continue
    }

    const value = flagArgs[index + 1]

    if (!value || value.startsWith('--')) {
      throw new Error(`Missing value for --${name}.`)
    }

    parsed[toCamelCase(name)] = value
    index += 1
  }

  return parsed
}

function requireEnv(names) {
  const missing = names.filter((name) => !process.env[name])

  if (missing.length > 0) {
    throw new Error(`Missing required environment variable(s): ${missing.join(', ')}`)
  }

  return Object.fromEntries(names.map((name) => [name, process.env[name]]))
}

async function loadState() {
  const filePath = statePath()

  try {
    return JSON.parse(await fs.readFile(filePath, 'utf8'))
  } catch (error) {
    if (error && error.code === 'ENOENT') {
      return { processedIssueIds: [], processedIssues: {} }
    }

    throw error
  }
}

function normalizeState(state) {
  state.processedIssueIds = state.processedIssueIds || []
  state.processedIssues = state.processedIssues || {}
}

async function saveState(state) {
  const filePath = statePath()
  await fs.mkdir(path.dirname(filePath), { recursive: true })
  await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`)
}

function statePath() {
  const configuredPath = process.env.LINEAR_TRIAGE_STATE_PATH || '.linear-x-bookmark-agent-triage/state.json'
  const expandedPath = configuredPath.replace(/^~(?=$|\/|\\)/, os.homedir())
  return path.resolve(expandedPath)
}

function unique(values) {
  return [...new Set(values)]
}

function toCamelCase(value) {
  return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
}
