import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost,
	 buildTag,
	 buildTheatre,
	 buildTheatreComment,
	 buildTheatreInfo,
	 buildTheatrePostSelectionWeights,
	 buildTheatreProgramme,
	 buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'

import type { ReactNode } from 'react'

const api = vi.hoisted (() => ({
  apiDelete:  vi.fn (),
  apiGet:     vi.fn (),
  apiPatch:   vi.fn (),
  apiPost:    vi.fn (),
  apiPut:     vi.fn (),
  isApiError: vi.fn (() => false),
}))

const postsApi = vi.hoisted (() => ({
  fetchPost: vi.fn (),
}))

const dialogue = vi.hoisted (() => ({
  confirm: vi.fn (),
}))

const postEmbed = vi.hoisted (() => ({
  props: vi.fn (),
  play:  vi.fn (),
  seek:  vi.fn (),
}))

vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
  ...await importOriginal<
      typeof import('@/components/dialogues/DialogueProvider')> (),
  useDialogue: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({
  default: (props: {
    ref?: { current: unknown }
    post: { title: string | null; url: string }
  }) => {
    postEmbed.props (props)
    if (props.ref)
      props.ref.current = {
        play: postEmbed.play,
        seek: postEmbed.seek,
      }

    return <div>Embed:{props.post.title || props.post.url}</div>
  },
}))
vi.mock ('@/components/PostEditForm', () => ({
  default: () => <div>Post edit form</div>,
}))
vi.mock ('framer-motion', () => ({
  motion: {
    aside: ({ children }: { children?: ReactNode }) => <aside>{children}</aside>,
    div:   ({ children }: { children?: ReactNode }) => <div>{children}</div>,
    main:  ({ children }: { children?: ReactNode }) => <main>{children}</main>,
  },
}))

const currentPost = buildPost ({
  id:    10,
  title: '上映中の投稿',
  url:   'https://www.nicovideo.jp/watch/sm10',
})
const theatre = buildTheatre ({ id: 7, name: '上映室' })
const programme = buildTheatreProgramme ({
  theatreId: 7,
  position:  3,
  post:      currentPost,
})
const weights = buildTheatrePostSelectionWeights ({
  lightestPosts: [{
    post:    currentPost,
    penalty: 2,
    weight:  0.5,
    tags:    [],
  }],
})
const watchingUsers = [
  { id: 1, name: 'tester' },
  { id: 2, name: 'another' },
]

const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
  renderWithProviders (
      <Routes>
        <Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
      </Routes>,
      { route: '/theatres/7' },
  )

const tagSection = (): HTMLElement =>
  screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!

const mockDefaultApi = () => {
  let commentsFetched = false
  let currentTheatreInfo = buildTheatreInfo ({
    postId:        currentPost.id,
    postStartedAt: '2026-01-02T03:04:05.000Z',
    postElapsedMs: 1_000,
    watchingUsers,
    skipVote:      {
      votesCount:         0,
      requiredCount:      2,
      watchingUsersCount: watchingUsers.length,
      voted:              false,
    },
  })

  api.apiGet.mockImplementation ((path: string) => {
    switch (path)
    {
    case '/theatres/7':
      return Promise.resolve (theatre)

    case '/theatres/7/comments':
      if (commentsFetched)
	return Promise.resolve ([])

      commentsFetched = true
      return Promise.resolve ([
	  buildTheatreComment ({
	    theatreId: 7,
	    no:        2,
	    user:      { id: 1, name: 'tester' },
	    content:   '視聴コメント',
	  }),
      ])

    case '/theatres/7/programmes':
      return Promise.resolve ([programme])

    case '/theatres/7/post_selection_weights':
      return Promise.resolve (weights)

    default:
      return Promise.reject (new Error (`Unexpected GET ${ path }`))
    }
  })

  api.apiPut.mockImplementation ((path: string) => {
    switch (path)
    {
    case '/theatres/7/watching':
      return Promise.resolve (currentTheatreInfo)

    case '/theatres/7/skip_vote':
      currentTheatreInfo = buildTheatreInfo ({
	postId:        currentPost.id,
	postStartedAt: '2026-01-02T03:04:05.000Z',
	postElapsedMs: 2_000,
	watchingUsers,
	skipVote:      {
	  votesCount:         1,
	  requiredCount:      2,
	  watchingUsersCount: watchingUsers.length,
	  voted:              true,
	},
      })
      return Promise.resolve (currentTheatreInfo)

    default:
      return Promise.reject (new Error (`Unexpected PUT ${ path }`))
    }
  })

  api.apiDelete.mockResolvedValue (undefined)
  api.apiPatch.mockResolvedValue (undefined)
  api.apiPost.mockResolvedValue (undefined)
  postsApi.fetchPost.mockResolvedValue (currentPost)
  dialogue.confirm.mockResolvedValue (true)
}

describe ('TheatreDetailPage', () => {
  beforeEach (() => {
    vi.useRealTimers ()
    vi.clearAllMocks ()
    mockDefaultApi ()
  })

  it ('loads theatre state, comments, current post, programme history, and weights', async () => {
    renderPage ()

    expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument ()
    expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument ()
    expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument ()
    expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument ()
    expect (screen.getByText ('penalty 2')).toBeInTheDocument ()

    await waitFor (() => {
      expect (postsApi.fetchPost).toHaveBeenCalledWith ('10')
    })
  })

  it ('votes to skip the current post', async () => {
    renderPage ()

    await screen.findByText ('Embed:上映中の投稿')

    fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' }))

    await waitFor (() => {
      expect (api.apiPut).toHaveBeenCalledWith (
          '/theatres/7/skip_vote',
          { post_id: 10 },
      )
    })
    expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' }))
      .toBeInTheDocument ()
  })

  it ('does not seek to zero while applying video length from the player', async () => {
    api.apiPut.mockImplementation ((path: string) => {
      switch (path)
      {
      case '/theatres/7/watching':
        return Promise.resolve (buildTheatreInfo ({
          hostFlg:       true,
          postId:        currentPost.id,
          postStartedAt: '2026-01-02T03:04:05.000Z',
          postElapsedMs: 7_000,
          watchingUsers: [{ id: 1, name: 'tester' }],
          skipVote:      {
            votesCount:         0,
            requiredCount:      2,
            watchingUsersCount: 1,
            voted:              false,
          },
        }))

      default:
        return Promise.reject (new Error (`Unexpected PUT ${ path }`))
      }
    })

    renderPage ()
    await screen.findByText ('Embed:上映中の投稿')

    const props = postEmbed.props.mock.calls.at (-1)![0]

    act (() => {
      props.onVideoReady (120_000)
      props.onPlaybackChange (0)
    })

    const seekMs = postEmbed.seek.mock.calls[0][0]
    expect (seekMs).toBeGreaterThanOrEqual (7_000)
    expect (seekMs).toBeLessThan (10_000)
    expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
  })

  it (
      'shows child tags from the post tag tree in both vertical and horizontal layouts',
      async () => {
    const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
    const parentTag = buildTag ({
      id:       11,
      name:     '親タグ',
      category: 'general',
      children: [childTag],
    })
    const duplicateParentTag = buildTag ({
      id:       13,
      name:     '別親タグ',
      category: 'general',
      children: [childTag],
    })

    postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
      ...currentPost,
      tags: [parentTag, duplicateParentTag],
    }))

    renderPage ()

    await screen.findByText ('Embed:上映中の投稿')

    expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
      .toBeInTheDocument ()
    expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
      .toBeInTheDocument ()
    expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
      .toBeInTheDocument ()
    expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)

    fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
    fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])

    await waitFor (() => {
      expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
	.toBeInTheDocument ()
      expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
	.toBeInTheDocument ()
      expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
	.toBeInTheDocument ()
      expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
	.toHaveLength (1)
    })
      })

  it ('does not advance host post while video length is unknown', async () => {
    api.apiPut.mockImplementation ((path: string) => {
      switch (path)
      {
      case '/theatres/7/watching':
        return Promise.resolve (buildTheatreInfo ({
          hostFlg:       true,
          postId:        currentPost.id,
          postStartedAt: '2026-01-02T03:04:05.000Z',
          postElapsedMs: 4_000,
          watchingUsers: [{ id: 1, name: 'tester' }],
          skipVote:      {
            votesCount:         0,
            requiredCount:      2,
            watchingUsersCount: 1,
            voted:              false,
          },
        }))

      default:
        return Promise.reject (new Error (`Unexpected PUT ${ path }`))
      }
    })

    renderPage ()

    await screen.findByText ('Embed:上映中の投稿')
    await waitFor (() => {
      expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching')
    })

    await waitFor (() => {
      expect (api.apiPut).toHaveBeenCalledTimes (2)
    }, { timeout: 2_500 })
    expect (api.apiPatch).not.toHaveBeenCalledWith ('/theatres/7/next_post')
  })

  it ('deletes an owned comment after confirmation', async () => {
    renderPage ()

    fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0])

    await waitFor (() => {
      expect (dialogue.confirm).toHaveBeenCalled ()
    })
    await waitFor (() => {
      expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2')
    })
    expect (await screen.findAllByText ('削除されました．')).toHaveLength (2)
  })
})
