import type { PostImportOrigin,
	      PostImportDisplayTag,
	      PostImportExistingPost,
	      PostImportResetSnapshot,
	      PostImportRow,
	      PostImportSession,
	      PostImportStatus,
	      PostImportSkipReason,
	      StorageErrorHandler } from '@/lib/postImportTypes'

const SESSION_VERSION = 3
const SESSION_PREFIX = 'post-import-session:'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const MAX_SESSION_JSON_BYTES = 262_144
const MAX_SESSION_ROWS = 100
const MAX_SOURCE_BYTES = 262_144
const SESSION_ID_PATTERN =
    /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const ATTRIBUTE_KEYS = [
    'title',
    'thumbnailBase',
    'originalCreatedFrom',
    'originalCreatedBefore',
    'duration',
    'videoMs',
    'tags',
    'parentPostIds'] as const
const PROVENANCE_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const TAG_SOURCE_KEYS = ['automatic', 'manual'] as const
const WARNING_KEYS = [...ATTRIBUTE_KEYS, 'url'] as const
const ROW_KEYS = [
    'sourceRow',
    'url',
    'attributes',
    'fieldWarnings',
    'baseWarnings',
    'validationErrors',
    'importErrors',
    'provenance',
    'tagSources',
    'status',
    'skipReason',
    'existingPostId',
    'existingPost',
    'metadataUrl',
    'displayTags',
    'resetSnapshot',
    'createdPostId',
    'importStatus',
    'recoverable'] as const
const SESSION_KEYS = [
    'version',
    'expiresAt',
    'source',
    'rows',
    'repairMode'] as const
const textEncoder = new TextEncoder ()


const isPlainObject = (value: unknown): value is Record<string, unknown> =>
    typeof value === 'object' && value != null && !(Array.isArray (value))

const byteLength = (value: string): number =>
    textEncoder.encode (value).byteLength


export const validatePostImportSessionId = (value: unknown): string | null => {
    if (typeof value !== 'string')
      return null
    if (value.length > 36 || !(SESSION_ID_PATTERN.test (value)))
      return null

    return value.toLowerCase ()
}


const generateUuidV4FromRandomValues = (): string => {
    const bytes = new Uint8Array (16)
    crypto.getRandomValues (bytes)
    bytes[6] = (bytes[6] & 0x0f) | 0x40
    bytes[8] = (bytes[8] & 0x3f) | 0x80

    const hex = Array.from (
	bytes,
	byte => byte.toString (16).padStart (2, '0'))
    return [
	hex.slice (0, 4).join (''),
	hex.slice (4, 6).join (''),
	hex.slice (6, 8).join (''),
	hex.slice (8, 10).join (''),
	hex.slice (10, 16).join ('')].join ('-')
}


export const generatePostImportSessionId = (): string => {
    if (typeof crypto.randomUUID === 'function')
      return crypto.randomUUID ()

    return generateUuidV4FromRandomValues ()
}


const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`


const readStorage = (
    key:       string,
    onError?:  StorageErrorHandler,
): string | null => {
    if (typeof window === 'undefined')
      return null

    try
    {
      return sessionStorage.getItem (key)
    }
    catch
    {
      onError?.('保存済みデータを読み込めませんでした．')
      return null
    }
}


const writeStorage = (
    key:       string,
    value:     string,
    onError?:  StorageErrorHandler,
): boolean => {
    if (typeof window === 'undefined')
      return false

    try
    {
      sessionStorage.setItem (key, value)
      return true
    }
    catch
    {
      onError?.('ブラウザへ保存できませんでした．')
      return false
    }
}


const removeStorage = (
    key:       string,
    onError?:  StorageErrorHandler,
) => {
    if (typeof window === 'undefined')
      return

    try
    {
      sessionStorage.removeItem (key)
    }
    catch
    {
      onError?.('保存済みデータを削除できませんでした．')
    }
}


const ensureStringListRecord = (value: unknown): Record<string, string[]> | null => {
    if (!(isPlainObject (value)))
      return null

    const result: Record<string, string[]> = { }
    for (const [key, entry] of Object.entries (value))
      {
        if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string')))
          return null
        result[key] = entry
      }
    return result
}


const isValidStatus = (
    value: unknown,
): value is PostImportRow['status'] =>
    value === 'pending'
    || value === 'ready'
    || value === 'warning'
    || value === 'error'


const isValidImportStatus = (
    value: unknown,
): value is PostImportStatus =>
    value === 'pending'
    || value === 'created'
    || value === 'skipped'
    || value === 'failed'


const isValidOrigin = (
    value: unknown,
): value is PostImportOrigin =>
    value === 'automatic' || value === 'manual'


const isValidSkipReason = (
    value: unknown,
): value is PostImportSkipReason =>
    value === 'existing' || value === 'manual'

const isPositiveInteger = (value: unknown): value is number =>
    Number.isSafeInteger (value) && Number (value) > 0

const isNonEmptyMessageRecord = (value: Record<string, string[]>): boolean =>
    Object.values (value).some (messages => messages.length > 0)

const hasOnlyKeys = (
    value:        Record<string, unknown>,
    allowedKeys:  readonly string[],
): boolean =>
    Object.keys (value).every (key => allowedKeys.includes (key))

const sanitiseDisplayTag = (value: unknown): PostImportDisplayTag | null => {
    if (!(isPlainObject (value)))
      return null
    if (!(hasOnlyKeys (value, ['name', 'category', 'sectionLiterals'])))
      return null
    if (typeof value.name !== 'string' || value.name === '')
      return null
    if (typeof value.category !== 'string')
      return null
    if (!(['deerjikist',
	   'meme',
	   'character',
	   'general',
	   'material',
	   'meta',
	   'nico'] as const).includes (value.category as never))
      return null
    if (value.sectionLiterals != null
        && (!(Array.isArray (value.sectionLiterals))
            || !(value.sectionLiterals.every (entry => typeof entry === 'string'))))
      return null

    return {
        name: value.name,
        category: value.category,
        sectionLiterals:
            value.sectionLiterals == null
            ? undefined
            : [...value.sectionLiterals] }
}

const sanitiseDisplayTags = (value: unknown): PostImportDisplayTag[] | null => {
    if (value == null)
      return []
    if (!(Array.isArray (value)))
      return null

    const tags = value.map (sanitiseDisplayTag)
    return tags.some (tag => tag == null) ? null : (tags as PostImportDisplayTag[])
}

const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
    if (!(isPlainObject (value)))
      return null
    if (!(hasOnlyKeys (value, ['url',
			       'attributes',
			       'displayTags',
			       'provenance',
			       'tagSources',
			       'fieldWarnings',
			       'baseWarnings',
			       'metadataUrl'])))
      return null
    if (typeof value.url !== 'string')
      return null
    if (!(isPlainObject (value.attributes)))
      return null
    if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
      return null
    if (!(Object.values (value.attributes).every (entry =>
        typeof entry === 'string' || typeof entry === 'number')))
      return null
    if (!(isPlainObject (value.provenance)))
      return null
    if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
      return null
    if (!(Object.values (value.provenance).every (origin => isValidOrigin (origin))))
      return null
    if (!(isPlainObject (value.tagSources)))
      return null
    if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
      return null
    if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
      return null
    const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
    if (fieldWarnings == null)
      return null
    if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
      return null
    if (!(Array.isArray (value.baseWarnings))
        || !(value.baseWarnings.every (warning => typeof warning === 'string')))
      return null
    if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
      return null
    const displayTags = sanitiseDisplayTags (value.displayTags)
    if (displayTags == null)
      return null

    return {
        url: value.url,
        attributes: value.attributes as Record<string, string | number>,
        displayTags,
        provenance: value.provenance as Record<string, PostImportOrigin>,
        tagSources: value.tagSources as Record<PostImportOrigin, string>,
        fieldWarnings,
        baseWarnings: value.baseWarnings,
        metadataUrl: value.metadataUrl as string | undefined }
}

const sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefined => {
    if (value == null)
      return undefined
    if (!(isPlainObject (value)))
      return undefined
    if (!(isPositiveInteger (value.id)))
      return undefined
    if (typeof value.title !== 'string' || typeof value.url !== 'string')
      return undefined
    if (value.thumbnail !== undefined
        && value.thumbnail !== null
        && typeof value.thumbnail !== 'string')
      return undefined
    if (value.thumbnailBase !== undefined
        && value.thumbnailBase !== null
        && typeof value.thumbnailBase !== 'string')
      return undefined
    if (value.thumbnailUrl !== undefined
        && value.thumbnailUrl !== null
        && typeof value.thumbnailUrl !== 'string')
      return undefined

    return {
        id: Number (value.id),
        title: value.title,
        url: value.url,
        thumbnail:
            value.thumbnail === null
            ? null
            : typeof value.thumbnail === 'string'
            ? value.thumbnail
            : value.thumbnailUrl === null
              ? null
              : typeof value.thumbnailUrl === 'string'
              ? value.thumbnailUrl
              : undefined,
        thumbnailBase:
            value.thumbnailBase === null
            ? null
            : typeof value.thumbnailBase === 'string'
            ? value.thumbnailBase
            : undefined }
}

const canonicalisePersistedRow = (
    row: PostImportRow,
): PostImportRow | null => {
    const sanitised = sanitiseRow (serialisePostImportRow (row))
    return sanitised == null ? null : recalculateThumbnailWarning (sanitised)
}

const recalculateThumbnailWarning = (row: PostImportRow): PostImportRow => {
    const others = (row.fieldWarnings.thumbnailBase ?? []).filter (
	key => key !== 'サムネールなし')
    const thumbnailBasePresent =
	typeof row.attributes.thumbnailBase === 'string'
	&& row.attributes.thumbnailBase.trim () !== ''

    return {
        ...row,
        fieldWarnings: {
	    ...row.fieldWarnings,
	    thumbnailBase:
		thumbnailBasePresent
		? others
		: [...new Set ([...others, 'サムネールなし'])] } }
}

const serialiseExistingPost = (value: PostImportExistingPost | undefined) =>
    value == null
    ? undefined
    : {
	 id: value.id,
	 title: value.title,
	 url: value.url,
	 thumbnail: value.thumbnail,
	 thumbnailBase: value.thumbnailBase }

const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({
    url: value.url,
    attributes: Object.fromEntries (
	Object.entries (value.attributes).map (([key, entry]) => [key, entry])),
    displayTags: value.displayTags.map (tag => ({
	name: tag.name,
	category: tag.category,
	sectionLiterals:
	    tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
    provenance: Object.fromEntries (
	Object.entries (value.provenance).map (([key, entry]) => [key, entry])),
    tagSources: {
	automatic: value.tagSources.automatic,
	manual: value.tagSources.manual },
    fieldWarnings: Object.fromEntries (
	Object.entries (value.fieldWarnings).map (([key, entry]) => [key, [...entry]])),
    baseWarnings: [...value.baseWarnings],
    metadataUrl: value.metadataUrl })

export const serialisePostImportRow = (row: PostImportRow) => {
    const thumbnailBaseWarnings = (() => {
      const others = (row.fieldWarnings.thumbnailBase ?? []).filter (
	  message => message !== 'サムネールなし')
      const thumbnailBasePresent =
	  typeof row.attributes.thumbnailBase === 'string'
	  && row.attributes.thumbnailBase.trim () !== ''
      return thumbnailBasePresent
	  ? others
	  : [...new Set ([...others, 'サムネールなし'])]
    }) ()

    return {
	sourceRow: row.sourceRow,
	url: row.url,
	attributes: Object.fromEntries (
	    Object.entries (row.attributes).map (([key, entry]) => [key, entry])),
	fieldWarnings: Object.fromEntries (
	    Object.entries ({
		...row.fieldWarnings,
		thumbnailBase: thumbnailBaseWarnings }).map (([key, entry]) => [key, [...entry]])),
	baseWarnings: [...row.baseWarnings],
	validationErrors: Object.fromEntries (
	    Object.entries (row.validationErrors).map (([key, entry]) => [key, [...entry]])),
	importErrors:
	    row.importErrors == null
	    ? undefined
	    : Object.fromEntries (
		Object.entries (row.importErrors).map (([key, entry]) => [key, [...entry]])),
	provenance: Object.fromEntries (
	    Object.entries (row.provenance).map (([key, entry]) => [key, entry])),
	displayTags:
	    row.displayTags?.map (tag => ({
		name: tag.name,
		category: tag.category,
		sectionLiterals:
		    tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [],
	tagSources:
	    row.tagSources == null
	    ? undefined
	    : {
		automatic: row.tagSources.automatic,
		manual: row.tagSources.manual },
	status: row.status,
	skipReason: row.skipReason,
	existingPostId: row.existingPostId,
	existingPost: serialiseExistingPost (row.existingPost),
	metadataUrl: row.metadataUrl,
	resetSnapshot: serialiseResetSnapshot (row.resetSnapshot),
	createdPostId: row.createdPostId,
	importStatus: row.importStatus,
	recoverable: row.recoverable }
}

export const serialisePostImportRows = (rows: PostImportRow[]) =>
    rows.map (row => serialisePostImportRow (row))

export const serialisedPostImportRowsEqual = (
    left:  PostImportRow[],
    right: PostImportRow[],
): boolean => {
    const canonicalLeft = left.map (canonicalisePersistedRow)
    const canonicalRight = right.map (canonicalisePersistedRow)
    if (canonicalLeft.some (row => row == null) || canonicalRight.some (row => row == null))
      return false

    return JSON.stringify (serialisePostImportRows (canonicalLeft as PostImportRow[]))
        === JSON.stringify (serialisePostImportRows (canonicalRight as PostImportRow[]))
}


const sanitiseRow = (value: unknown): PostImportRow | null => {
    if (!(isPlainObject (value)))
      return null
    if (!(hasOnlyKeys (value, ROW_KEYS)))
      return null
    if (!(Number.isInteger (value.sourceRow)) || Number (value.sourceRow) <= 0)
      return null
    if (typeof value.url !== 'string')
      return null
    if (byteLength (value.url) > 20_480)
      return null
    if (!(isPlainObject (value.attributes)))
      return null
    if (!(isPlainObject (value.provenance)))
      return null
    if (!(isValidStatus (value.status)))
      return null
    if (value.importStatus != null && !(isValidImportStatus (value.importStatus)))
      return null
    if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
      return null
    if (value.recoverable != null && value.recoverable !== true)
      return null
    if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
      return null
    if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId)))
      return null
    if (value.importStatus !== 'created' && value.createdPostId != null)
      return null
    if (value.recoverable === true
        && value.importStatus !== 'failed'
        && value.importStatus !== 'pending')
      return null
    if ((value.importStatus === 'created' || value.importStatus === 'skipped')
        && value.recoverable != null)
      return null

    const validationErrors = ensureStringListRecord (value.validationErrors)
    const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
    const resetSnapshot = sanitiseResetSnapshot (value.resetSnapshot)
    if (validationErrors == null || fieldWarnings == null)
      return null
    if (resetSnapshot == null)
      return null

    const importErrors =
        value.importErrors == null
        ? undefined
        : ensureStringListRecord (value.importErrors)
    if (importErrors === null)
      return null
    if (!(Array.isArray (value.baseWarnings))
        || !(value.baseWarnings.every (warning => typeof warning === 'string')))
      return null

    const provenanceEntries = Object.entries (value.provenance)
    if (!(hasOnlyKeys (value.attributes, ATTRIBUTE_KEYS)))
      return null
    if (!(Object.values (value.attributes).every (entry =>
        typeof entry === 'string' || typeof entry === 'number')))
      return null
    if (!(hasOnlyKeys (value.provenance, PROVENANCE_KEYS)))
      return null
    if (!(provenanceEntries.every (([, origin]) => isValidOrigin (origin))))
      return null

    if (value.tagSources != null)
      {
        if (!(isPlainObject (value.tagSources)))
          return null
        if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
          return null
        if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
          return null
      }
    const displayTags = sanitiseDisplayTags (value.displayTags)
    if (displayTags == null)
      return null

    const existingPost = sanitiseExistingPost (value.existingPost)
    const existingPostId =
        isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined
    if (existingPost != null && existingPostId == null)
      return null
    if (existingPost != null && existingPost.id !== existingPostId)
      return null

    return {
        sourceRow: Number (value.sourceRow),
        url: value.url,
        attributes: value.attributes as Record<string, string | number>,
        fieldWarnings: isNonEmptyMessageRecord (fieldWarnings) ? fieldWarnings : { },
        baseWarnings: value.baseWarnings,
        validationErrors: isNonEmptyMessageRecord (validationErrors) ? validationErrors : { },
        importErrors:
            importErrors != null && isNonEmptyMessageRecord (importErrors)
            ? importErrors
            : undefined,
        provenance: value.provenance as Record<string, PostImportOrigin>,
        displayTags,
        tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
        status: value.status,
        skipReason: value.skipReason ?? undefined,
        existingPostId,
        existingPost,
        metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined,
        resetSnapshot,
        createdPostId:
            isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
        importStatus: value.importStatus ?? undefined,
        recoverable: value.recoverable === true ? true : undefined }
}

const sanitiseSession = (value: unknown): PostImportSession | null => {
    if (!(isPlainObject (value)))
      return null
    if (!(hasOnlyKeys (value, SESSION_KEYS)))
      return null
    if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
      return null
    if (value.rows.length === 0 || value.rows.length > MAX_SESSION_ROWS)
      return null
    if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
      return null
    if (typeof value.source !== 'string' || byteLength (value.source) > MAX_SOURCE_BYTES)
      return null

    const rows = value.rows.map (sanitiseRow)
    if (rows.some (row => row == null))
      return null
    if ((new Set (rows.map (row => row?.sourceRow))).size !== rows.length)
      return null

    return {
        version: SESSION_VERSION,
        expiresAt: value.expiresAt,
        source: value.source,
        rows: (rows as PostImportRow[]).map (row => recalculateThumbnailWarning (row)),
        repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}


const isExpiredSession = (expiresAt: string): boolean => {
    const value = Date.parse (expiresAt)
    return Number.isNaN (value) || value <= Date.now ()
}


export const cleanupExpiredPostImportSessions = (
    onError?: StorageErrorHandler,
) => {
    if (typeof window === 'undefined')
      return

    try
    {
      for (let i = 0; i < sessionStorage.length; ++i)
        {
          const key = sessionStorage.key (i)
          if (key == null || !(key.startsWith (SESSION_PREFIX)))
            continue

          const sessionId = validatePostImportSessionId (key.slice (SESSION_PREFIX.length))
          if (sessionId == null)
            {
              sessionStorage.removeItem (key)
              --i
              continue
            }

          const raw = sessionStorage.getItem (key)
          if (raw == null)
            continue
          if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
            {
              sessionStorage.removeItem (key)
              --i
              continue
            }

          try
          {
            if (sanitiseSession (JSON.parse (raw)) == null)
              {
                sessionStorage.removeItem (key)
                --i
              }
          }
          catch
          {
            sessionStorage.removeItem (key)
            --i
          }
        }
    }
    catch
    {
      onError?.('保存済みデータを整理できませんでした．')
    }
}


export const loadPostImportSourceDraft = (
    onError?: StorageErrorHandler,
): { source: string } => {
    const raw = readStorage (SOURCE_DRAFT_KEY, onError)
    if (raw == null)
      return { source: '' }

    try
    {
      const value = JSON.parse (raw) as { source?: string }
      return { source: typeof value.source === 'string' ? value.source : '' }
    }
    catch
    {
      return { source: '' }
    }
}


export const savePostImportSourceDraft = (
    source:    string,
    onError?:  StorageErrorHandler,
): boolean =>
    writeStorage (SOURCE_DRAFT_KEY, JSON.stringify ({ source }), onError)


export const clearPostImportSourceDraft = (
    onError?: StorageErrorHandler,
) => {
    removeStorage (SOURCE_DRAFT_KEY, onError)
}


export const savePostImportSession = (
    sessionId: string,
    session: Omit<PostImportSession, 'version' | 'expiresAt'>,
    onError?:  StorageErrorHandler,
): boolean => {
    const validatedId = validatePostImportSessionId (sessionId)
    if (validatedId == null)
      return false

    return writeStorage (
        sessionKey (validatedId),
        JSON.stringify ({
            source: session.source,
            rows: serialisePostImportRows (session.rows),
            repairMode: session.repairMode,
            version: SESSION_VERSION,
            expiresAt: new Date (Date.now () + SESSION_MAX_AGE_MS).toISOString () }),
        onError)
}


export const loadPostImportSession = (
    sessionId: string,
    onError?: StorageErrorHandler,
): PostImportSession | null => {
    const validatedId = validatePostImportSessionId (sessionId)
    if (validatedId == null)
      return null

    const raw = readStorage (sessionKey (validatedId), onError)
    if (raw == null)
      return null
    if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
      {
        removeStorage (sessionKey (validatedId), onError)
        return null
      }

    try
    {
      const session = sanitiseSession (JSON.parse (raw))
      if (session == null)
        {
          removeStorage (sessionKey (validatedId), onError)
          return null
        }
      return session
    }
    catch
    {
      removeStorage (sessionKey (validatedId), onError)
      return null
    }
}


export const clearPostImportSession = (
    sessionId: string,
    onError?: StorageErrorHandler,
) => {
    const validatedId = validatePostImportSessionId (sessionId)
    if (validatedId == null)
      return

    removeStorage (sessionKey (validatedId), onError)
}
