import { AnimatePresence, motion } from 'framer-motion'
import { 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 {
    appendPostNewSessionId,
    parsePostNewState,
    parsePostNewSessionId,
    serialisePostNewState,
} from '@/lib/postNewQueryState'
import {
    applyThumbnailWarning,
    buildNextEditedRow,
    canEditReviewRow,
    canRetryResultRow,
    clearPostImportSession,
    clearPostImportSourceDraft,
    generatePostImportSessionId,
    hasThumbnailBaseValue,
    hasErrorMessages,
    isCompletedReviewRow,
    isExistingSkipRow,
    isManualSkipRow,
    isNonRecoverableFailedRow,
    mergeImportResults,
    processableImportRows,
    replaceImportRow,
    resultRepairMode,
    resultRowMessages,
    reviewSummaryCounts,
    retryImportRow,
    loadPostImportSession,
    compactMessageRecord,
    serialisedPostImportRowsEqual,
    savePostImportSession,
} from '@/lib/postImportSession'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { canEditContent } from '@/lib/users'
import Forbidden from '@/pages/Forbidden'

import type { FC } from 'react'

import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
import type {
    PostImportResultRow,
    PostImportRow,
    PostImportSession,
} from '@/lib/postImportSession'
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?:          PostImportRow['displayTags']
  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 isRepairRow = (row: PostImportRow): boolean =>
    row.recoverable === true
    && (row.importStatus === 'failed'
        || (row.importStatus === 'pending'
            && hasErrorMessages (row.validationErrors)))

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

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 buildSession = (rows: PostImportRow[]): PostImportSession => ({
    version: 3,
    expiresAt: '',
    source: rows.map (row => row.url).join ('\n'),
    rows,
    repairMode: resultRepairMode (rows) })


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 ('video_ms', String (row.attributes.videoMs ?? ''))
  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 ?? '',
            videoMs: result.videoMs ?? '',
            tags: result.tags ?? '',
            parentPostIds: String (result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
        displayTags:
            result.displayTags?.map (tag => ({
                name: tag.name,
                category: tag.category,
                sectionLiterals:
                    tag.sectionLiterals == null
                    ? undefined
                    : [...tag.sectionLiterals] })) ?? [],
        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 ?? '',
	videoMs: preview.videoMs ?? '',
	tags: preview.tags ?? '',
	parentPostIds: String (preview.parentPostIds ?? '') },
    displayTags:
	preview.displayTags?.map (tag => ({
	    name: tag.name,
	    category: tag.category,
	    sectionLiterals:
		tag.sectionLiterals == null
		? undefined
		: [...tag.sectionLiterals] })) ?? [],
    provenance: {
	url: 'manual',
	title: 'automatic',
	thumbnailBase: 'automatic',
	originalCreatedFrom: 'automatic',
	originalCreatedBefore: 'automatic',
	duration: 'automatic',
	videoMs: 'automatic',
	tags: 'automatic',
	parentPostIds: 'automatic' },
    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:
	  currentRow.displayTags?.map (tag => ({
	      name: tag.name,
	      category: tag.category,
	      sectionLiterals:
		  tag.sectionLiterals == null
		  ? undefined
		  : [...tag.sectionLiterals] })) ?? [],
      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 =
	  preview.displayTags?.map (tag => ({
	      name: tag.name,
	      category: tag.category,
	      sectionLiterals:
		  tag.sectionLiterals == null
		  ? undefined
		  : [...tag.sectionLiterals] })) ?? []
    }
  nextRow.tagSources.automatic = preview.tags ?? ''
  if (currentRow.provenance.parentPostIds !== 'manual')
    nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
  if (currentRow.provenance.videoMs !== 'manual')
    nextRow.attributes.videoMs = preview.videoMs ?? ''
  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 ?? '',
	      video_ms: row.attributes.videoMs ?? '',
	      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 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 [session, setSession] = useState<PostImportSession | 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 sessionRef = useRef<PostImportSession | null> (null)
  const sessionIdRef = useRef<string | null> (null)
  const persistedSearchRef = useRef<string | null> (null)
  const persistSequenceRef = useRef (0)
  const previewSequenceRef = useRef (0)
  const metadataSequenceRef = useRef (0)

  const showStorageError = (message: string) =>
      toast ({ description: message })

  const commitSessionState = (
      nextSession: PostImportSession,
  ): {
      session:                    PostImportSession
      sessionRoundTripSucceeded:  boolean } => {
    const sessionId =
	sessionIdRef.current ?? generatePostImportSessionId ()
    sessionIdRef.current = sessionId
    const persistedSession = buildSession (nextSession.rows)
    const saved = savePostImportSession (sessionId, persistedSession, showStorageError)
    const loadedSession =
	saved
	? loadPostImportSession (sessionId, showStorageError)
	: null
    sessionRef.current = persistedSession
    setSession (persistedSession)
    return {
	session: persistedSession,
	sessionRoundTripSucceeded:
	    saved
	    && serialisedPostImportRowsEqual (
		loadedSession?.rows ?? [],
		persistedSession.rows) }
  }

  const syncSessionUrl = async (
      nextSession: PostImportSession,
  ): Promise<PostImportSession | null> => {
    const sessionId =
	sessionIdRef.current ?? generatePostImportSessionId ()
    sessionIdRef.current = sessionId
    const sequence = ++persistSequenceRef.current
    const serialised = await serialisePostNewState (nextSession.rows)
    if (serialised == null)
      return null
    if (persistSequenceRef.current !== sequence)
      return null

    const { session: persistedSession,
	    sessionRoundTripSucceeded } = commitSessionState (nextSession)
    const canNavigate =
	serialised.complete
	|| sessionRoundTripSucceeded
    if (!(canNavigate))
      {
	showStorageError ('ブラウザへ保存できませんでした．')
	return null
      }

    const path = appendPostNewSessionId (serialised.path, sessionId)
    persistedSearchRef.current = (new URL (path, window.location.origin)).search
    navigate (path, { replace: true })
    return persistedSession
  }

  const currentRowBySource = (
      sourceRow: number,
      fallback?: PostImportSession,
  ): PostImportRow | null =>
      (sessionRef.current?.rows ?? fallback?.rows ?? []).find (
	  row => row.sourceRow === sourceRow) ?? null

  const finishImport = () => {
    const sessionId = sessionIdRef.current
    if (sessionId != null)
      clearPostImportSession (sessionId)
    clearPostImportSourceDraft (message =>
	toast ({ title: '入力内容を削除できませんでした', description: message }))
    navigate ('/posts')
  }

  useEffect (() => {
    let active = true
    const previewSequence = ++previewSequenceRef.current
    metadataSequenceRef.current = previewSequence
    const controllers = new Set<AbortController> ()

    if (sessionRef.current != null
        && persistedSearchRef.current === location.search
        && !(sessionRef.current.rows.some (row => shouldFetchReviewRow (row))))
      return () => {
        previewSequenceRef.current = previewSequence
      }

    const refreshRows = async (baseSession: PostImportSession) => {
      const blocksSubmit = baseSession.rows.some (row => shouldFetchMetadata (row))
      if (active && blocksSubmit)
	setMetadataLoading (true)
      try
      {
        let nextIndex = 0
        const mergeCurrentRow = (
	    sourceRow: number,
	    updater:   (row: PostImportRow) => PostImportRow,
        ) => {
	    const latestRows = sessionRef.current?.rows ?? baseSession.rows
	    const nextRows = applyDuplicateUrlErrors (latestRows.map (row =>
	        row.sourceRow === sourceRow
	        ? updater (row)
	        : row))
	    commitSessionState (buildSession (nextRows))
        }
        const worker = async () => {
          while (active)
          {
            const index = nextIndex
            ++nextIndex
            if (index >= baseSession.rows.length)
              return

            const baseRow = baseSession.rows[index]
            const currentRow = currentRowBySource (baseRow.sourceRow, baseSession)
            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) || previewSequenceRef.current !== previewSequence)
                return
              mergeCurrentRow (currentRow.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) || previewSequenceRef.current !== previewSequence)
                return
              if (!(isApiError<{
		      errors?: Record<string, string[]>
		      baseErrors?: string[]
		  }> (requestError)))
                {
                  mergeCurrentRow (currentRow.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 (currentRow.sourceRow, row => ({
                      ...row,
                      validationErrors: rowErrors,
                      status: 'error' }))
                }
              else
                mergeCurrentRow (currentRow.sourceRow, row => ({
                    ...row,
                    status: 'error' }))
            }
            finally
            {
              controllers.delete (controller)
            }
          }
        }

        await Promise.all (
            Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
        if (!(active) || previewSequenceRef.current !== previewSequence)
          return

        const latestRows = sessionRef.current?.rows ?? baseSession.rows
        const nextRows = applyDuplicateUrlErrors (latestRows)
        await syncSessionUrl (buildSession (nextRows))
      }
      finally
      {
        if (active
	    && blocksSubmit
	    && metadataSequenceRef.current === previewSequence)
	  setMetadataLoading (false)
      }
    }

    const hydrate = async () => {
      const parsedSessionId = parsePostNewSessionId (location.search)
      const sessionId = parsedSessionId ?? generatePostImportSessionId ()
      const loadedSession = loadPostImportSession (sessionId)
      const parsed = await parsePostNewState (location.search)
      if (!(active))
	return
      if (parsed == null && loadedSession == null)
	{
	  setMetadataLoading (false)
	  navigate ('/posts/new', { replace: true })
	  return
	}

      sessionIdRef.current = sessionId
      const loaded = loadedSession ?? buildSession (parsed?.rows ?? [])
      persistedSearchRef.current = location.search
      sessionRef.current = loaded
      setSession (loaded)

      void refreshRows (loaded)
    }

    void hydrate ()

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

  useEffect (() => {
    if (session != null)
      sessionRef.current = session
  }, [session])

  useEffect (() => {
    if (editingRow == null || session?.repairMode !== 'failed')
      return

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

  const rows = session?.rows ?? []
  const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
  const sortedRows =
      session?.repairMode === 'failed'
      ? (
	  [...rows].sort ((a, b) => {
	    const aRepair = isRepairRow (a) ? 0 : 1
	    const bRepair = isRepairRow (b) ? 0 : 1
	    return aRepair - bRepair || a.sourceRow - b.sourceRow
	  }))
      : rows
  const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
  const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
  const processingRows = processableImportRows (rows)
  const canSubmit = processingRows.length > 0

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

    const nextRows = currentSession.rows.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 nextSession = buildSession (applyDuplicateUrlErrors (nextRows))
    if (metadataLoading)
      {
        commitSessionState (nextSession)
        return
      }
    await syncSessionUrl (nextSession)
  }

  const saveDraft = async (
      row: PostImportRow,
      { draft, resetRequested }: {
	  draft:          PostImportRowDraft
	  resetRequested: boolean },
  ): Promise<{ saved: boolean
	       row:   PostImportRow | null }> => {
    const currentSession = sessionRef.current
    if (currentSession == null)
      return { saved: false, row: null }
    const currentRow = currentRowBySource (row.sourceRow, currentSession)
    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:
		currentRow.resetSnapshot.displayTags.map (tag => ({
		    name: tag.name,
		    category: tag.category,
		    sectionLiterals:
			tag.sectionLiterals == null
			? undefined
			: [...tag.sectionLiterals] })),
	    provenance: { ...currentRow.resetSnapshot.provenance },
	    tagSources: { ...currentRow.resetSnapshot.tagSources },
	    fieldWarnings: Object.fromEntries (
		Object.entries (currentRow.resetSnapshot.fieldWarnings)
		    .map (([key, values]) => [key, [...values]])),
	    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 latestSession = sessionRef.current
      if (latestSession == null)
	return { saved: false, row: null }

      const mergedRow = mergeDryRunRow (candidateRow, dryRun)
      const mergedRows = applyDuplicateUrlErrors (
	  replaceImportRow (latestSession.rows, mergedRow))
      const nextSession =
	  metadataLoading
	  ? commitSessionState (buildSession (mergedRows)).session
	  : await syncSessionUrl (buildSession (mergedRows))
      if (nextSession == null)
	return { saved: false, row: null }
      const mergedTarget = nextSession.rows.find (
	  currentRow => currentRow.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:          PostImportRowDraft
	    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 = sessionRef.current?.rows.find (
	current => current.sourceRow === 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 initialSession = sessionRef.current
    if (initialSession == null)
      return

    const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
    if (originalRow == null)
      return
    if (rowOperationBusy (originalRow, submitting, busyRowIds))
      return

    setBusyRowIds (current => new Set ([...current, sourceRow]))
    try
    {
      const pendingRows = retryImportRow (initialSession.rows, sourceRow)
      const pendingSession =
	  metadataLoading
	  ? commitSessionState (buildSession (pendingRows)).session
	  : await syncSessionUrl (buildSession (pendingRows))
      if (pendingSession == null)
	return

      const target = pendingSession.rows.find (row => row.sourceRow === sourceRow)
      if (target == null)
	return

      const result = await apiPost<{ results: BulkApiRow[] }>(
	  '/posts/bulk',
	  buildBulkFormData ([target]))
      if (result.results.length !== 1)
	{
	  const latest = sessionRef.current ?? pendingSession
	  const restoredRows = replaceImportRow (latest.rows, originalRow)
	  if (metadataLoading)
	    commitSessionState (buildSession (restoredRows))
	  else
	    await syncSessionUrl (buildSession (restoredRows))
	  toast ({ title: '登録結果が不完全でした' })
	  return
	}

      const indexedResults = indexedBulkResults ([target], result.results)
      const latestAfterImport = sessionRef.current ?? pendingSession
      const nextRows = applyDuplicateUrlErrors (
	  mergeImportResults (latestAfterImport.rows, indexedResults))
          .map (row => applyThumbnailWarning (row))
      const persisted =
	  metadataLoading
	  ? commitSessionState (buildSession (nextRows)).session
	  : await syncSessionUrl (buildSession (nextRows))
      if (persisted != null
	  && persisted.rows.every (row => isCompletedReviewRow (row)))
	finishImport ()
    }
    catch
    {
      const latest = sessionRef.current ?? initialSession
      const restoredRows = replaceImportRow (latest.rows, originalRow)
      if (metadataLoading)
	commitSessionState (buildSession (restoredRows))
      else
	await syncSessionUrl (buildSession (restoredRows))
      toast ({ title: '再試行に失敗しました' })
    }
    finally
    {
      setBusyRowIds (current => {
	  const next = new Set (current)
	  next.delete (sourceRow)
	  return next
      })
    }
  }

  const submit = async () => {
    const currentSession = sessionRef.current
    if (currentSession == null
	|| metadataLoading
	|| submitting
	|| busyRowIds.size > 0)
      return
    const processingRows = processableImportRows (currentSession.rows)
    if (processingRows.length === 0)
      return

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

      const indexedResults = indexedBulkResults (processingRows, result.results)
      const latestAfterImport = sessionRef.current ?? buildSession (currentSession.rows)
      const nextRows = applyDuplicateUrlErrors (
	  mergeImportResults (latestAfterImport.rows, indexedResults))
          .map (row => applyThumbnailWarning (row))
      const persisted = await syncSessionUrl (buildSession (nextRows))
      if (persisted != null
	  && persisted.rows.every (row => isCompletedReviewRow (row)))
	finishImport ()
    }
    catch
    {
      toast ({ title: '登録に失敗しました' })
    }
    finally
    {
      setSubmitting (false)
    }
  }

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

  if (session == null)
    return null

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

	<MainArea className="min-h-0">
	  <div className="mx-auto max-w-6xl space-y-4 p-4">
	    <PageTitle>追加内容確認</PageTitle>

	    {counts.existingSkipped > 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 (location.state?.from === '/posts/new'
				    ? '/posts/new'
				    : (-1))}
	    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
