import { AnimatePresence, motion } from 'framer-motion'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { ChevronRight } from 'lucide-react'
import { useLocation, useNavigate } from 'react-router-dom'

import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PrefetchLink from '@/components/PrefetchLink'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPost, isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { parsePostNewReviewUrls } from '@/lib/postNewQueryState'
import { validateImportSource } from '@/lib/postImportSourceValidation'
import {
    applyThumbnailWarning,
    buildNextEditedRow,
    canEditReviewRow,
    canRetryResultRow,
    compactMessageRecord,
    hasErrorMessages,
    hasThumbnailBaseValue,
    isCompletedReviewRow,
    isExistingSkipRow,
    isManualSkipRow,
    isNonRecoverableFailedRow,
    mergeImportResults,
    processableImportRows,
    replaceImportRow,
    resultRepairMode,
    resultRowMessages,
    reviewSummaryCounts,
    retryImportRow,
} from '@/lib/postImportRows'
import { clearPostImportSourceDraft } from '@/lib/postImportStorage'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils'
import { canEditContent } from '@/lib/users'
import Forbidden from '@/pages/Forbidden'

import type { FC } from 'react'

import type {
    PostImportDisplayTag,
    PostImportEditableDraft,
    PostImportResetSnapshot,
    PostImportResultRow,
    PostImportRow,
} from '@/lib/postImportTypes'
import type { User } from '@/types'

type Props = { user: User | null }

type PostMetadataResponse = {
  url:                    string
  title?:                 string
  thumbnailBase?:         string
  originalCreatedFrom?:   string
  originalCreatedBefore?: string
  parentPostIds?:         string
  duration?:              string
  videoMs?:               number
  tags?:                  string
  displayTags?:           PostImportDisplayTag[]
  fieldWarnings?:         Record<string, string[]>
  baseWarnings?:          string[]
  validationErrors?:      Record<string, string[]>
  existingPostId?:        number
  existingPost?:          {
    id:            number
    title:         string
    url:           string
    thumbnail?:    string | null
    thumbnailBase?: string | null } | null }

type BulkApiRow = {
  status:         'created' | 'skipped' | 'failed'
  post?:          { id: number }
  existingPostId?: number
  existingPost?:  {
    id:            number
    title:         string
    url:           string
    thumbnail?:    string | null
    thumbnailBase?: string | null } | null
  fieldWarnings?: Record<string, string[]>
  baseWarnings?:  string[]
  errors?:        Record<string, string[]>
  baseErrors?:    string[]
  recoverable?:   boolean }

type ExistingSkippedRowsProps = {
  id:   string
  rows: PostImportRow[] }

const DUPLICATE_URL_MESSAGE = 'URL が重複しています．'
const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました．'

const emptyAttributes = () => ({
    title: '',
    thumbnailBase: '',
    originalCreatedFrom: '',
    originalCreatedBefore: '',
    duration: '',
    tags: '',
    parentPostIds: '' })


const emptyProvenance = () => ({
    url: 'manual',
    title: 'automatic',
    thumbnailBase: 'automatic',
    originalCreatedFrom: 'automatic',
    originalCreatedBefore: 'automatic',
    duration: 'automatic',
    tags: 'automatic',
    parentPostIds: 'automatic' }) as const


const emptyTagSources = () => ({
    automatic: '',
    manual: '' })


const cloneDisplayTags = (
    tags: PostImportDisplayTag[] | undefined,
): PostImportDisplayTag[] =>
    tags?.map (tag => ({
	name: tag.name,
	category: tag.category,
	sectionLiterals:
	    tag.sectionLiterals == null
	    ? undefined
	    : [...tag.sectionLiterals] })) ?? []


const cloneMessageRecord = (
    record: Record<string, string[]>,
): Record<string, string[]> =>
    Object.fromEntries (
	Object.entries (record).map (([key, messages]) => [key, [...messages]]))


const buildEmptyResetSnapshot = (url: string): PostImportResetSnapshot => ({
    url,
    attributes: emptyAttributes (),
    displayTags: [],
    provenance: emptyProvenance (),
    tagSources: emptyTagSources (),
    fieldWarnings: { },
    baseWarnings: [] })


const buildInitialRows = (urls: string[]): PostImportRow[] => {
  const source = urls.join ('\n')
  const issues = validateImportSource (source)
  const issuesByRow = issues.reduce<Record<number, string[]>> ((result, issue) => {
    result[issue.sourceRow] = [...(result[issue.sourceRow] ?? []), issue.message]
    return result
  }, { })

  return applyDuplicateUrlErrors (
      urls.map ((url, index) => {
        const sourceRow = index + 1
        const rowIssues = issuesByRow[sourceRow] ?? []
        const validationErrors: Record<string, string[]> =
            rowIssues.length > 0
            ? { url: [...rowIssues] }
            : { }
        return {
            sourceRow,
            url,
            attributes: emptyAttributes (),
            fieldWarnings: { },
            baseWarnings: [],
            validationErrors,
            provenance: emptyProvenance (),
            tagSources: emptyTagSources (),
            status: rowIssues.length > 0 ? 'error' : 'pending',
            displayTags: [],
            resetSnapshot: buildEmptyResetSnapshot (url) }
      }))
}


const rowMetadataPending = (row: PostImportRow): boolean =>
    row.status === 'pending'


const rowOperationBusy = (
    row:         PostImportRow,
    submitting:  boolean,
    busyRowIds:  Set<number>,
): boolean =>
    submitting
    || busyRowIds.has (row.sourceRow)
    || rowMetadataPending (row)


const rowSkipBusy = (
    row:         PostImportRow,
    submitting:  boolean,
    busyRowIds:  Set<number>,
): boolean =>
    submitting
    || busyRowIds.has (row.sourceRow)
    || (rowMetadataPending (row) && !(isManualSkipRow (row)))


const shouldFetchMetadata = (row: PostImportRow): boolean =>
    row.status === 'pending'
    && row.skipReason !== 'manual'
    && row.importStatus !== 'created'
    && row.importStatus !== 'skipped'
    && !(isNonRecoverableFailedRow (row))
    && row.metadataUrl == null


const shouldHydrateExistingPost = (row: PostImportRow): boolean =>
    row.skipReason === 'existing'
    && row.existingPostId != null
    && row.existingPost == null
    && row.importStatus !== 'created'
    && row.importStatus !== 'skipped'


const shouldFetchReviewRow = (row: PostImportRow): boolean =>
    shouldFetchMetadata (row) || shouldHydrateExistingPost (row)


const applyDuplicateUrlErrors = (
    rows: PostImportRow[],
): PostImportRow[] => {
  const counts = rows.reduce<Record<string, number>> ((result, row) => {
    if (row.url !== ''
	&& !(isManualSkipRow (row))
	&& row.importStatus !== 'created'
	&& row.importStatus !== 'skipped')
      result[row.url] = (result[row.url] ?? 0) + 1
    return result
  }, { })

  return rows.map (row => {
    const urlErrors = (row.validationErrors.url ?? []).filter (
	message => message !== DUPLICATE_URL_MESSAGE)
    if ((counts[row.url] ?? 0) < 2)
      {
        const nextValidationErrors = compactMessageRecord ({
	    ...row.validationErrors,
	    url: urlErrors })
        const hasErrors = hasErrorMessages (nextValidationErrors)
	return urlErrors.length === (row.validationErrors.url ?? []).length
	    ? row
	    : {
		...row,
		validationErrors: nextValidationErrors,
		skipReason:
		    row.skipReason === 'manual'
		    ? 'manual'
		    : row.existingPostId != null
		      ? 'existing'
		      : undefined,
		status: hasErrors ? 'error' : row.status }
      }

    return {
	...row,
	skipReason: row.skipReason === 'manual' ? 'manual' : undefined,
	validationErrors: compactMessageRecord ({
	    ...row.validationErrors,
	    url: [...new Set ([...urlErrors, DUPLICATE_URL_MESSAGE])] }),
	status: 'error' }
  })
}


const buildDryRunFormData = (row: PostImportRow): FormData => {
  const formData = new FormData ()
  formData.append ('url', row.url)
  formData.append ('title', String (row.attributes.title ?? ''))
  formData.append ('thumbnail_base', String (row.attributes.thumbnailBase ?? ''))
  formData.append ('tags', String (row.attributes.tags ?? ''))
  formData.append ('parent_post_ids', String (row.attributes.parentPostIds ?? ''))
  formData.append (
      'original_created_from',
      String (row.attributes.originalCreatedFrom ?? ''))
  formData.append (
      'original_created_before',
      String (row.attributes.originalCreatedBefore ?? ''))
  formData.append ('duration', String (row.attributes.duration ?? ''))
  if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
    formData.append ('thumbnail', row.thumbnailFile)
  return formData
}


const mergeDryRunRow = (
    currentRow: PostImportRow,
    result:     PostMetadataResponse,
): PostImportRow => {
  const fieldWarnings = compactMessageRecord (result.fieldWarnings ?? { })
  const hasWarnings =
      Object.values (fieldWarnings).some (messages => messages.length > 0)
      || (result.baseWarnings?.length ?? 0) > 0

  return applyThumbnailWarning ({
      ...currentRow,
      url: result.url,
      attributes: {
	  ...currentRow.attributes,
	  title: result.title ?? '',
	  thumbnailBase: result.thumbnailBase ?? '',
	  originalCreatedFrom: result.originalCreatedFrom ?? '',
	  originalCreatedBefore: result.originalCreatedBefore ?? '',
	  duration: result.duration ?? '',
	  tags: result.tags ?? '',
	  parentPostIds: String (
	      result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
      displayTags: cloneDisplayTags (result.displayTags),
      fieldWarnings,
      baseWarnings: result.baseWarnings ?? [],
      validationErrors: { },
      importErrors: undefined,
      status: hasWarnings ? 'warning' : 'ready',
      skipReason:
	  result.existingPostId != null || result.existingPost?.id != null
	  ? 'existing'
	  : undefined,
      existingPostId: result.existingPostId ?? result.existingPost?.id,
      existingPost: result.existingPost ?? undefined,
      importStatus:
	  currentRow.importStatus === 'created'
	  ? 'created'
	  : 'pending',
      recoverable: undefined })
}


const buildPreviewResetSnapshot = (
    preview: PostMetadataResponse,
): PostImportRow['resetSnapshot'] => ({
    url: preview.url,
    attributes: {
	title: preview.title ?? '',
	thumbnailBase: preview.thumbnailBase ?? '',
	originalCreatedFrom: preview.originalCreatedFrom ?? '',
	originalCreatedBefore: preview.originalCreatedBefore ?? '',
	duration: preview.duration ?? '',
	tags: preview.tags ?? '',
	parentPostIds: String (preview.parentPostIds ?? '') },
    displayTags: cloneDisplayTags (preview.displayTags),
    provenance: emptyProvenance (),
    tagSources: {
	automatic: preview.tags ?? '',
	manual: '' },
    fieldWarnings: preview.fieldWarnings ?? { },
    baseWarnings: preview.baseWarnings ?? [],
    metadataUrl: preview.url })


const indexedBulkResults = (
    rows:     PostImportRow[],
    results:  BulkApiRow[],
): PostImportResultRow[] =>
    rows.map ((row, index) => {
      const result = results[index]
      const errors =
	  result?.baseErrors?.length
	  ? {
	      ...(result.errors ?? { }),
	      base: [...new Set ([...(result.errors?.base ?? []), ...result.baseErrors])] }
	  : result?.errors
      if (result?.status === 'created' && result.post != null)
        {
          return {
              sourceRow: row.sourceRow,
              status: 'created',
              post: result.post,
              fieldWarnings: result.fieldWarnings,
              baseWarnings: result.baseWarnings,
              errors }
        }
      if (result?.status === 'skipped')
        {
          const existingPostId =
              result.existingPostId
              ?? result.existingPost?.id
              ?? row.existingPostId
          if (existingPostId == null)
            {
              return {
                  sourceRow: row.sourceRow,
                  status: 'failed',
                  fieldWarnings: result.fieldWarnings,
                  baseWarnings: result.baseWarnings,
                  errors: {
                      ...(errors ?? { }),
                      base: [...new Set ([...(errors?.base ?? []),
					  BULK_PROCESSING_FAILED_MESSAGE])] },
                  recoverable: false }
            }
          return {
              sourceRow: row.sourceRow,
              status: 'skipped',
              existingPostId,
              existingPost: result.existingPost ?? row.existingPost,
              fieldWarnings: result.fieldWarnings,
              baseWarnings: result.baseWarnings,
              errors }
        }
      return {
          sourceRow: row.sourceRow,
          status: 'failed',
          fieldWarnings: result?.fieldWarnings,
          baseWarnings: result?.baseWarnings,
          errors,
          recoverable: result?.recoverable }
    })


const mergePreviewRow = (
    currentRow: PostImportRow,
    preview:    PostMetadataResponse,
): PostImportRow => {
  const fieldWarnings = compactMessageRecord (preview.fieldWarnings ?? { })
  const baseWarnings = preview.baseWarnings ?? []
  const validationErrors = compactMessageRecord (preview.validationErrors ?? { })
  const metadataChanged = currentRow.metadataUrl !== preview.url
  const hasWarnings =
      Object.values (fieldWarnings).some (messages => messages.length > 0)
      || baseWarnings.length > 0
  const hasErrors =
      Object.values (validationErrors).some (messages => messages.length > 0)
  const nextRow: PostImportRow = {
      ...currentRow,
      attributes: { ...currentRow.attributes },
      provenance: { ...currentRow.provenance },
      displayTags: cloneDisplayTags (currentRow.displayTags),
      tagSources: {
	  automatic: currentRow.tagSources?.automatic ?? '',
	  manual: currentRow.tagSources?.manual ?? '' },
      url: preview.url,
      fieldWarnings,
      baseWarnings,
      validationErrors,
      existingPostId: preview.existingPostId ?? preview.existingPost?.id,
      existingPost: preview.existingPost ?? undefined,
      skipReason:
	  preview.existingPostId != null || preview.existingPost?.id != null
	  ? 'existing'
	  : currentRow.skipReason === 'manual'
	    ? 'manual'
	    : undefined,
      metadataUrl: preview.url,
      resetSnapshot:
	  metadataChanged
	  ? buildPreviewResetSnapshot (preview)
	  : currentRow.resetSnapshot,
      status:
	  hasErrors
	  ? 'error'
	  : hasWarnings
	    ? 'warning'
	    : 'ready' }

  if (currentRow.provenance.title !== 'manual')
    nextRow.attributes.title = preview.title ?? ''
  if (currentRow.provenance.thumbnailBase !== 'manual')
    nextRow.attributes.thumbnailBase = preview.thumbnailBase ?? ''
  if (currentRow.provenance.originalCreatedFrom !== 'manual')
    nextRow.attributes.originalCreatedFrom = preview.originalCreatedFrom ?? ''
  if (currentRow.provenance.originalCreatedBefore !== 'manual')
    nextRow.attributes.originalCreatedBefore = preview.originalCreatedBefore ?? ''
  if (currentRow.provenance.tags !== 'manual')
    {
      nextRow.attributes.tags = preview.tags ?? ''
      nextRow.displayTags = cloneDisplayTags (preview.displayTags)
    }
  nextRow.tagSources!.automatic = preview.tags ?? ''
  if (currentRow.provenance.parentPostIds !== 'manual')
    nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
  if (currentRow.provenance.duration !== 'manual')
    nextRow.attributes.duration = preview.duration ?? ''

  return applyThumbnailWarning (nextRow)
}


const mergeExistingSkippedRow = (
    currentRow: PostImportRow,
    preview:    PostMetadataResponse,
): PostImportRow =>
    preview.existingPostId != null || preview.existingPost?.id != null
    ? {
	...currentRow,
	url: preview.url,
	existingPostId: preview.existingPostId ?? preview.existingPost?.id,
	existingPost: preview.existingPost ?? currentRow.existingPost,
	skipReason: 'existing',
	metadataUrl: preview.url }
    : currentRow


const buildBulkFormData = (rows: PostImportRow[]): FormData => {
  const formData = new FormData ()
  formData.append (
      'posts',
      JSON.stringify (
	  rows.map (row => ({
	      url: row.url,
	      title: row.attributes.title ?? '',
	      thumbnail_base: row.attributes.thumbnailBase ?? '',
	      tags: row.attributes.tags ?? '',
	      parent_post_ids: row.attributes.parentPostIds ?? '',
	      original_created_from: row.attributes.originalCreatedFrom ?? '',
	      original_created_before: row.attributes.originalCreatedBefore ?? '',
	      duration: row.attributes.duration ?? '' }))))
  rows.forEach ((row, index) => {
    if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
      formData.append (`thumbnails[${ index }]`, row.thumbnailFile)
  })
  return formData
}


const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ id, rows }) => {
  return (
      <div id={id} className="mt-3 max-h-72 overflow-y-auto rounded border">
	<div className="divide-y">
	  {rows.map (row => {
	    const post = row.existingPost
	    if (post == null)
	      {
		return (
		    <div key={row.sourceRow} className="flex items-center gap-3 p-3">
		      <PostThumbnailPreview
			  url=""
			  className="h-10 w-10 shrink-0"/>
		      <div className="min-w-0 flex-1">
			<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
			  {row.url}
			</div>
		      </div>
		    </div>)
	      }
	    return (
		<PrefetchLink
		    key={post.id}
		    to={`/posts/${ post.id }`}
		    target="_blank"
		    rel="noopener noreferrer"
		    className="flex items-center gap-3 p-3">
		  <PostThumbnailPreview
		      url={post.thumbnail || post.thumbnailBase || ''}
		      className="h-10 w-10 shrink-0"/>
		  <div className="min-w-0 flex-1">
		    <div className="truncate text-sm font-medium">
		      {post.title}
		    </div>
		    <div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
		      {post.url}
		    </div>
		  </div>
		</PrefetchLink>)
	  })}
	</div>
      </div>)
}


const editableRowDirty = (row: PostImportRow): boolean =>
    row.skipReason === 'manual'
    || row.url !== row.resetSnapshot.url
    || String (row.attributes.title ?? '')
        !== String (row.resetSnapshot.attributes.title ?? '')
    || String (row.attributes.thumbnailBase ?? '')
        !== String (row.resetSnapshot.attributes.thumbnailBase ?? '')
    || String (row.attributes.originalCreatedFrom ?? '')
        !== String (row.resetSnapshot.attributes.originalCreatedFrom ?? '')
    || String (row.attributes.originalCreatedBefore ?? '')
        !== String (row.resetSnapshot.attributes.originalCreatedBefore ?? '')
    || String (row.attributes.duration ?? '')
        !== String (row.resetSnapshot.attributes.duration ?? '')
    || String (row.attributes.tags ?? '')
        !== String (row.resetSnapshot.attributes.tags ?? '')
    || String (row.attributes.parentPostIds ?? '')
        !== String (row.resetSnapshot.attributes.parentPostIds ?? '')
    || row.thumbnailFile != null


const PostImportReviewPage: FC<Props> = ({ user }) => {
  const editable = canEditContent (user)
  const dialogue = useDialogue ()
  const location = useLocation ()
  const navigate = useNavigate ()
  const behaviourSettings = useClientBehaviourSettings ()
  const animationMode = behaviourSettings.animation ?? 'normal'
  const existingRowsTransition =
      animationMode === 'off'
      ? { duration: 0 }
      : animationMode === 'reduced'
	? { duration: .08, ease: 'linear' as const }
	: { duration: .2, ease: 'easeOut' as const }

  const [rows, setRows] = useState<PostImportRow[] | null> (null)
  const [metadataLoading, setMetadataLoading] = useState (false)
  const [submitting, setSubmitting] = useState (false)
  const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
  const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
  const [showExistingRows, setShowExistingRows] = useState (false)
  const rowsRef = useRef<PostImportRow[]> ([])
  const metadataSequenceRef = useRef (0)
  const discardChanges = useCallback (() => undefined, [])

  const dirty = useMemo (
      () => rows?.some (row => editableRowDirty (row)) ?? false,
      [rows],
  )
  const { allowNextNavigation } = useUnsavedChangesGuard ({
      dirty,
      onDiscard: discardChanges,
  })

  const commitRows = useCallback ((nextRows: PostImportRow[]) => {
    rowsRef.current = nextRows
    setRows (nextRows)
    return nextRows
  }, [])

  const updateRows = useCallback ((
      updater: (currentRows: PostImportRow[]) => PostImportRow[],
  ) => {
    const nextRows = applyDuplicateUrlErrors (updater (rowsRef.current))
    return commitRows (nextRows)
  }, [commitRows])

  const currentRowBySource = useCallback ((
      sourceRow: number,
  ): PostImportRow | null =>
      rowsRef.current.find (row => row.sourceRow === sourceRow) ?? null,
  [])

  const finishImport = useCallback (() => {
    allowNextNavigation ()
    clearPostImportSourceDraft (message =>
	toast ({ title: '入力内容を削除できませんでした', description: message }))
    navigate ('/posts')
  }, [allowNextNavigation, navigate])

  useEffect (() => {
    const urls = parsePostNewReviewUrls (location.search)
    const initialRows = buildInitialRows (urls)
    commitRows (initialRows)

    let active = true
    const sequence = ++metadataSequenceRef.current
    const controllers = new Set<AbortController> ()
    let nextIndex = 0
    const targetSourceRows = initialRows
        .filter (row => shouldFetchReviewRow (row))
        .map (row => row.sourceRow)

    setMetadataLoading (targetSourceRows.length > 0)

    const mergeCurrentRow = (
	sourceRow: number,
	updater:   (row: PostImportRow) => PostImportRow,
    ) => {
      updateRows (currentRows =>
	  currentRows.map (row =>
	      row.sourceRow === sourceRow
	      ? updater (row)
	      : row))
    }

    const worker = async () => {
      while (active)
	{
	  const sourceRow = targetSourceRows[nextIndex]
	  ++nextIndex
	  if (sourceRow == null)
	    return

	  const currentRow = currentRowBySource (sourceRow)
	  if (currentRow == null || !(shouldFetchReviewRow (currentRow)))
	    continue

	  const controller = new AbortController ()
	  controllers.add (controller)

	  try
	  {
	    const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
		params: { url: currentRow.url },
		signal: controller.signal })
	    if (!(active) || metadataSequenceRef.current !== sequence)
	      return

	    mergeCurrentRow (sourceRow, row =>
		row.importStatus === 'created'
		|| row.importStatus === 'skipped'
		|| isNonRecoverableFailedRow (row)
		|| row.skipReason === 'manual'
		? row
		: (shouldFetchMetadata (row)
		   ? mergePreviewRow (row, preview)
		   : mergeExistingSkippedRow (row, preview)))
	  }
	  catch (requestError)
	  {
	    if (controller.signal.aborted)
	      return
	    if (!(active) || metadataSequenceRef.current !== sequence)
	      return

	    if (!(isApiError<{
		    errors?:     Record<string, string[]>
		    baseErrors?: string[]
		}> (requestError)))
	      {
		mergeCurrentRow (sourceRow, row => ({
		    ...row,
		    status: 'error' }))
		continue
	      }

	    if (requestError.response?.status === 422)
	      {
		const rowErrors = compactMessageRecord ({
		    ...(requestError.response.data.errors ?? { }),
		    ...(requestError.response.data.baseErrors?.length
			? {
			    base: requestError.response.data.baseErrors }
			: { }) })
		mergeCurrentRow (sourceRow, row => ({
		    ...row,
		    validationErrors: rowErrors,
		    status: 'error' }))
	      }
	    else
	      {
		mergeCurrentRow (sourceRow, row => ({
		    ...row,
		    status: 'error' }))
	      }
	  }
	  finally
	  {
	    controllers.delete (controller)
	  }
	}
    }

    void Promise.all (
	Array.from (
	    { length: Math.min (4, targetSourceRows.length) },
	    () => worker (),
	)).finally (() => {
	  if (active && metadataSequenceRef.current === sequence)
	    setMetadataLoading (false)
	})

    return () => {
      active = false
      controllers.forEach (controller => controller.abort ())
    }
  }, [commitRows, currentRowBySource, location.search, updateRows])

  useEffect (() => {
    if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed')
      return

    const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
    element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
  }, [editingRow, rows])

  const reviewRowsState = rows ?? []
  const counts = useMemo (
      () => reviewSummaryCounts (reviewRowsState),
      [reviewRowsState],
  )
  const sortedRows =
      resultRepairMode (reviewRowsState) === 'failed'
      ? [...reviewRowsState].sort ((a, b) => {
	  const aRepair =
	      a.recoverable === true
	      && (a.importStatus === 'failed'
		  || (a.importStatus === 'pending'
		      && hasErrorMessages (a.validationErrors)))
	      ? 0
	      : 1
	  const bRepair =
	      b.recoverable === true
	      && (b.importStatus === 'failed'
		  || (b.importStatus === 'pending'
		      && hasErrorMessages (b.validationErrors)))
	      ? 0
	      : 1
	  return aRepair - bRepair || a.sourceRow - b.sourceRow
      })
      : reviewRowsState
  const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
  const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
  const processingRows = processableImportRows (reviewRowsState)
  const canSubmit = processingRows.length > 0

  const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
    const currentRow = currentRowBySource (sourceRow)
    if (currentRow == null)
      return
    if (rowSkipBusy (currentRow, submitting, busyRowIds))
      return

    updateRows (currentRows =>
	currentRows.map (row => {
	  if (row.sourceRow !== sourceRow)
	    return row
	  if (isExistingSkipRow (row) || row.importStatus === 'created')
	    return row
	  return {
	      ...row,
	      skipReason:
		  checked
		  ? 'manual'
		  : row.existingPostId != null
		    ? 'existing'
		    : undefined }
	}))
  }

  const saveDraft = async (
      row: PostImportRow,
      { draft, resetRequested }: {
	  draft:          PostImportEditableDraft
	  resetRequested: boolean },
  ): Promise<{ saved: boolean
	       row:   PostImportRow | null }> => {
    const currentRow = currentRowBySource (row.sourceRow)
    if (currentRow == null)
      return { saved: false, row: null }
    if (!(canEditReviewRow (currentRow)))
      return { saved: false, row: null }

    const baseRow =
	resetRequested
	? {
	    ...currentRow,
	    url: currentRow.resetSnapshot.url,
	    attributes: { ...currentRow.resetSnapshot.attributes },
	    displayTags: cloneDisplayTags (currentRow.resetSnapshot.displayTags),
	    provenance: { ...currentRow.resetSnapshot.provenance },
	    tagSources: { ...currentRow.resetSnapshot.tagSources },
	    fieldWarnings: cloneMessageRecord (currentRow.resetSnapshot.fieldWarnings),
	    baseWarnings: [...currentRow.resetSnapshot.baseWarnings],
	    metadataUrl: currentRow.resetSnapshot.metadataUrl,
	    thumbnailFile: undefined }
	: currentRow
    const urlChanged = draft.url !== baseRow.url
    const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
    let candidateRow = nextRow

    try
    {
      candidateRow =
	  urlChanged
	  ? mergePreviewRow (
	      nextRow,
	      await apiGet<PostMetadataResponse> ('/posts/metadata', {
		  params: { url: nextRow.url } }))
	  : nextRow
      const dryRun = await apiPost<PostMetadataResponse> (
	  '/posts?dry=1',
	  buildDryRunFormData (candidateRow))
      const mergedRow = mergeDryRunRow (candidateRow, dryRun)
      const mergedRows = updateRows (currentRows =>
	  replaceImportRow (currentRows, mergedRow))
      const mergedTarget = mergedRows.find (
	  current => current.sourceRow === baseRow.sourceRow)
      if (mergedTarget == null)
	return { saved: false, row: null }
      if (hasErrorMessages (mergedTarget.validationErrors))
	return { saved: false, row: mergedTarget }
      return { saved: true, row: null }
    }
    catch (requestError)
    {
      if (isApiError<{
	      errors?:     Record<string, string[]>
	      baseErrors?: string[]
	  }> (requestError)
	  && requestError.response?.status === 422)
	{
	  const errorRow = {
	      ...candidateRow,
	      validationErrors: compactMessageRecord ({
		  ...(requestError.response.data.errors ?? { }),
		  ...(requestError.response.data.baseErrors?.length
		      ? {
			  base: requestError.response.data.baseErrors }
		      : { }) }),
	      baseWarnings: [],
	      fieldWarnings: { },
	      status: 'error' as const }
	  return { saved: false, row: applyThumbnailWarning (errorRow) }
	}

      toast ({ title: '行の再検証に失敗しました' })
      return { saved: false, row: null }
    }
  }

  const openEditingDialogue = async (row: PostImportRow) => {
    const saveRowDraft = (
	{ draft, resetRequested }: {
	    draft:          PostImportEditableDraft
	    resetRequested: boolean },
    ) =>
	saveDraft (row, { draft, resetRequested })

    await dialogue.form ({
	title: '投稿を編輯',
	cancelText: '取消',
	size: 'large',
	body: controls => (
	    <PostImportRowForm
		row={row}
		controls={controls}
		onSave={saveRowDraft}/>) })
  }

  const editRow = async (row: PostImportRow) => {
    const currentRow = currentRowBySource (row.sourceRow)
    if (currentRow == null)
      return
    if (!(canEditReviewRow (currentRow)))
      return
    if (rowOperationBusy (currentRow, submitting, busyRowIds))
      return

    setEditingRow (currentRow)
    try
    {
      await openEditingDialogue (currentRow)
    }
    finally
    {
      setEditingRow (current =>
	  current?.sourceRow === currentRow.sourceRow
	  ? null
	  : current)
    }
  }

  const retry = async (sourceRow: number) => {
    const originalRow = currentRowBySource (sourceRow)
    if (originalRow == null)
      return
    if (rowOperationBusy (originalRow, submitting, busyRowIds))
      return

    setBusyRowIds (current => new Set ([...current, sourceRow]))
    try
    {
      updateRows (currentRows => retryImportRow (currentRows, sourceRow))
      const target = currentRowBySource (sourceRow)
      if (target == null)
	return

      const result = await apiPost<{ results: BulkApiRow[] }>(
	  '/posts/bulk',
	  buildBulkFormData ([target]))
      if (result.results.length !== 1)
	{
	  updateRows (currentRows => replaceImportRow (currentRows, originalRow))
	  toast ({ title: '登録結果が不完全でした' })
	  return
	}

      const indexedResults = indexedBulkResults ([target], result.results)
      const nextRows = applyDuplicateUrlErrors (
	  mergeImportResults (rowsRef.current, indexedResults))
	  .map (row => applyThumbnailWarning (row))
      commitRows (nextRows)
      if (nextRows.every (row => isCompletedReviewRow (row)))
	finishImport ()
    }
    catch
    {
      updateRows (currentRows => replaceImportRow (currentRows, originalRow))
      toast ({ title: '再試行に失敗しました' })
    }
    finally
    {
      setBusyRowIds (current => {
	  const next = new Set (current)
	  next.delete (sourceRow)
	  return next
      })
    }
  }

  const submit = async () => {
    if (metadataLoading || submitting || busyRowIds.size > 0)
      return

    const latestProcessingRows = processableImportRows (rowsRef.current)
    if (latestProcessingRows.length === 0)
      return

    setSubmitting (true)
    try
    {
      const result = await apiPost<{ results: BulkApiRow[] }>(
	  '/posts/bulk',
	  buildBulkFormData (latestProcessingRows))
      if (result.results.length !== latestProcessingRows.length)
	{
	  toast ({ title: '登録結果が不完全でした' })
	  return
	}

      const indexedResults = indexedBulkResults (latestProcessingRows, result.results)
      const nextRows = applyDuplicateUrlErrors (
	  mergeImportResults (rowsRef.current, indexedResults))
	  .map (row => applyThumbnailWarning (row))
      commitRows (nextRows)
      if (nextRows.every (row => isCompletedReviewRow (row)))
	finishImport ()
    }
    catch
    {
      toast ({ title: '登録に失敗しました' })
    }
    finally
    {
      setSubmitting (false)
    }
  }

  if (!(editable))
    return <Forbidden/>

  if (rows == null)
    return null

  return (
      <>
	<Helmet>
	  <title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
	</Helmet>

	<MainArea className="pb-40">
	  <div className="mx-auto max-w-6xl space-y-6">
	    <PageTitle>追加内容確認</PageTitle>

	    {existingRows.length > 0 && (
		<div className="rounded-lg border p-4">
		  <button
		      type="button"
		      aria-expanded={showExistingRows}
		      aria-controls="post-import-existing-skips"
		      className="flex items-center gap-2 text-left text-sm font-medium"
		      onClick={() => setShowExistingRows (current => !(current))}>
		    <span className="inline-flex">
		      <ChevronRight
			  className={cn (
			      'h-4 w-4',
			      showExistingRows && 'rotate-90',
			      animationMode === 'off'
			      ? ''
			      : 'transition-transform')}/>
		    </span>
		    既存投稿による自動スキップ {counts.existingSkipped}件
		  </button>
		  <AnimatePresence initial={false}>
		    {showExistingRows && (
			<motion.div
			    initial={
				animationMode === 'off'
				? false
				: { height: 0, opacity: 0 }
			    }
			    animate={{ height: 'auto', opacity: 1 }}
			    exit={{ height: 0, opacity: 0 }}
			    transition={existingRowsTransition}
			    className="overflow-hidden">
			  <ExistingSkippedRows
			      id="post-import-existing-skips"
			      rows={existingRows}/>
			</motion.div>)}
		  </AnimatePresence>
		</div>)}

	    <div className="space-y-3">
	      {reviewRows.map ((row, index) => (
		  <div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
		    <PostImportRowSummary
			row={row}
			displayNumber={index + 1}
			showSkipToggle={true}
			editDisabled={rowOperationBusy (row, submitting, busyRowIds)}
			retryDisabled={rowOperationBusy (row, submitting, busyRowIds)}
			skipDisabled={
			    rowSkipBusy (row, submitting, busyRowIds)
			    || isExistingSkipRow (row)
			    || row.importStatus === 'created'}
			onEdit={() => editRow (row)}
			onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
			onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined}
			rowMessages={resultRowMessages (row)}/>
		  </div>))}
	    </div>
	  </div>
	</MainArea>

	<PostImportFooter
	    metadataLoading={metadataLoading}
	    submitting={submitting}
	    busyRowsPresent={busyRowIds.size > 0}
	    canSubmit={canSubmit}
	    creatableCount={counts.creatable}
	    manualSkippedCount={counts.manualSkipped}
	    existingSkippedCount={counts.existingSkipped}
	    pendingOrErrorCount={counts.pendingOrError}
	    onBack={() => navigate ('/posts/new')}
	    onSubmit={() => submit ()}/>
      </>)
}


const PostImportFooter = (
    { metadataLoading,
      submitting,
      busyRowsPresent,
      canSubmit,
      creatableCount,
      manualSkippedCount,
      existingSkippedCount,
      pendingOrErrorCount,
      onBack,
      onSubmit }: { metadataLoading:      boolean
		    submitting:           boolean
		    busyRowsPresent:      boolean
		    canSubmit:            boolean
		    creatableCount:       number
		    manualSkippedCount:   number
		    existingSkippedCount: number
		    pendingOrErrorCount:  number
		    onBack:               () => void
		    onSubmit:             () => void },
) => (
    <div
	className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
		   dark:border-neutral-700 dark:bg-neutral-950/95">
      <div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
		      md:items-center md:justify-between">
	<div className="flex flex-wrap items-center gap-2 text-sm">
	  <span>作成対象 {creatableCount}件</span>
	  <span>手動スキップ {manualSkippedCount}件</span>
	  <span>既存投稿による自動スキップ {existingSkippedCount}件</span>
	  <span>未処理 {pendingOrErrorCount}件</span>
	</div>
	<div className="flex flex-col gap-2 md:flex-row">
	  <Button
	      type="button"
	      variant="outline"
	      className="w-full md:w-auto"
	      onClick={onBack}
	      disabled={submitting}>
	    戻る
	  </Button>
	  <Button
	      type="button"
	      className="w-full md:w-auto"
	      onClick={onSubmit}
	      disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
	    {creatableCount > 1 ? '一括追加' : '追加'}
	  </Button>
	</div>
      </div>
    </div>)

export default PostImportReviewPage
