import { ProviderError, type Adapter, type Field, type FieldOption, type Item, type Value } from './types'

/**
 * Linear (GraphQL). Containers are teams; items are issues. Fields: title, status (the team's
 * workflow states), priority, labels, and each label group as a single-choice field (Linear lets an
 * issue have one label per group), which is what categories map to.
 */

const PRIORITIES: FieldOption[] = [
  { id: '0', name: 'No priority' },
  { id: '1', name: 'Urgent' },
  { id: '2', name: 'High' },
  { id: '3', name: 'Medium' },
  { id: '4', name: 'Low' },
]

const ISSUE = `id identifier url title updatedAt archivedAt trashed priority
  state { id name }
  labels(first: 50) { nodes { id name parent { id } } }
  assignee { name }`

interface Issue {
  id: string
  identifier: string
  url: string
  title: string
  updatedAt: string
  archivedAt: string | null
  trashed?: boolean | null
  priority: number
  state: { id: string; name: string } | null
  labels: { nodes: { id: string; name: string; parent: { id: string } | null }[] }
  assignee: { name: string } | null
}

function toItem(i: Issue): Item {
  const values: Record<string, Value> = {
    title: i.title,
    state: i.state?.name ?? null,
    priority: PRIORITIES.find((p) => p.id === String(i.priority))?.name ?? null,
    labels: i.labels.nodes.filter((l) => !l.parent).map((l) => l.name),
    assignee: i.assignee ? [i.assignee.name] : [],
  }
  for (const l of i.labels.nodes) if (l.parent) values[`group:${l.parent.id}`] = l.name
  return { id: i.id, url: i.url, title: i.title, values, updatedAt: i.updatedAt, archived: !!i.archivedAt || !!i.trashed }
}

export function linearAdapter(apiOrigin = 'https://api.linear.app'): Adapter {
  async function gql<T>(token: string, query: string, variables: Record<string, unknown> = {}): Promise<T> {
    const res = await fetch(`${apiOrigin}/graphql`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    })
    const json = (await res.json().catch(() => ({}))) as { data?: T; errors?: { message: string; extensions?: { code?: string } }[] }
    const err = json.errors?.[0]
    if (err || !res.ok || !json.data) {
      const limited = err?.extensions?.code === 'RATELIMITED' || res.status === 429
      const missing = /not found|could not find|entity not found/i.test(err?.message ?? '')
      throw new ProviderError(`Linear: ${err?.message ?? res.status}`, limited ? 429 : missing ? 404 : res.status >= 400 ? res.status : 502, limited ? 10 : undefined)
    }
    return json.data
  }

  /** Option ids for the named values: labels and label groups become label ids. */
  function labelIds(fields: Field[], values: Record<string, Value>, current: string[] = []) {
    let ids = [...current]
    let touched = false
    for (const [fieldId, value] of Object.entries(values)) {
      const field = fields.find((f) => f.id === fieldId)
      if (!field || (fieldId !== 'labels' && !fieldId.startsWith('group:'))) continue
      touched = true
      const own = new Set(field.options?.map((o) => o.id))
      ids = ids.filter((id) => !own.has(id))
      const names = Array.isArray(value) ? value : value ? [String(value)] : []
      for (const name of names) {
        const option = field.options?.find((o) => o.name === name)
        if (option?.id) ids.push(option.id)
      }
    }
    return touched ? ids : undefined
  }

  function input(fields: Field[], values: Record<string, Value>, currentLabels?: string[]) {
    const out: Record<string, unknown> = {}
    if ('title' in values) out.title = String(values.title ?? '')
    if ('state' in values) {
      const state = fields.find((f) => f.id === 'state')?.options?.find((o) => o.name === values.state)
      if (state?.id) out.stateId = state.id
    }
    if ('priority' in values) {
      const p = PRIORITIES.find((o) => o.name === values.priority)
      out.priority = p ? Number(p.id) : 0
    }
    const labels = labelIds(fields, values, currentLabels)
    if (labels) out.labelIds = labels
    return out
  }

  const adapter: Adapter = {
    async searchContainers(token, query) {
      const q = query.trim()
      const data = await gql<{ teams: { nodes: { id: string; name: string; key: string }[] } }>(
        token,
        `query Teams($filter: TeamFilter) { teams(first: 50, filter: $filter) { nodes { id name key } } }`,
        { filter: q ? { name: { containsIgnoreCase: q } } : null },
      )
      return data.teams.nodes.map((t) => ({ id: t.id, name: t.name, icon: null, url: undefined }))
    },

    async getFields(token, containerId) {
      const data = await gql<{
        team: { states: { nodes: { id: string; name: string; color: string; position: number }[] } }
        issueLabels: { nodes: { id: string; name: string; color: string; isGroup: boolean; parent: { id: string } | null }[] }
      }>(
        token,
        // team() takes the id as String!, filters as ID: two variables with the same value.
        `query Fields($id: String!, $teamId: ID) {
          team(id: $id) { states(first: 100) { nodes { id name color position } } }
          issueLabels(first: 250, filter: { or: [{ team: { id: { eq: $teamId } } }, { team: { null: true } }] }) { nodes { id name color isGroup parent { id } } }
        }`,
        { id: containerId, teamId: containerId },
      )
      const states = [...data.team.states.nodes].sort((a, b) => a.position - b.position)
      const labels = data.issueLabels.nodes
      const fields: Field[] = [
        { id: 'title', name: 'Title', type: 'title' },
        { id: 'state', name: 'Status', type: 'status', options: states.map((s) => ({ id: s.id, name: s.name, color: s.color })) },
        { id: 'priority', name: 'Priority', type: 'select', options: PRIORITIES },
        { id: 'labels', name: 'Labels', type: 'multi_select', options: labels.filter((l) => !l.isGroup && !l.parent).map((l) => ({ id: l.id, name: l.name, color: l.color })) },
      ]
      for (const g of labels.filter((l) => l.isGroup)) {
        fields.push({
          id: `group:${g.id}`,
          name: g.name,
          type: 'select',
          options: labels.filter((l) => l.parent?.id === g.id).map((l) => ({ id: l.id, name: l.name, color: l.color })),
        })
      }
      fields.push({ id: 'assignee', name: 'Assignee', type: 'person' })
      return fields
    },

    async createOptionField(token, containerId, name, options) {
      const group = await gql<{ issueLabelCreate: { issueLabel: { id: string } } }>(
        token,
        `mutation Group($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { issueLabel { id } } }`,
        { input: { name, teamId: containerId, isGroup: true } },
      )
      const parentId = group.issueLabelCreate.issueLabel.id
      const children: FieldOption[] = []
      for (const o of options) {
        const color = /^#[0-9a-f]{6}$/i.test(o.color ?? '') ? o.color : undefined
        const made = await gql<{ issueLabelCreate: { issueLabel: { id: string; name: string; color: string } } }>(
          token,
          `mutation Label($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { issueLabel { id name color } } }`,
          { input: { name: o.name, teamId: containerId, parentId, ...(color ? { color } : {}) } },
        )
        children.push({ id: made.issueLabelCreate.issueLabel.id, name: made.issueLabelCreate.issueLabel.name, color: made.issueLabelCreate.issueLabel.color })
      }
      return { id: `group:${parentId}`, name, type: 'select', options: children }
    },

    async upsertOptions(token, containerId, fieldId, wanted) {
      const parentId = fieldId.startsWith('group:') ? fieldId.slice(6) : null
      if (!parentId) throw new ProviderError('Only label groups can be kept in step', 400)
      const field = (await adapter.getFields(token, containerId)).find((f) => f.id === fieldId)
      const options = field?.options ?? []
      const hex = (c?: string) => (/^#[0-9a-f]{6}$/i.test(c ?? '') ? c : undefined)
      for (const w of wanted) {
        const same = options.find((o) => o.name === w.name)
        const old = w.previous ? options.find((o) => o.name === w.previous) : undefined
        const target = same ?? old
        if (target?.id) {
          const input: Record<string, string> = {}
          if (target.name !== w.name) input.name = w.name
          if (hex(w.color) && hex(w.color)!.toLowerCase() !== (target.color ?? '').toLowerCase()) input.color = hex(w.color)!
          if (Object.keys(input).length) {
            await gql(token, `mutation LabelUpdate($id: String!, $input: IssueLabelUpdateInput!) { issueLabelUpdate(id: $id, input: $input) { success } }`, { id: target.id, input })
          }
        } else {
          await gql(token, `mutation Label($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { issueLabel { id name color } } }`, {
            input: { name: w.name, teamId: containerId, parentId, ...(hex(w.color) ? { color: hex(w.color) } : {}) },
          })
        }
      }
      return (await adapter.getFields(token, containerId)).find((f) => f.id === fieldId)!
    },

    async searchItems(token, containerId, query) {
      const q = query.trim()
      const data = await gql<{ issues: { nodes: Issue[] } }>(
        token,
        `query Search($filter: IssueFilter) { issues(first: 25, orderBy: updatedAt, filter: $filter) { nodes { ${ISSUE} } } }`,
        { filter: { team: { id: { eq: containerId } }, ...(q ? { title: { containsIgnoreCase: q } } : {}) } },
      )
      return data.issues.nodes.map(toItem)
    },

    async getItem(token, itemId) {
      try {
        const data = await gql<{ issue: Issue | null }>(token, `query Issue($id: String!) { issue(id: $id) { ${ISSUE} } }`, { id: itemId })
        return data.issue ? toItem(data.issue) : null
      } catch (err) {
        if (err instanceof ProviderError && err.status === 404) return null
        throw err
      }
    },

    async createItem(token, containerId, fields, values) {
      const data = await gql<{ issueCreate: { issue: Issue } }>(
        token,
        `mutation Create($input: IssueCreateInput!) { issueCreate(input: $input) { issue { ${ISSUE} } } }`,
        { input: { teamId: containerId, ...input(fields, values) } },
      )
      return toItem(data.issueCreate.issue)
    },

    async updateItem(token, itemId, fields, values) {
      // Labels are replaced as a set: start from the issue's current ones.
      const needsLabels = Object.keys(values).some((k) => k === 'labels' || k.startsWith('group:'))
      let current: string[] | undefined
      if (needsLabels) {
        const now = await gql<{ issue: Issue }>(token, `query Current($id: String!) { issue(id: $id) { ${ISSUE} } }`, { id: itemId })
        current = now.issue.labels.nodes.map((l) => l.id)
      }
      const data = await gql<{ issueUpdate: { issue: Issue } }>(
        token,
        `mutation Update($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { issue { ${ISSUE} } } }`,
        { id: itemId, input: input(fields, values, current) },
      )
      return toItem(data.issueUpdate.issue)
    },

    async setArchived(token, itemId, archived) {
      await gql(
        token,
        archived
          ? `mutation Archive($id: String!) { issueArchive(id: $id) { success } }`
          : `mutation Unarchive($id: String!) { issueUnarchive(id: $id) { success } }`,
        { id: itemId },
      )
    },

    async listChanged(token, containerId, since) {
      const items: Item[] = []
      let after: string | null = null
      do {
        const data: { issues: { nodes: Issue[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } } = await gql(
          token,
          `query Changed($filter: IssueFilter, $after: String) {
            issues(first: 100, after: $after, filter: $filter) { nodes { ${ISSUE} } pageInfo { hasNextPage endCursor } }
          }`,
          { filter: { team: { id: { eq: containerId } }, updatedAt: { gte: since } }, after },
        )
        items.push(...data.issues.nodes.map(toItem))
        after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null
      } while (after && items.length < 1000)
      return items
    },
  }
  return adapter
}
