#!/usr/bin/env node

import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'

const X_AUTH_URL = 'https://x.com/i/oauth2/authorize'
const X_TOKEN_URL = 'https://api.x.com/2/oauth2/token'
const X_API_URL = 'https://api.x.com/2'
const GITHUB_API_URL = 'https://api.github.com'
const DEFAULT_GITHUB_REPOSITORY = 'buildleansaas/obsidian-vault'
const DEFAULT_GITHUB_LABELS = ['inbox', 'agent-ready']
const SCOPES = ['tweet.read', 'users.read', 'bookmark.read', 'offline.access']

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 === 'auth-url') {
    await printAuthUrl()
    return
  }

  if (command === 'exchange') {
    await exchangeCode(flags.code, flags.state)
    return
  }

  if (command === 'sync') {
    await syncBookmarks(readSyncOptions({ defaultPages: 1 }))
    return
  }

  if (command === 'backfill') {
    await syncBookmarks(readSyncOptions({ defaultPages: 'all' }))
    return
  }

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

function printHelp() {
  console.log(`X Bookmark Capture Sync

Commands:
  auth-url                  Print the X OAuth authorization URL
  exchange --code <code> --state <state>
                            Exchange callback code and store tokens
  sync [options]            Fetch bookmarks and create GitHub issues
  backfill [options]        Fetch multiple pages for initial backlog capture

Sync options:
  --pages all|<n>           Pages to fetch. sync defaults to 1; backfill defaults to all.
  --page-size <1-100>       Bookmarks to request per page. Defaults to 100.
  --dry-run                 Print planned issues without creating GitHub issues or saving state.
  --stop-after-seen         Stop paging after the first already-processed bookmark.

Required environment:
  X_CLIENT_ID
  X_REDIRECT_URI
  GITHUB_TOKEN

Optional:
  X_CLIENT_SECRET           Use for confidential X OAuth apps.
  BOOKMARK_SYNC_STATE_PATH  Defaults to .bookmark-capture-sync/state.json
  GITHUB_REPOSITORY         Defaults to buildleansaas/obsidian-vault
  GITHUB_ISSUE_CAP          Defaults to 200 open configured inbox issues. Set 0 to disable the pre-X-fetch guard.
  GITHUB_ISSUE_LABELS       Comma-separated labels. Defaults to inbox,agent-ready, which exist in buildleansaas/obsidian-vault.
                            Missing configured labels are skipped; if none exist, the sync falls back to inbox or creates an unlabeled issue.
`)
}

async function printAuthUrl() {
  const env = requireEnv(['X_CLIENT_ID', 'X_REDIRECT_URI'])
  const verifier = base64Url(crypto.randomBytes(32))
  const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest())
  const state = base64Url(crypto.randomBytes(16))
  const store = await loadState()

  store.oauth = {
    ...(store.oauth || {}),
    codeVerifier: verifier,
    oauthState: state,
  }

  await saveState(store)

  const url = new URL(X_AUTH_URL)
  url.searchParams.set('response_type', 'code')
  url.searchParams.set('client_id', env.X_CLIENT_ID)
  url.searchParams.set('redirect_uri', env.X_REDIRECT_URI)
  url.searchParams.set('scope', SCOPES.join(' '))
  url.searchParams.set('state', state)
  url.searchParams.set('code_challenge', challenge)
  url.searchParams.set('code_challenge_method', 'S256')

  console.log(url.toString())
  console.log('\nAfter approving access, run:')
  console.log('node scripts/bookmark-capture-sync.mjs exchange --code <callback-code> --state <callback-state>')
}

async function exchangeCode(code, callbackState) {
  if (!code) {
    throw new Error('Missing --code <code>.')
  }

  if (!callbackState) {
    throw new Error('Missing --state <state>.')
  }

  const env = requireEnv(['X_CLIENT_ID', 'X_REDIRECT_URI'])
  const store = await loadState()
  const verifier = store.oauth?.codeVerifier
  const expectedState = store.oauth?.oauthState

  if (!verifier) {
    throw new Error('Missing OAuth code verifier. Run auth-url first.')
  }

  if (!expectedState) {
    throw new Error('Missing OAuth state. Run auth-url first.')
  }

  if (callbackState !== expectedState) {
    throw new Error('OAuth state mismatch. Run auth-url again and retry with the new callback values.')
  }

  const tokens = await requestXToken(env, {
    grant_type: 'authorization_code',
    redirect_uri: env.X_REDIRECT_URI,
    code,
    code_verifier: verifier,
  })

  store.oauth = {
    ...store.oauth,
    ...tokens,
    codeVerifier: undefined,
    oauthState: undefined,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  }

  await saveState(store)
  console.log('Stored X OAuth tokens.')
}

async function syncBookmarks({ maxPages, pageSize, dryRun, stopAfterSeen }) {
  const env = dryRun
    ? requireEnv(['X_CLIENT_ID'])
    : requireEnv(['X_CLIENT_ID', 'GITHUB_TOKEN'])

  if (!dryRun) {
    await assertGithubHasCapacity(env)
  }

  const store = await loadState()
  const accessToken = await getAccessToken(env, store)
  const user = await xGet(accessToken, '/users/me')
  const userId = user.data?.id

  if (!userId) {
    throw new Error('Could not resolve authenticated X user id.')
  }

  normalizeState(store)
  const processed = new Set(Object.keys(store.processedTweets || {}))
  let pagesFetched = 0
  let created = 0
  let skipped = 0
  let planned = 0
  let shouldStop = false
  let nextToken

  do {
    const params = new URLSearchParams({
      max_results: String(pageSize),
      'tweet.fields': 'author_id,created_at,entities,public_metrics',
      expansions: 'author_id',
      'user.fields': 'username,name',
    })

    if (nextToken) {
      params.set('pagination_token', nextToken)
    }

    const page = await xGet(accessToken, `/users/${userId}/bookmarks?${params.toString()}`)
    const authors = new Map((page.includes?.users || []).map((author) => [author.id, author]))
    const tweets = page.data || []

    if (tweets.length === 0) {
      console.log(`Fetched page ${pagesFetched + 1}: no bookmarks returned.`)
    }

    for (const tweet of tweets) {
      if (processed.has(tweet.id)) {
        skipped += 1
        if (stopAfterSeen) {
          shouldStop = true
          break
        }
        continue
      }

      const author = authors.get(tweet.author_id)
      const issueInput = await buildGithubIssueInput(env, tweet, author)
      planned += 1

      if (dryRun) {
        printDryRunIssue(issueInput, tweet, author)
        continue
      }

      const issue = await createGithubIssue(env, issueInput)
      processed.add(tweet.id)
      store.processedTweets[tweet.id] = {
        syncedAt: new Date().toISOString(),
        tweetId: tweet.id,
        tweetUrl: tweetUrl(tweet, author),
        githubIssueNumber: issue.number,
        githubIssueUrl: issue.html_url,
      }
      store.processedTweetIds = [...processed]
      store.lastSyncAt = new Date().toISOString()
      await saveState(store)
      created += 1
    }

    pagesFetched += 1
    nextToken = page.meta?.next_token
  } while (!shouldStop && nextToken && pagesFetched < maxPages)

  if (!dryRun) {
    store.processedTweetIds = [...processed]
    store.lastSyncAt = new Date().toISOString()
    await saveState(store)
  }

  const action = dryRun ? `Planned ${planned}` : `Created ${created}`
  const stopNote = shouldStop ? ' Stopped after the first already-processed bookmark.' : ''
  console.log(`Fetched ${pagesFetched} page(s). ${action} issue(s). Skipped ${skipped} already-processed bookmark(s).${stopNote}`)
}

async function getAccessToken(env, store) {
  const expiresAt = store.oauth?.expiresAt || 0
  const accessToken = store.oauth?.access_token

  if (accessToken && expiresAt - 60_000 > Date.now()) {
    return accessToken
  }

  const refreshToken = store.oauth?.refresh_token

  if (!refreshToken) {
    throw new Error('Missing X OAuth refresh token. Run auth-url and exchange first.')
  }

  const tokens = await requestXToken(env, {
    grant_type: 'refresh_token',
    refresh_token: refreshToken,
  })

  store.oauth = {
    ...store.oauth,
    ...tokens,
    refresh_token: tokens.refresh_token || refreshToken,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  }

  await saveState(store)
  return tokens.access_token
}

async function requestXToken(env, body) {
  const headers = { 'content-type': 'application/x-www-form-urlencoded' }
  const tokenBody = { ...body }

  if (process.env.X_CLIENT_SECRET) {
    headers.authorization = `Basic ${Buffer.from(`${env.X_CLIENT_ID}:${process.env.X_CLIENT_SECRET}`).toString('base64')}`
  } else {
    tokenBody.client_id = env.X_CLIENT_ID
  }

  const response = await fetch(X_TOKEN_URL, {
    method: 'POST',
    headers,
    body: new URLSearchParams(tokenBody),
  })

  if (!response.ok) {
    throw new Error(`X token request failed: ${response.status} ${await response.text()}`)
  }

  return response.json()
}

async function xGet(accessToken, endpoint) {
  const response = await fetch(`${X_API_URL}${endpoint}`, {
    headers: { authorization: `Bearer ${accessToken}` },
  })

  if (!response.ok) {
    throw new Error(`X API request failed: ${response.status} ${await response.text()}`)
  }

  return response.json()
}

async function buildGithubIssueInput(env, tweet, author) {
  const username = author?.username ? `@${author.username}` : tweet.author_id || 'unknown author'
  const url = tweetUrl(tweet, author)
  const title = `Review X bookmark: ${firstLine(tweet.text, 76)}`
  const description = [
    `Source: x-bookmark-capture-sync`,
    ``,
    `X bookmark from ${username}`,
    ``,
    url,
    ``,
    `Captured text:`,
    ``,
    quoteMarkdown(tweet.text),
    ``,
    `Tweet ID: ${tweet.id}`,
    tweet.created_at ? `Posted: ${tweet.created_at}` : null,
  ].filter(Boolean).join('\n')

  return {
    title,
    body: description,
    labels: env.GITHUB_TOKEN ? await resolveGithubLabels(env, githubLabels()) : githubLabels(),
    url,
  }
}

async function assertGithubHasCapacity(env) {
  const cap = parseGithubIssueCap()

  if (cap === Number.POSITIVE_INFINITY) {
    return
  }

  const count = await countOpenGithubInboxIssues(env, cap)

  if (count >= cap) {
    throw new Error(`GitHub inbox issue cap reached (${count}/${cap}). Skipping X bookmark fetch to avoid spending X API credits. Groom or close inbox issues in ${githubRepository()}, raise GITHUB_ISSUE_CAP, or set GITHUB_ISSUE_CAP=0 to disable this guard.`)
  }
}

async function countOpenGithubInboxIssues(env, cap) {
  const [owner, repo] = parseGithubRepository()
  const labelQuery = githubLabels().map((label) => `label:${JSON.stringify(label)}`).join(' ')
  const query = `repo:${owner}/${repo} is:issue is:open ${labelQuery}`
  const url = new URL(`${GITHUB_API_URL}/search/issues`)
  url.searchParams.set('q', query)
  url.searchParams.set('per_page', '1')

  const response = await githubFetch(env, url)
  const payload = await response.json().catch(() => null)

  if (!response.ok) {
    throw new Error(`GitHub inbox issue count failed: ${response.status} ${JSON.stringify(payload)}`)
  }

  const count = Number(payload?.total_count || 0)
  return Math.min(count, cap)
}

function parseGithubIssueCap() {
  const rawCap = process.env.GITHUB_ISSUE_CAP || '200'
  const cap = Number(rawCap)

  if (!Number.isInteger(cap) || cap < 0) {
    throw new Error('GITHUB_ISSUE_CAP must be a non-negative integer. Use 0 to disable the GitHub inbox capacity guard.')
  }

  return cap === 0 ? Number.POSITIVE_INFINITY : cap
}

async function createGithubIssue(env, issueInput) {
  const existing = await findExistingGithubIssue(env, issueInput.url)
  if (existing) {
    return existing
  }

  const [owner, repo] = parseGithubRepository()
  const response = await githubFetch(env, `${GITHUB_API_URL}/repos/${owner}/${repo}/issues`, {
    method: 'POST',
    body: JSON.stringify({
      title: issueInput.title,
      body: issueInput.body,
      labels: issueInput.labels,
    }),
  })
  const payload = await response.json().catch(() => null)

  if (!response.ok) {
    throw new Error(`GitHub issue create failed: ${response.status} ${JSON.stringify(payload)}`)
  }

  if (!payload?.html_url || !payload?.number) {
    throw new Error(`GitHub issue create returned an unexpected response: ${JSON.stringify(payload)}`)
  }

  return payload
}

async function findExistingGithubIssue(env, sourceUrl) {
  const [owner, repo] = parseGithubRepository()
  const query = `repo:${owner}/${repo} is:issue ${JSON.stringify(sourceUrl)}`
  const url = new URL(`${GITHUB_API_URL}/search/issues`)
  url.searchParams.set('q', query)
  url.searchParams.set('per_page', '1')

  const response = await githubFetch(env, url)
  const payload = await response.json().catch(() => null)

  if (!response.ok) {
    throw new Error(`GitHub source-url dedupe search failed: ${response.status} ${JSON.stringify(payload)}`)
  }

  return payload?.items?.[0] || null
}

async function listGithubLabels(env) {
  const [owner, repo] = parseGithubRepository()
  const response = await githubFetch(env, `${GITHUB_API_URL}/repos/${owner}/${repo}/labels?per_page=100`)
  const payload = await response.json().catch(() => null)

  if (!response.ok) {
    throw new Error(`GitHub label list failed: ${response.status} ${JSON.stringify(payload)}`)
  }

  return new Set((payload || []).map((label) => label.name))
}

async function resolveGithubLabels(env, requestedLabels) {
  const available = await listGithubLabels(env)
  const resolved = requestedLabels.filter((label) => available.has(label))

  if (resolved.length > 0) {
    return resolved
  }

  if (available.has('x-inbox')) return ['x-inbox']
  if (available.has('inbox')) return ['inbox']

  return []
}

function githubFetch(env, url, options = {}) {
  return fetch(url, {
    ...options,
    headers: {
      accept: 'application/vnd.github+json',
      authorization: `Bearer ${env.GITHUB_TOKEN}`,
      'content-type': 'application/json',
      'x-github-api-version': '2022-11-28',
      ...(options.headers || {}),
    },
  })
}

function githubRepository() {
  return process.env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY
}

function parseGithubRepository() {
  const repository = githubRepository()
  const [owner, repo] = repository.split('/')

  if (!owner || !repo) {
    throw new Error('GITHUB_REPOSITORY must use owner/repo format.')
  }

  return [owner, repo]
}

function githubLabels() {
  const raw = process.env.GITHUB_ISSUE_LABELS
  if (!raw) {
    return [...DEFAULT_GITHUB_LABELS]
  }

  return raw.split(',').map((label) => label.trim()).filter(Boolean)
}

function githubLabelColor(label) {
  if (label === 'x-inbox') return '1d9bf0'
  if (label.startsWith('status:')) return 'ededed'
  if (label.startsWith('type:')) return '0e8a16'
  return '5319e7'
}

function githubLabelDescription(label) {
  if (label === 'x-inbox') return 'Captured from X bookmarks for later triage'
  if (label === 'status:triage') return 'Needs human or agent triage'
  if (label === 'type:task') return 'Actionable task or inbox item'
  return 'Created by x-bookmark-capture-sync'
}

function firstLine(value, maxLength) {
  const normalized = value.replace(/\s+/g, ' ').trim()

  if (normalized.length <= maxLength) {
    return normalized
  }

  return `${normalized.slice(0, maxLength - 3)}...`
}

function quoteMarkdown(value) {
  return value
    .split('\n')
    .map((line) => `> ${line}`)
    .join('\n')
}

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 { processedTweetIds: [] }
    }

    throw error
  }
}

function normalizeState(store) {
  store.processedTweets = store.processedTweets || {}

  for (const tweetId of store.processedTweetIds || []) {
    store.processedTweets[tweetId] = store.processedTweets[tweetId] || {
      tweetId,
      syncedAt: null,
    }
  }

  store.processedTweetIds = Object.keys(store.processedTweets)
}

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.BOOKMARK_SYNC_STATE_PATH || '.bookmark-capture-sync/state.json'
  const expandedPath = configuredPath.replace(/^~(?=$|\/|\\)/, os.homedir())
  return path.resolve(expandedPath)
}

function base64Url(buffer) {
  return buffer.toString('base64url')
}

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' || name === 'stop-after-seen') {
      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 readSyncOptions({ defaultPages }) {
  return {
    maxPages: parsePages(flags.pages || defaultPages),
    pageSize: parsePageSize(flags.pageSize || 100),
    dryRun: Boolean(flags.dryRun),
    stopAfterSeen: Boolean(flags.stopAfterSeen),
  }
}

function parsePages(value) {
  if (value === 'all') {
    return Number.POSITIVE_INFINITY
  }

  const pages = Number(value)

  if (!Number.isInteger(pages) || pages < 1) {
    throw new Error('--pages must be a positive integer or all.')
  }

  return pages
}

function parsePageSize(value) {
  const pageSize = Number(value)

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

  return pageSize
}

function printDryRunIssue(issueInput, tweet, author) {
  console.log(JSON.stringify({
    action: 'dry-run:create-github-issue',
    tweetId: tweet.id,
    tweetUrl: issueInput.url,
    author: author?.username ? `@${author.username}` : tweet.author_id || null,
    title: issueInput.title,
  }, null, 2))
}

function tweetUrl(tweet, author) {
  return `https://x.com/${author?.username || 'i'}/status/${tweet.id}`
}

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