import type { ReactNode } from 'react'
import { useMemo, useRef } from 'react'
import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
import type { ClaudeProfile } from '../hooks/useIssues'
import type { Issue, IssueColumn } from '../types'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { isIssueLocked } from '../lib/dependencies'
import type { PropertyGroup } from './property-grid'
import { PropertyGrid } from './property-grid'

/** 跨机绝对路径只比 basename（同名 .json）映射到本机 profiles 列表。 */
function matchProfileOption(
  current: string | undefined,
  profiles: ClaudeProfile[],
): string {
  const raw = current?.trim()
  if (!raw)
    return ''
  if (profiles.some(p => p.path === raw))
    return raw
  const base = raw.split(/[/\\]/).pop() ?? ''
  if (!base)
    return raw
  const byFile = profiles.find(p => p.path.split(/[/\\]/).pop() === base)
  if (byFile)
    return byFile.path
  const name = base.endsWith('.json') ? base.slice(0, -'.json'.length) : base
  const byName = profiles.find(p => p.name === name)
  return byName?.path ?? raw
}

interface IssueDetailPanelProps {
  issue: Issue | null
  /** 当前所有 issues，用于解析前置依赖锁定态。 */
  allIssues: Issue[]
  /** 全局 autoReview 当前值；用于工单未显式设置 autoReview 时的回退展示。 */
  globalAutoReview: boolean
  onOpenInBrowser: (url: string) => void
  onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
  /** Open a new terminal that runs `codex resume <id>` for the auto-review
   * conversation associated with this issue. */
  onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
  /** Open a new terminal that runs `claude --resume <id>` for the test
   * conversation associated with this issue. */
  onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
  /** Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the
   * Play button next to the "测试会话 id" row (only shown when `issue.pr` exists). */
  onStartTestSession: (issueNumber: number) => void
  /** Open a workspace-relative file in the editor. */
  onOpenFile: (path: string) => void
  /** Kick off the end-to-end implementation flow for the given plan file. */
  onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
  /** Open the gitea PR page in the browser. */
  onOpenPr: (pr: string) => void
  /** Start a background Claude run that writes the PR diff summary markdown. */
  onGeneratePrDiffSummary: (issueNumber: number) => void
  /** Whether a PR diff summary generation request is in flight for an issue. */
  isPrDiffSummaryRunning: (issueNumber: number) => boolean
  /** Open the workspace-relative worktree path in a new VS Code window. */
  onOpenWorktree: (path: string) => void
  /** Delete the worktree (`git worktree remove`) and clear it from state. */
  onDeleteWorktree: (issueNumber: number, path: string) => void
  /** Pre-merge the issue's feature branch into the main worktree's current branch
   * (`git merge --no-commit --no-ff <branch>`) so the user can inspect locally. */
  onMergeBranch: (issueNumber: number, branch: string) => void
  /** 硬删整个工单 + 关联资源（worktree / PR / feature branch / cc tabs）。
   * 顶部垃圾桶按钮触发，扩展端做 modal confirm，可选。 */
  onDeleteIssue?: (issueNumber: number) => void
  /** 关闭 Gitea 工单，不清理本地会话、worktree、PR 或分支。 */
  onCloseIssue: (issueNumber: number) => void
  /** 将实施中 / 审查中的工单重置回待办（清 worktree / 未合并 PR / feature 分支）。 */
  onResetToTodo: (issueNumber: number) => void
  /** Close the matching session terminal tab. The extension watches
   * onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
   * makes the X button vanish on its own. */
  onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
  /** Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty
   * (created out-of-band via spx without webhook linking). Bound to the Play
   * button next to the "头脑风暴会话 id" row. */
  onStartBrainstormSession: (issueNumber: number) => void
  /** Persist a per-issue `autoReview` override into the state JSON. */
  onUpdateAutoReview: (issueNumber: number, value: boolean) => void
  /** 已知 Claude profile 列表，用于「配置文件」下拉选项。 */
  profiles: ClaudeProfile[]
  /** Persist a per-issue `profilePath` override into the state JSON. */
  onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
  /** Persist a per-issue `brainstormProfilePath` override into the state JSON. */
  onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
  /** Persist a per-issue `testProfilePath` override into the state JSON. */
  onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
  /** Open the in-webview log modal. */
  onOpenLogs: () => void
  /** 当前 Gitea login；undefined = 身份未知（接管按钮不显示）。 */
  me?: string
  onStartHandoff: (issueNumber: number) => void
  onAcceptHandoff: (issueNumber: number) => void
  isHandoffRunning: (issueNumber: number) => boolean
}

/**
 * 底部详情面板：以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
 *
 * 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮；下面是 PropertyGrid
 * 渲染的 state JSON 字段（当前为 column + sessionId）。sessionId 是
 * 一个 action 类型字段，双击或选中卡按 Enter 都会触发
 * `claude --resume <id>` 在新终端中恢复对话。
 */
export function IssueDetailPanel({
  issue,
  allIssues,
  globalAutoReview,
  onOpenInBrowser,
  onResumeSession,
  onResumeReviewSession,
  onResumeTestSession,
  onStartTestSession,
  onOpenFile,
  onImplement,
  onOpenPr,
  onGeneratePrDiffSummary,
  isPrDiffSummaryRunning,
  onOpenWorktree,
  onDeleteWorktree,
  onMergeBranch,
  onDeleteIssue,
  onCloseIssue,
  onResetToTodo,
  onCloseSessionTab,
  onStartBrainstormSession,
  onUpdateAutoReview,
  profiles,
  onUpdateProfilePath,
  onUpdateBrainstormProfilePath,
  onUpdateTestProfilePath,
  onOpenLogs,
  me,
  onStartHandoff,
  onAcceptHandoff,
  isHandoffRunning,
}: IssueDetailPanelProps) {
  // 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮，
  // 但已存在的会话恢复链接保持可用（属于用户已操作过的入口）。
  const { locked, prerequisiteNumber } = issue
    ? isIssueLocked(issue, allIssues)
    : { locked: false, prerequisiteNumber: undefined as number | undefined }
  const lockTitle = locked && prerequisiteNumber != null
    ? `等待 #${prerequisiteNumber} 完成`
    : undefined
  const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false

  // 三个会话 id 行（头脑风暴/实施/审查）共用一个"上次触发时间戳"表，
  // 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal
  // 在前一个 createTerminal 尚未完成时查不到已存在的终端，会重复开 tab。
  // key 用 sessionId 字符串本身（足够区分三种行 + 不同工单）。
  const lastFiredAtRef = useRef<Map<string, number>>(new Map())
  const RESUME_THROTTLE_MS = 800
  const throttleBySessionId = (sid: string): boolean => {
    const now = Date.now()
    const last = lastFiredAtRef.current.get(sid) ?? 0
    if (now - last < RESUME_THROTTLE_MS)
      return false
    lastFiredAtRef.current.set(sid, now)
    return true
  }

  const schema = useMemo<PropertyGroup[]>(
    () => [
      {
        id: 'state',
        label: 'state JSON',
        actions: (
          <button
            type="button"
            onClick={onOpenLogs}
            title="查看扩展日志"
            aria-label="查看扩展日志"
            className="grid size-5 place-items-center rounded opacity-60 hover:opacity-100"
          >
            <Terminal className="size-3.5" />
          </button>
        ),
        properties: [
          {
            key: 'column',
            label: '状态',
            type: 'select',
            readOnly: true,
            description: '工单所在的看板列；写入 issue 最后一条 JSON 评论的 column 字段',
            options: COLUMN_ORDER.map((c: IssueColumn) => ({
              label: `${c}（${COLUMN_LABELS[c]}）`,
              value: c,
            })),
          },
          (() => {
            // 头脑风暴会话 id 行有三种状态：
            //  1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab
            //  2. sessionId 已存在 + brainstormTabOpen=false → 显示 id（无右侧按钮）
            //  3. sessionId 为空 + column !== 'done' → 显示 Play 按钮（启动一个新规划 cc tab）
            //  4. sessionId 为空 + column === 'done' → 占位 —，不显示按钮
            const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
            const canStart = !hasSession && issue?.column !== 'done'
            let secondaryActionIcon: ReactNode | undefined
            let secondaryActionTitle: string | undefined
            let onSecondaryAction: (() => void) | undefined
            if (hasSession && issue?.brainstormTabOpen) {
              secondaryActionIcon = <X className="size-3.5" />
              secondaryActionTitle = '关闭头脑风暴 tab'
              onSecondaryAction = () => {
                if (issue)
                  onCloseSessionTab(issue.number, 'brainstorm')
              }
            }
            else if (canStart) {
              secondaryActionIcon = <Play className="size-3.5" />
              secondaryActionTitle = '启动头脑风暴会话'
              onSecondaryAction = () => {
                if (issue)
                  onStartBrainstormSession(issue.number)
              }
            }
            return {
              key: 'sessionId',
              label: '头脑风暴会话id',
              type: 'action' as const,
              description: hasSession
                ? '双击在新终端运行 claude --resume <id> 恢复对话'
                : '工单尚未关联 cc 会话；点击右侧 ▶ 启动一个新的规划 cc tab',
              actionIcon: <Terminal className="size-3.5" />,
              onAction: (v: unknown) => {
                if (typeof v !== 'string' || v.length === 0)
                  return
                if (!throttleBySessionId(v))
                  return
                onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number)
              },
              secondaryActionIcon,
              secondaryActionTitle,
              onSecondaryAction,
            }
          })(),
          // 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口：
          // extension 端 handleResumeSession 会在 worktree 路径不存在时退回
          // 工作区根目录，并通过 toast 告知用户；description 在两种状态下
          // 给出不同的提示文案。
          {
            key: 'implementSessionId',
            label: '实施会话id',
            type: 'action',
            description: issue?.worktreeExists
              ? '双击在新终端运行 claude --resume <id> 恢复实施对话（cwd 为 worktree）'
              : 'worktree 已清理，将在工作区根目录恢复（cc 可能提示原 cwd 不存在）',
            actionIcon: <Terminal className="size-3.5" />,
            onAction: (v) => {
              if (typeof v !== 'string' || v.length === 0)
                return
              if (!throttleBySessionId(v))
                return
              onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number)
            },
            secondaryActionIcon: issue?.implementTabOpen
              ? <X className="size-3.5" />
              : undefined,
            secondaryActionTitle: '关闭实施 tab',
            onSecondaryAction: () => {
              if (issue)
                onCloseSessionTab(issue.number, 'implement')
            },
          },
          issue?.reviewSessionFileExists
            ? {
                key: 'reviewSessionId',
                label: '审查会话id',
                type: 'action',
                description: '双击在新终端运行 codex resume <id> 查看审查会话（worktree 在则用之，否则工作区根目录）',
                actionIcon: <Terminal className="size-3.5" />,
                onAction: (v) => {
                  if (typeof v !== 'string' || v.length === 0 || !issue)
                    return
                  if (!throttleBySessionId(v))
                    return
                  onResumeReviewSession(v, issue.number, issue?.worktreePath)
                },
                secondaryActionIcon: issue?.reviewTabOpen
                  ? <X className="size-3.5" />
                  : undefined,
                secondaryActionTitle: '关闭审查 tab',
                onSecondaryAction: () => {
                  if (issue)
                    onCloseSessionTab(issue.number, 'review')
                },
              }
            : {
                key: 'reviewSessionId',
                label: '审查会话id',
                type: 'string',
                readOnly: true,
                description: '本机没有该 codex 会话文件，无法 resume；仅保留 id 文本',
              },
          (() => {
            // 测试会话 id 行有三种状态（照搬头脑风暴行的三态）：
            //  1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab
            //  2. testSessionId 已存在 + testTabOpen=false → 显示 id，双击 resume
            //  3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab
            //     （prompt 依赖已合并的 PR 号，没有 PR 不显示启动按钮）
            const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
            const canStart = !hasSession && !!issue?.pr
            let secondaryActionIcon: ReactNode | undefined
            let secondaryActionTitle: string | undefined
            let onSecondaryAction: (() => void) | undefined
            if (hasSession && issue?.testTabOpen) {
              secondaryActionIcon = <X className="size-3.5" />
              secondaryActionTitle = '关闭测试 tab'
              onSecondaryAction = () => {
                if (issue)
                  onCloseSessionTab(issue.number, 'test')
              }
            }
            else if (canStart) {
              secondaryActionIcon = <Play className="size-3.5" />
              secondaryActionTitle = '启动测试会话'
              onSecondaryAction = () => {
                if (issue)
                  onStartTestSession(issue.number)
              }
            }
            return {
              key: 'testSessionId',
              label: '测试会话id',
              type: 'action' as const,
              description: hasSession
                ? '双击在新终端运行 claude --resume <id> 在工作区根（main）恢复测试对话'
                : '工单 PR 合并后点击右侧 ▶ 在工作区根（main）启动一个测试 cc tab（让 cc 了解代码后告诉你怎么测试）',
              actionIcon: <Terminal className="size-3.5" />,
              onAction: (v: unknown) => {
                if (typeof v !== 'string' || v.length === 0 || !issue)
                  return
                if (!throttleBySessionId(v))
                  return
                onResumeTestSession(v, issue.number)
              },
              secondaryActionIcon,
              secondaryActionTitle,
              onSecondaryAction,
            }
          })(),
          {
            key: 'autoReview',
            label: '自动审查',
            type: 'boolean',
            description: `打开新 PR 时是否自动启动 codex 审查；未设置时跟随全局（当前全局：${globalAutoReview ? '开' : '关'}）`,
          },
          {
            key: 'color',
            label: '颜色',
            type: 'theme-color',
            readOnly: true,
            description: '首次开会话时随机分配的终端颜色（VS Code ThemeColor），用于该工单所有会话的终端 tab 着色',
          },
          {
            key: 'brainstormProfilePath',
            label: '头脑风暴配置文件',
            type: 'select',
            options: (() => {
              // 占位项：value='' 对应「未设置」。原生 select 在 value 不匹配任何
              // option 时会假显示第一项且 selectedIndex=0，导致点第一项不触发
              // onChange、永远存不进去；占位项让未设置态有真实匹配项。
              // 跨机绝对路径按 basename 对齐本机 profiles，避免「自定义：/Users/...」。
              const opts = [{ label: '（默认）', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
              const current = matchProfileOption(issue?.brainstormProfilePath, profiles)
              if (current && !opts.some(o => o.value === current))
                opts.push({ label: `自定义：${current}`, value: current })
              return opts
            })(),
            description: '头脑风暴会话使用的 Claude 配置文件；选「（默认）」用内置默认，与实施/测试互不回退',
          },
          {
            key: 'profilePath',
            label: '实施配置文件',
            type: 'select',
            options: (() => {
              const opts = [{ label: '（默认）', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
              const current = matchProfileOption(issue?.profilePath, profiles)
              if (current && !opts.some(o => o.value === current))
                opts.push({ label: `自定义：${current}`, value: current })
              return opts
            })(),
            description: '实施会话使用的 Claude 配置文件；选「（默认）」用内置默认，与头脑风暴/测试互不回退',
          },
          {
            key: 'testProfilePath',
            label: '测试配置文件',
            type: 'select',
            options: (() => {
              const opts = [{ label: '（默认）', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
              const current = matchProfileOption(issue?.testProfilePath, profiles)
              if (current && !opts.some(o => o.value === current))
                opts.push({ label: `自定义：${current}`, value: current })
              return opts
            })(),
            description: '测试会话使用的 Claude 配置文件；选「（默认）」用内置默认，与头脑风暴/实施互不回退',
          },
          {
            key: 'specFile',
            label: '规格文件',
            type: 'file-link',
            description: '双击文件名在编辑器打开；cc 通过 issue body 的 <!-- spx:spec=路径 --> 注释实时同步',
            onOpen: (p: string) => onOpenFile(p),
          },
          {
            key: 'planFile',
            label: '计划文件',
            type: 'file-link',
            description: '双击文件名在编辑器打开；点击实施按钮启动实施流程；cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
            onOpen: (p: string) => onOpenFile(p),
            // Hide the "实施" button entirely when there's no plan file.
            secondaryActionIcon: issue?.planFile
              ? <Play className="size-3.5" />
              : undefined,
            secondaryActionTitle: locked ? lockTitle : '实施此计划',
            // Disable while running or already done. The user can still click
            // when status === 'failed' or never been run. Re-clicking while
            // 'running' is blocked at the UI to avoid registering duplicate
            // webhooks. To retry after a perceived failure with status still
            // 'running', edit the state-JSON comment manually for now.
            // 锁定态（前置工单未完成）同样禁止启动实施。
            secondaryDisabled:
              !issue?.planFile
              || issue?.implementStatus === 'running'
              || issue?.implementStatus === 'done'
              || locked,
            onSecondaryAction: () => {
              if (!issue?.planFile)
                return
              onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId)
            },
          },
          {
            key: 'pr',
            label: '合并请求',
            type: 'pr-link',
            description: '双击在浏览器打开关联的 PR；点击右侧按钮生成 PR 变更摘要；已合并时编号后追加 "(已合并)" 标识',
            onAction: (v) => {
              if (typeof v !== 'string' || v.length === 0)
                return
              // value 形如 "65" 或 "65(已合并)"；剥掉合并后缀只把纯编号传出去。
              const pr = v.replace(/\(已合并\)\s*$/, '')
              if (pr.length > 0)
                onOpenPr(pr)
            },
            secondaryActionIcon: issue?.pr && !issue?.prDiffFile
              ? <Play className="size-3.5" />
              : undefined,
            secondaryActionTitle: prDiffSummaryRunning
              ? 'PR 变更摘要生成中'
              : '生成 PR 变更摘要',
            secondaryDisabled: prDiffSummaryRunning,
            onSecondaryAction: () => {
              if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning)
                onGeneratePrDiffSummary(issue.number)
            },
          },
          {
            key: 'prDiffFile',
            label: 'PR 变更摘要',
            type: 'file-link',
            description: '双击文件名在编辑器打开生成的 PR 代码变更摘要',
            onOpen: (p: string) => onOpenFile(p),
          },
          {
            key: 'branch',
            label: '分支',
            type: 'string',
            readOnly: true,
            description: '实施流程创建的分支名',
            secondaryActionIcon: issue?.branch ? <GitMerge className="size-3.5" /> : undefined,
            secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)',
            onSecondaryAction: () => {
              if (issue?.branch)
                onMergeBranch(issue.number, issue.branch)
            },
          },
          issue?.worktreeExists
            ? {
                key: 'worktreePath',
                label: '工作树',
                type: 'file-link',
                description: '双击文件名在新 VS Code 窗口打开；点击垃圾桶删除 worktree',
                onOpen: (p: string) => onOpenWorktree(p),
                onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
                secondaryActionIcon: <Trash2 className="size-3.5" />,
                secondaryActionTitle: '删除 worktree',
              }
            : {
                key: 'worktreePath',
                label: '工作树',
                type: 'string',
                readOnly: true,
                description: '实施流程创建的 git worktree 路径（workspace 相对）',
              },
        ],
      },
    ],
    [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
  )

  const data = useMemo<Record<string, unknown> | null>(() => {
    if (!issue)
      return null
    return {
      column: issue.column,
      sessionId: issue.sessionId ?? null,
      implementSessionId: issue.implementSessionId ?? null,
      reviewSessionId: issue.reviewSessionId ?? null,
      testSessionId: issue.testSessionId ?? null,
      autoReview: issue.autoReview ?? globalAutoReview,
      color: issue.color ?? null,
      // 展示/选中值按 basename 映射到本机 profiles，避免跨机绝对路径变成「自定义」项。
      brainstormProfilePath: matchProfileOption(issue.brainstormProfilePath, profiles) || null,
      profilePath: matchProfileOption(issue.profilePath, profiles) || null,
      testProfilePath: matchProfileOption(issue.testProfilePath, profiles) || null,
      specFile: issue.specFile ?? null,
      planFile: issue.planFile ?? null,
      prDiffFile: issue.prDiffFile ?? null,
      pr: issue.pr
        ? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}`
        : null,
      branch: issue.branch ?? null,
      worktreePath: issue.worktreePath ?? null,
    }
  }, [
    issue,
    globalAutoReview,
    profiles,
  ])

  if (!issue || !data) {
    return (
      <div className="flex h-full items-center justify-center border-t border-[var(--vscode-panel-border)] p-3 text-xs opacity-60">
        按方向键或点击卡片查看详情
      </div>
    )
  }

  return (
    <div className="flex h-full min-h-0 flex-col gap-3 border-t border-[var(--vscode-panel-border)] p-3">
      <div className="flex items-center gap-3">
        <h3 className="flex-1 truncate text-sm font-medium">
          <span className="font-mono opacity-60">
            {issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
          </span>
          {issue.source === 'youtrack' && (
            <span
              className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
              title="此工单来自 YouTrack"
            >
              YouTrack
            </span>
          )}
          {' '}
          {issue.title}
          {locked && prerequisiteNumber != null
            ? (
                <span
                  className="ml-2 inline-flex items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 align-middle text-[10px] font-normal text-[var(--vscode-disabledForeground)]"
                  title={lockTitle}
                >
                  等待 #
                  {prerequisiteNumber}
                  {' '}
                  完成
                </span>
              )
            : null}
        </h3>
        <button
          type="button"
          onClick={() => onOpenInBrowser(issue.htmlUrl)}
          className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
        >
          <ExternalLink className="size-3.5" />
          {issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
        </button>
        {/* Close/delete write to gitea by number — hide for youtrack cards to
            avoid acting on a gitea issue of the same number. */}
        {issue.source !== 'youtrack' && (
          <>
            {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
              <button
                type="button"
                disabled={isHandoffRunning(issue.number)}
                onClick={() => onAcceptHandoff(issue.number)}
                title={`接管（${issue.handoffFrom ?? '?'} 移交）`}
                aria-label="接管"
                className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
              >
                {isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
                接管
              </button>
            )}
            {!issue.handoffAttachmentId && issue.column !== 'done' && (
              <button
                type="button"
                disabled={isHandoffRunning(issue.number)}
                onClick={() => onStartHandoff(issue.number)}
                title="移交给同事"
                aria-label="移交"
                className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
              >
                {isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
              </button>
            )}
            {issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
              <span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70" title={`${issue.handoffFrom ?? '?'} 已移交，等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
                待接管
              </span>
            )}
            {(issue.column === 'in-progress' || issue.column === 'review') && (
              <button
                type="button"
                onClick={() => onResetToTodo(issue.number)}
                title="重置为待办"
                aria-label="重置为待办"
                className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
              >
                <RotateCcw className="size-3.5" />
              </button>
            )}
            <button
              type="button"
              onClick={() => onCloseIssue(issue.number)}
              title="关闭工单"
              aria-label="关闭工单"
              className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
            >
              <CircleSlash className="size-3.5" />
            </button>
            <button
              type="button"
              onClick={() => issue && onDeleteIssue?.(issue.number)}
              title="删除工单（不可撤销）"
              aria-label="删除工单"
              className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
            >
              <Trash2 className="size-3.5" />
            </button>
          </>
        )}
      </div>

      {issue.source === 'youtrack' && issue.attachments && issue.attachments.length > 0 && (
        <div className="flex flex-col gap-1 text-xs">
          <span className="opacity-60">附件（{issue.attachments.length}）</span>
          <div className="flex flex-wrap gap-2">
            {issue.attachments.map(att => (
              <button
                key={att.url}
                type="button"
                onClick={() => onOpenInBrowser(att.url)}
                title={att.name}
                className="inline-flex max-w-[200px] items-center gap-1 truncate rounded border border-[var(--vscode-panel-border)] px-2 py-1 hover:bg-black/10 dark:hover:bg-white/10"
              >
                <ExternalLink className="size-3 shrink-0" />
                <span className="truncate">{att.name}</span>
              </button>
            ))}
          </div>
        </div>
      )}

      <div className="min-h-0 flex-1">
        <PropertyGrid
          key={issue.number}
          schema={schema}
          data={data}
          hideToolbar
          fillHeight
          onChange={(key, value) => {
            if (key === 'autoReview' && issue && typeof value === 'boolean')
              onUpdateAutoReview(issue.number, value)
            if (key === 'brainstormProfilePath' && issue && typeof value === 'string')
              onUpdateBrainstormProfilePath(issue.number, value)
            if (key === 'profilePath' && issue && typeof value === 'string')
              onUpdateProfilePath(issue.number, value)
            if (key === 'testProfilePath' && issue && typeof value === 'string')
              onUpdateTestProfilePath(issue.number, value)
          }}
        />
      </div>
    </div>
  )
}
