/*
 * Copyright (C) 2020 - present Instructure, Inc.
 *
 * This file is part of Canvas.
 *
 * Canvas is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Affero General Public License as published by the Free
 * Software Foundation, version 3 of the License.
 *
 * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Affero General Public License along
 * with this program. If not, see <http://www.gnu.org/licenses/>.
 */

import React from 'react'
import {render, fireEvent, act} from '@testing-library/react'
import timezone from 'timezone_core'
import tokyo from 'timezone/Asia/Tokyo'
import moment from 'moment-timezone'
import BulkEdit from '../BulkEdit'

// Because node 10 is dumb and doesn't have this yet
import 'array-flat-polyfill'

// grab this before fake timers replace it
const realSetImmediate = setImmediate
async function flushPromises() {
  await act(() => new Promise(realSetImmediate))
}

function standardAssignmentResponse() {
  return [
    {
      id: 'assignment_1',
      name: 'First Assignment',
      can_edit: true,
      all_dates: [
        {
          base: true,
          unlock_at: '2020-03-19T00:00:00Z',
          due_at: '2020-03-20T03:00:00Z',
          lock_at: '2020-04-11T00:00:00Z',
          can_edit: true
        },
        {
          id: 'override_1',
          title: '2 students',
          unlock_at: '2020-03-29T00:00:00Z',
          due_at: '2020-03-30T00:00:00Z',
          lock_at: '2020-04-21T00:00:00Z',
          can_edit: true
        }
      ]
    },
    {
      id: 'assignment_2',
      name: 'second assignment',
      can_edit: true,
      all_dates: [{base: true, unlock_at: null, due_at: null, lock_at: null, can_edit: true}]
    }
  ]
}

function restrictedAssignmentResponse() {
  const data = standardAssignmentResponse()
  data[0].all_dates[1].can_edit = false
  data[0].all_dates[1].in_closed_grading_period = true
  data[0].all_dates.push({
    id: 'override_2',
    title: 'blah',
    unlock_at: '2020-03-20T00:00:00Z',
    due_at: '2020-03-21T00:00:00Z',
    lock_at: '2020-03-22T00:00:00Z',
    can_edit: false
  })
  data[1].can_edit = false
  data[1].all_dates[0].can_edit = false
  data[1].moderated_grading = true
  return data
}

function tooManyDatesResponse() {
  const data = standardAssignmentResponse()
  delete data[1].all_dates
  data[1].all_dates_count = 51

  return data
}

function mockAssignmentsResponse(assignments) {
  fetch.mockResponse(JSON.stringify(assignments))
  return assignments
}

function mockStandardAssignmentsResponse() {
  return mockAssignmentsResponse(standardAssignmentResponse())
}

function renderBulkEdit(overrides = {}) {
  const props = {
    courseId: '42',
    onCancel: jest.fn(),
    onSave: jest.fn(),
    ...overrides
  }
  const result = {...render(<BulkEdit {...props} />), ...props}
  return result
}

async function renderBulkEditAndWait(overrides = {}, assignments = standardAssignmentResponse()) {
  fetch.mockResponseOnce(JSON.stringify(assignments))
  const result = renderBulkEdit(overrides)
  await flushPromises()
  result.assignments = assignments
  return result
}

function changeAndBlurInput(input, newValue) {
  fireEvent.change(input, {target: {value: newValue}})
  fireEvent.blur(input)
}

beforeEach(() => {
  jest.useFakeTimers()
  fetch.resetMocks()
})

describe('Assignment Bulk Edit Dates', () => {
  let oldEnv
  let timezoneSnapshot
  beforeEach(() => {
    oldEnv = window.ENV
    window.ENV = {
      TIMEZONE: 'Asia/Tokyo',
      FEATURES: {}
    }
    timezoneSnapshot = timezone.snapshot()
    timezone.changeZone(tokyo, 'Asia/Tokyo')
  })

  afterEach(async () => {
    await flushPromises()
    window.ENV = oldEnv
    timezone.restore(timezoneSnapshot)
  })

  it('shows a spinner while loading', async () => {
    mockStandardAssignmentsResponse()
    const {getByText} = renderBulkEdit()
    expect(getByText('Loading')).toBeInTheDocument()
  })

  it('invokes onCancel when cancel button is clicked', async () => {
    const {getByText, onCancel} = await renderBulkEditAndWait()
    fireEvent.click(getByText('Cancel'))
    expect(onCancel).toHaveBeenCalled()
  })

  it('invokes onSave when the save button is clicked', async () => {
    const {getByText, getAllByLabelText, onSave} = await renderBulkEditAndWait()
    changeAndBlurInput(getAllByLabelText('Due At')[0], '2020-04-01')
    fireEvent.click(getByText('Save'))
    expect(onSave).toHaveBeenCalled()
  })

  it('disables save when local validation fails', async () => {
    const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
    changeAndBlurInput(getAllByLabelText('Due At')[0], '2019-04-01')
    expect(getByText('Unlock date cannot be after due date')).toBeInTheDocument()
    expect(getByText('Save').closest('button').disabled).toBe(true)
  })

  it('clears the validation error when a bad edit is reverted', async () => {
    const {queryByText, getAllByText, getAllByLabelText} = await renderBulkEditAndWait()
    const theInput = getAllByLabelText('Due At')[0]
    changeAndBlurInput(theInput, '2019-04-01')
    expect(queryByText('Unlock date cannot be after due date')).toBeInTheDocument()
    const revertButtons = getAllByText('Revert date changes').filter(elt => elt.closest('button'))
    expect(revertButtons).toHaveLength(1)
    fireEvent.click(revertButtons[0])
    expect(queryByText('Unlock date cannot be after due date')).not.toBeInTheDocument()
  })

  it('validates against closed grading periods', async () => {
    ENV.HAS_GRADING_PERIODS = true
    ENV.active_grading_periods = [
      {
        start_date: '1970-01-01T00:00:00-07:00',
        end_date: '2020-03-01T23:59:59-07:00',
        close_date: '2020-03-01T23:59:59-07:00',
        id: '1',
        is_closed: true,
        is_last: false,
        permissions: {read: true, create: false, update: false, delete: false},
        title: 'Closed'
      },
      {
        start_date: '2020-03-01T23:59:59-06:00',
        close_date: '3000-12-31T23:59:59-07:00',
        end_date: '3000-12-31T23:59:59-07:00',
        id: '2',
        is_closed: false,
        is_last: true,
        permissions: {read: true, create: false, update: false, delete: false},
        title: '5ever'
      }
    ]
    const {queryByText, getAllByLabelText} = await renderBulkEditAndWait()
    changeAndBlurInput(getAllByLabelText('Available From')[0], '2020-01-01')
    const theInput = getAllByLabelText('Due At')[0]
    changeAndBlurInput(theInput, '2020-03-03')
    expect(queryByText(/Please enter a due date on or after/)).not.toBeInTheDocument()
    changeAndBlurInput(theInput, '2020-01-03')
    expect(queryByText(/Please enter a due date on or after/)).toBeInTheDocument()
  })

  it('disables save when nothing has been edited', async () => {
    const {getByText} = await renderBulkEditAndWait()
    expect(getByText('Save').closest('button').disabled).toBe(true)
  })

  it('shows the specified dates', async () => {
    const {getAllByLabelText} = await renderBulkEditAndWait()
    const dueDateInputs = getAllByLabelText('Due At')
    expect(dueDateInputs.map(i => i.value)).toEqual(['Fri Mar 20, 2020', 'Mon Mar 30, 2020', ''])
    const unlockAtInputs = getAllByLabelText('Available From')
    expect(unlockAtInputs.map(i => i.value)).toEqual(['Thu Mar 19, 2020', 'Sun Mar 29, 2020', ''])
    const lockAtInputs = getAllByLabelText('Available Until')
    expect(lockAtInputs.map(i => i.value)).toEqual(['Sat Apr 11, 2020', 'Tue Apr 21, 2020', ''])
  })

  it('shows a message and no date default date fields if an assignment does not have default dates', async () => {
    const assignments = standardAssignmentResponse()
    assignments[0].all_dates.shift() // remove the base override
    const {getByText, getAllByLabelText} = await renderBulkEditAndWait({}, assignments)
    expect(getByText('This assignment has no default dates.')).toBeInTheDocument()
    expect(getAllByLabelText('Due At')).toHaveLength(2)
  })

  it('modifies unlock date and enables save', async () => {
    const {getByText, getByDisplayValue} = await renderBulkEditAndWait()
    const assignmentUnlockInput = getByDisplayValue('Thu Mar 19, 2020')
    changeAndBlurInput(assignmentUnlockInput, '2020-01-01')
    expect(assignmentUnlockInput.value).toBe('Wed Jan 1, 2020')
    expect(getByText('Save').closest('button').disabled).toBe(false)
  })

  it('modifies lock at date and enables save', async () => {
    const {getByText, getByDisplayValue} = await renderBulkEditAndWait()
    const overrideLockInput = getByDisplayValue('Tue Apr 21, 2020')
    changeAndBlurInput(overrideLockInput, '2020-12-31')
    expect(overrideLockInput.value).toBe('Thu Dec 31, 2020')
    expect(getByText('Save').closest('button').disabled).toBe(false)
  })

  it('modifies due date and enables save', async () => {
    const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
    const nullDueDate = getAllByLabelText('Due At')[2]
    changeAndBlurInput(nullDueDate, '2020-06-15')
    expect(nullDueDate.value).toBe('Mon Jun 15, 2020')
    expect(getByText('Save').closest('button').disabled).toBe(false)
  })

  it('can blur a field without changes without revert button showing up', async () => {
    const {queryAllByText, getByDisplayValue, getAllByDisplayValue} = await renderBulkEditAndWait()
    const assignmentUnlockAt = getByDisplayValue('Thu Mar 19, 2020')
    fireEvent.blur(assignmentUnlockAt)
    let revertButtons = queryAllByText('Revert date changes').filter(elt => elt.closest('button'))
    expect(revertButtons).toHaveLength(0)

    const nullAssignmentUnlockAt = getAllByDisplayValue('')[0]
    fireEvent.blur(nullAssignmentUnlockAt)
    revertButtons = queryAllByText('Revert date changes').filter(elt => elt.closest('button'))
    expect(revertButtons).toHaveLength(0)
  })

  it('can revert edited dates on a row', async () => {
    const {getAllByText, getAllByLabelText, getByDisplayValue} = await renderBulkEditAndWait()

    const assignmentUnlockAt = getByDisplayValue('Thu Mar 19, 2020')
    changeAndBlurInput(assignmentUnlockAt, '2020-06-15')

    const overrideLockInput = getByDisplayValue('Tue Apr 21, 2020')
    changeAndBlurInput(overrideLockInput, '')

    const nullDueDate = getAllByLabelText('Due At')[2]
    changeAndBlurInput(nullDueDate, '2020-06-16')

    const revertButtons = getAllByText('Revert date changes').filter(elt => elt.closest('button'))
    expect(revertButtons).toHaveLength(3)

    fireEvent.click(revertButtons[1])
    expect(overrideLockInput.value).toBe('Tue Apr 21, 2020') // original value
    expect(assignmentUnlockAt.value).toBe('Mon Jun 15, 2020') // not changed yet
    expect(nullDueDate.value).toBe('Tue Jun 16, 2020') // not changed yet
    // focus should be explicitly set to the lock at input
    expect(document.activeElement).toBe(overrideLockInput)

    fireEvent.click(revertButtons[0])
    fireEvent.click(revertButtons[2])
    expect(assignmentUnlockAt.value).toBe('Thu Mar 19, 2020') // original value
    expect(nullDueDate.value).toBe('') // original value
  })

  it('can revert nonsense input on a row', async () => {
    const {getAllByText, getByDisplayValue} = await renderBulkEditAndWait()
    const assignmentUnlockAt = getByDisplayValue('Thu Mar 19, 2020')
    changeAndBlurInput(assignmentUnlockAt, 'asdf')
    const revertButton = getAllByText('Revert date changes').filter(elt => elt.closest('button'))[0]
    fireEvent.click(revertButton)
    expect(assignmentUnlockAt.value).toBe('Thu Mar 19, 2020') // original value
  })

  it('disables non-editable dates', async () => {
    const {getByTitle, getAllByLabelText} = await renderBulkEditAndWait(
      {},
      restrictedAssignmentResponse()
    )
    const dueDateInputs = getAllByLabelText('Due At')
    expect(dueDateInputs.map(i => i.disabled)).toEqual([false, true, true, true])
    const unlockAtInputs = getAllByLabelText('Available From')
    expect(unlockAtInputs.map(i => i.disabled)).toEqual([false, true, true, true])
    const lockAtInputs = getAllByLabelText('Available Until')
    expect(lockAtInputs.map(i => i.disabled)).toEqual([false, true, true, true])
    expect(getByTitle('In closed grading period')).toBeInTheDocument()
    expect(getByTitle('Only the moderator can edit this assignment')).toBeInTheDocument()
    expect(getByTitle('You do not have permission to edit this assignment')).toBeInTheDocument()
  })

  it('deals with too many dates', async () => {
    const {getByText, getAllByLabelText} = await renderBulkEditAndWait({}, tooManyDatesResponse())
    const dueDateInputs = getAllByLabelText('Due At')
    expect(dueDateInputs.length).toEqual(2)

    expect(getByText('This assignment has too many dates to display.')).toBeInTheDocument()
  })

  describe('saving data', () => {
    it('sets all dates in the set if any one date is edited', async () => {
      const {assignments, getByText, getAllByLabelText} = await renderBulkEditAndWait()
      const overrideDueAtInput = getAllByLabelText('Due At')[1]
      const dueAtDate = '2020-04-01'
      changeAndBlurInput(overrideDueAtInput, dueAtDate)
      fireEvent.click(getByText('Save'))
      await flushPromises()
      expect(fetch).toHaveBeenCalledWith(
        '/api/v1/courses/42/assignments/bulk_update',
        expect.objectContaining({
          method: 'PUT'
        })
      )
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              due_at: '2020-04-01T00:00:00.000Z', // Time of day was preserved, which was UTC 00:00:00
              unlock_at: assignments[0].all_dates[1].unlock_at,
              lock_at: assignments[0].all_dates[1].lock_at
            }
          ]
        }
      ])
    })

    it('can save multiple assignments and overrides', async () => {
      const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
      const dueAtDate = '2020-04-01'
      const dueAtMoment = moment.tz(dueAtDate, 'Asia/Tokyo')
      changeAndBlurInput(getAllByLabelText('Due At')[0], dueAtDate)
      changeAndBlurInput(getAllByLabelText('Due At')[1], dueAtDate)
      changeAndBlurInput(getAllByLabelText('Due At')[2], dueAtDate)
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              base: true,
              due_at: '2020-04-01T03:00:00.000Z' // The UTC time of day was preserved
            },
            {
              id: 'override_1',
              due_at: '2020-04-01T00:00:00.000Z'
            }
          ]
        },
        {
          id: 'assignment_2',
          all_dates: [
            {
              base: true,
              due_at: dueAtMoment
                .clone()
                .endOf('day') // new due date gets end of day in the specified TZ
                .toISOString()
            }
          ]
        }
      ])
    })

    it('disables the Save button while saving', async () => {
      const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
      const overrideDueAtInput = getAllByLabelText('Due At')[1]
      const dueAtDate = '2020-04-01'
      changeAndBlurInput(overrideDueAtInput, dueAtDate)
      const saveButton = getByText('Save').closest('button')
      expect(saveButton.disabled).toBe(false)
      fireEvent.click(saveButton)
      expect(saveButton.disabled).toBe(true)
      expect(getByText('Saving...')).toBeInTheDocument()
    })

    it('can clear an existing date', async () => {
      const {assignments, getByText, getAllByLabelText} = await renderBulkEditAndWait()
      const dueAtInput = getAllByLabelText('Due At')[0]
      changeAndBlurInput(dueAtInput, '')
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              base: true,
              due_at: null,
              unlock_at: assignments[0].all_dates[0].unlock_at,
              lock_at: assignments[0].all_dates[0].lock_at
            }
          ]
        }
      ])
    })

    it('invokes fancy midnight on new dates for due_at and lock_at', async () => {
      const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
      const dueAtInput = getAllByLabelText('Due At')[2]
      const lockAtInput = getAllByLabelText('Available Until')[2]
      const dueAtDate = '2020-04-01'
      const lockAtDate = '2020-04-02'

      changeAndBlurInput(dueAtInput, dueAtDate)
      changeAndBlurInput(lockAtInput, lockAtDate)
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body).toMatchObject([
        {
          id: 'assignment_2',
          all_dates: [
            {
              base: true,
              due_at: moment
                .tz(dueAtDate, 'Asia/Tokyo')
                .endOf('day')
                .toISOString(),
              lock_at: moment
                .tz(lockAtDate, 'Asia/Tokyo')
                .endOf('day')
                .toISOString(),
              unlock_at: null
            }
          ]
        }
      ])
    })

    it('invokes beginning of day on new dates for unlock_at', async () => {
      const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
      const unlockAtInput = getAllByLabelText('Available From')[2]
      const unlockDate = '2020-04-01'

      changeAndBlurInput(unlockAtInput, unlockDate)
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body).toMatchObject([
        {
          id: 'assignment_2',
          all_dates: [
            {
              base: true,
              unlock_at: moment
                .tz(unlockDate, 'Asia/Tokyo')
                .startOf('day')
                .toISOString(),
              due_at: null,
              lock_at: null
            }
          ]
        }
      ])
    })

    it('preserves the existing time on existing dates', async () => {
      const {assignments, getByText, getAllByLabelText} = await renderBulkEditAndWait()
      const dueAtInput = getAllByLabelText('Due At')[0]
      const dueAtDate = '2020-04-01'
      const originalDueAtMoment = moment.tz(assignments[0].all_dates[0].due_at, 'Asia/Tokyo')
      const localTimeOffset = originalDueAtMoment.diff(originalDueAtMoment.clone().startOf('day'))
      changeAndBlurInput(dueAtInput, dueAtDate)
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              base: true,
              due_at: moment
                .tz(dueAtDate, 'Asia/Tokyo')
                .add(localTimeOffset, 'ms')
                .toISOString()
            }
          ]
        }
      ])
    })

    it('displays an error if starting the save fails', async () => {
      const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
      changeAndBlurInput(getAllByLabelText('Due At')[0], '2020-04-01')
      fetch.mockResponseOnce(JSON.stringify({errors: [{message: 'something bad happened'}]}), {
        status: 401
      })
      fireEvent.click(getByText('Save'))
      await flushPromises()
      expect(getByText(/something bad happened/)).toBeInTheDocument()
    })
  })

  describe('save progress', () => {
    async function renderBulkEditAndSave() {
      const fns = await renderBulkEditAndWait()
      changeAndBlurInput(fns.getAllByLabelText('Due At')[0], '2020-04-01')
      fetch.mockResponses(
        [JSON.stringify({url: 'progress url'})],
        [JSON.stringify({url: 'progress url', workflow_state: 'queued', completion: 0})]
      )
      fireEvent.click(fns.getByText('Save'))
      await flushPromises()
      return fns
    }

    it('polls for progress and updates a progress bar', async () => {
      const {getByText} = await renderBulkEditAndSave()
      expect(fetch).toHaveBeenCalledWith('progress url', expect.anything())
      expect(getByText('0%')).toBeInTheDocument()

      fetch.mockResponses(
        [JSON.stringify({url: 'progress url', workflow_state: 'running', completion: 42})],
        [JSON.stringify({url: 'progress url', workflow_state: 'complete', completion: 100})]
      )

      act(jest.runOnlyPendingTimers)
      await flushPromises()
      expect(getByText('42%')).toBeInTheDocument()

      act(jest.runOnlyPendingTimers)
      await flushPromises()
      expect(getByText(/saved successfully/)).toBeInTheDocument()
      expect(getByText('Save').closest('button').disabled).toBe(true)
      expect(getByText('Close')).toBeInTheDocument()
      // complete, expect no more polling
      fetch.resetMocks()
      act(jest.runAllTimers)
      await flushPromises()
      expect(fetch).not.toHaveBeenCalled()
    })

    it('displays an error if the progress fetch fails', async () => {
      const {getByText} = await renderBulkEditAndSave()
      fetch.mockResponseOnce(JSON.stringify({errors: [{message: 'could not get progress'}]}), {
        status: 401
      })
      act(jest.runAllTimers)
      await flushPromises()
      expect(getByText(/could not get progress/)).toBeInTheDocument()
      expect(getByText('Save').closest('button').disabled).toBe(false)
    })

    it('displays an error if the job fails', async () => {
      const {getByText, getAllByLabelText} = await renderBulkEditAndSave()
      fetch.mockResponseOnce(
        JSON.stringify({
          completion: 42,
          workflow_state: 'failed',
          results: [
            {assignment_id: 'assignment_1', errors: {due_at: [{message: 'some bad dates'}]}}
          ]
        })
      )
      act(jest.runAllTimers)
      await flushPromises()
      expect(getByText(/some bad dates/)).toBeInTheDocument()
      // save button is disabled due to error
      expect(getByText('Save').closest('button').disabled).toBe(true)
      // fix the error and save should be re-enabled
      changeAndBlurInput(getAllByLabelText(/Due At/)[0], '2020-04-04')
      expect(getByText('Save').closest('button').disabled).toBe(false)
    })

    it('can start a second save operation', async () => {
      const {getByText, queryByText, getAllByLabelText} = await renderBulkEditAndSave()
      fetch.mockResponseOnce(
        JSON.stringify({url: 'progress url', workflow_state: 'complete', completion: 100})
      )
      act(jest.runAllTimers)
      await flushPromises()
      expect(getByText(/saved successfully/)).toBeInTheDocument()
      expect(getByText('Save').closest('button').disabled).toBe(true)

      changeAndBlurInput(getAllByLabelText('Due At')[0], '2020-04-02')
      expect(queryByText(/saved successfully/)).toBe(null)

      fetch.mockResponses(
        [JSON.stringify({url: 'progress url'})],
        [JSON.stringify({url: 'progress url', workflow_state: 'complete', completion: 100})]
      )
      fireEvent.click(getByText('Save'))
      await flushPromises()
      expect(getByText(/saved successfully/)).toBeInTheDocument()
    })
  })

  describe('assignment selections', () => {
    it('displays checkboxes for each main assignment', async () => {
      const {getByText, getAllByText, assignments} = await renderBulkEditAndWait()
      expect(getAllByText(/Select assignment:/)).toHaveLength(assignments.length)
      expect(getByText('0 assignments selected')).toBeInTheDocument()
    })

    it('disables checkboxes for assignments that cannot be edited', async () => {
      const {getAllByLabelText} = await renderBulkEditAndWait({}, restrictedAssignmentResponse())
      expect(getAllByLabelText(/Select assignment:/)[0].disabled).toBe(true)
      expect(getAllByLabelText(/Select assignment:/)[1].disabled).toBe(true)
    })

    it('allows assignments to be checked individually', async () => {
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait()
      const checkboxes = getAllByLabelText(/Select assignment:/)
      fireEvent.click(checkboxes[0])
      expect(checkboxes[0].checked).toBe(true)
      expect(checkboxes[1].checked).toBe(false)
      expect(getByLabelText('Select all assignments').getAttribute('aria-checked')).toBe('mixed')
      expect(getByText('1 assignment selected')).toBeInTheDocument()
    })

    it('selects and deselects all editable assignments with the header', async () => {
      const assignments = restrictedAssignmentResponse()
      assignments.push({
        id: 'assignment_3',
        name: 'third assignment',
        can_edit: true,
        all_dates: [{base: true, unlock_at: null, due_at: null, lock_at: null, can_edit: true}]
      })
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
        {},
        assignments
      )
      const allCheckbox = getByLabelText('Select all assignments')
      fireEvent.click(allCheckbox)
      const checkboxes = getAllByLabelText(/Select assignment:/)
      expect(allCheckbox.checked).toBe(true)
      expect(checkboxes[0].checked).toBe(false)
      expect(checkboxes[1].checked).toBe(false)
      expect(checkboxes[2].checked).toBe(true)
      expect(getByText('1 assignment selected')).toBeInTheDocument()

      fireEvent.click(allCheckbox)
      expect(allCheckbox.checked).toBe(false)
      expect(checkboxes[0].checked).toBe(false)
      expect(checkboxes[1].checked).toBe(false)
      expect(checkboxes[2].checked).toBe(false)
    })
  })

  describe('assignment selection by date', () => {
    function assignmentListWithDates() {
      return [
        {
          id: 'assignment_1',
          name: 'First Assignment',
          can_edit: true,
          all_dates: [
            {
              base: true,
              unlock_at: moment.tz('2020-03-19T00:00:00', 'Asia/Tokyo').toISOString(),
              due_at: moment.tz('2020-03-20T11:59:59', 'Asia/Tokyo').toISOString(),
              lock_at: moment.tz('2020-04-11T11:59:59', 'Asia/Tokyo').toISOString(),
              can_edit: true
            },
            {
              id: 'override_1',
              title: '2 students',
              unlock_at: moment.tz('2020-03-29T00:00:00', 'Asia/Tokyo').toISOString(),
              due_at: moment.tz('2020-03-30T11:59:59', 'Asia/Tokyo').toISOString(),
              lock_at: moment.tz('2020-04-21T11:59:59', 'Asia/Tokyo').toISOString(),
              can_edit: true
            }
          ]
        },
        {
          id: 'assignment_2',
          name: 'second assignment',
          can_edit: true,
          all_dates: [
            {
              id: 'override_2',
              unlock_at: moment.tz('2020-03-22T00:00:00', 'Asia/Tokyo').toISOString(),
              due_at: moment.tz('2020-03-23T11:59:59', 'Asia/Tokyo').toISOString(),
              lock_at: null,
              can_edit: true
            }
          ]
        },
        {
          id: 'assignment_3',
          name: 'third assignment',
          can_edit: true,
          all_dates: [
            {
              base: true,
              unlock_at: moment.tz('2020-03-24T00:00:00', 'Asia/Tokyo').toISOString(),
              due_at: moment.tz('2020-03-25T11:59:59', 'Asia/Tokyo').toISOString(),
              lock_at: null,
              can_edit: true
            }
          ]
        },
        {
          id: 'assignment_4',
          name: 'fourth assignment',
          can_edit: true,
          all_dates: [
            {
              base: true,
              unlock_at: null,
              due_at: null,
              lock_at: null,
              can_edit: true
            }
          ]
        }
      ]
    }

    it('apply button is initially disabled when both fields are blank', async () => {
      const {getByText} = await renderBulkEditAndWait()
      expect(getByText(/Apply date range selection/).closest('button').disabled).toBe(true)
    })

    it('apply button is enabled if either field is filled', async () => {
      const {getByText, getByLabelText} = await renderBulkEditAndWait()
      const applyButton = getByText(/Apply date range selection/)
      const startInput = getByLabelText('Selection start date')
      changeAndBlurInput(startInput, '2020-03-18')
      expect(applyButton.closest('button').disabled).toBe(false)
      changeAndBlurInput(startInput, '')
      expect(applyButton.closest('button').disabled).toBe(true)
      const endInput = getByLabelText('Selection end date')
      changeAndBlurInput(endInput, '2020-03-18')
      expect(applyButton.closest('button').disabled).toBe(false)
    })

    it('selects some assignments between two dates', async () => {
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
        {},
        assignmentListWithDates()
      )
      const checkboxes = getAllByLabelText(/Select assignment:/)
      changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-20')
      changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-23')
      fireEvent.click(getByText(/^Apply$/))
      expect(checkboxes.map(cb => cb.checked)).toEqual([true, true, false, false])
    })

    it('deselects assignments outside of the dates', async () => {
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
        {},
        assignmentListWithDates()
      )
      const checkboxes = getAllByLabelText(/Select assignment:/)
      checkboxes.forEach(cb => fireEvent.click(cb))
      expect(checkboxes.map(cb => cb.checked)).toEqual([true, true, true, true])
      changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-20')
      changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-20')
      fireEvent.click(getByText(/^Apply$/))
      expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, false, false])
    })

    it('selects some assignments from start date to end of time', async () => {
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
        {},
        assignmentListWithDates()
      )
      changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-24') // catches the unlock dates
      fireEvent.click(getByText(/^Apply$/))
      const checkboxes = getAllByLabelText(/Select assignment:/)
      expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, true, false])
    })

    it('selects some assignments from beginning of time to end date', async () => {
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
        {},
        assignmentListWithDates()
      )
      changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-22')
      fireEvent.click(getByText(/^Apply$/))
      const checkboxes = getAllByLabelText(/Select assignment:/)
      expect(checkboxes.map(cb => cb.checked)).toEqual([true, true, false, false])
    })

    it('checks unlock date for selection', async () => {
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
        {},
        assignmentListWithDates()
      )
      changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-29')
      changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-29')
      fireEvent.click(getByText(/^Apply$/))
      const checkboxes = getAllByLabelText(/Select assignment:/)
      expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, false, false])
    })

    it('checks lock date for selection', async () => {
      const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
        {},
        assignmentListWithDates()
      )
      changeAndBlurInput(getByLabelText('Selection start date'), '2020-04-21')
      changeAndBlurInput(getByLabelText('Selection end date'), '2020-04-21')
      fireEvent.click(getByText(/^Apply$/))
      const checkboxes = getAllByLabelText(/Select assignment:/)
      expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, false, false])
    })

    it('shows an error and disables apply if end date is before start date', async () => {
      const {getByText, getByLabelText, getAllByText} = await renderBulkEditAndWait(
        {},
        assignmentListWithDates()
      )
      changeAndBlurInput(getByLabelText('Selection start date'), '2020-05-15')
      changeAndBlurInput(getByLabelText('Selection end date'), '2020-05-14')
      expect(
        getAllByText('The end date must be after the start date').length
      ).toBeGreaterThanOrEqual(1)
      expect(getByText(/^Apply$/).closest('button')).toBeDisabled()
    })
  })

  describe('batch edit dialog', () => {
    async function renderOpenBatchEditDialog(selectAssignments = [0]) {
      const result = await renderBulkEditAndWait()
      selectAssignments.forEach(index => {
        fireEvent.click(result.getAllByLabelText(/Select assignment:/)[index])
      })
      fireEvent.click(result.getByText('Batch Edit'))
      return result
    }

    it('has a disabled "Batch Edit" button when no assignments are selected', async () => {
      const {getByText, queryByText} = await renderBulkEditAndWait()
      expect(getByText('Batch Edit').closest('button').disabled).toBe(true)
      expect(queryByText('Batch Edit Dates')).toBeNull()
    })

    it('can be canceled with no effects', async () => {
      const {getByText, queryByText, getByTestId} = await renderOpenBatchEditDialog()
      expect(getByText('Batch Edit Dates')).toBeInTheDocument()
      fireEvent.click(getByTestId('cancel-batch-edit'))
      jest.runAllTimers() // required for modal to actually close
      expect(queryByText('Batch Edit Dates')).toBeNull()
      // check no dates edited by disabled save button
      expect(getByText('Save').closest('button').disabled).toBe(true)
    })

    it('shifts dates for all selected assignments forward N days, including overrides', async () => {
      const {getByText, getByLabelText} = await renderOpenBatchEditDialog([0])
      fireEvent.change(getByLabelText('Days'), {target: {value: '2'}})
      fireEvent.click(getByText('Ok'))
      jest.runAllTimers() // required for modal to actually close
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body.length).toBe(1) // second assignment was not selected
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              base: true,
              unlock_at: '2020-03-21T00:00:00.000Z',
              due_at: '2020-03-22T03:00:00.000Z', // time preservation
              lock_at: '2020-04-13T00:00:00.000Z'
            },
            {
              id: 'override_1',
              unlock_at: '2020-03-31T00:00:00.000Z',
              due_at: '2020-04-01T00:00:00.000Z',
              lock_at: '2020-04-23T00:00:00.000Z'
            }
          ]
        }
      ])
    })

    it('ignores blank date fields', async () => {
      const {getByText, getByLabelText} = await renderOpenBatchEditDialog([1])
      fireEvent.change(getByLabelText('Days'), {target: {value: '2'}})
      fireEvent.click(getByText('Ok'))
      jest.runAllTimers() // required for modal to actually close
      // all dates in the assignment are null, so nothing should change
      expect(getByText('Save').closest('button').disabled).toBe(true)
    })

    it('disables "Ok" when N days input is blank', async () => {
      const {getByText, getByLabelText} = await renderOpenBatchEditDialog([1])
      fireEvent.change(getByLabelText('Days'), {target: {value: ''}})
      expect(getByText('Ok').closest('button').disabled).toBe(true)
    })

    it('removes due dates from assignments', async () => {
      const {assignments, getByText, getByLabelText} = await renderOpenBatchEditDialog([0])
      fireEvent.click(getByLabelText('Remove Dates'))
      fireEvent.click(getByText('Ok'))
      jest.runAllTimers() // required for modal to actually close
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body.length).toBe(1) // second assignment was not selected
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              base: true,
              unlock_at: assignments[0].all_dates[0].unlock_at,
              due_at: null,
              lock_at: assignments[0].all_dates[0].lock_at
            },
            {
              id: 'override_1',
              unlock_at: assignments[0].all_dates[1].unlock_at,
              due_at: null,
              lock_at: assignments[0].all_dates[1].lock_at
            }
          ]
        }
      ])
    })

    it('removes availability dates from assignments', async () => {
      const {assignments, getByText, getByLabelText} = await renderOpenBatchEditDialog([0])
      fireEvent.click(getByLabelText('Remove Dates'))
      fireEvent.click(getByLabelText('Due Dates'))
      fireEvent.click(getByLabelText('Availability Dates'))
      fireEvent.click(getByText('Ok'))
      jest.runAllTimers() // required for modal to actually close
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body.length).toBe(1) // second assignment was not selected
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              base: true,
              unlock_at: null,
              due_at: assignments[0].all_dates[0].due_at,
              lock_at: null
            },
            {
              id: 'override_1',
              unlock_at: null,
              due_at: assignments[0].all_dates[1].due_at,
              lock_at: null
            }
          ]
        }
      ])
    })

    it('removes all dates from assignments', async () => {
      const {getByText, getByLabelText} = await renderOpenBatchEditDialog([0])
      fireEvent.click(getByLabelText('Remove Dates'))
      fireEvent.click(getByLabelText('Availability Dates'))
      fireEvent.click(getByText('Ok'))
      jest.runAllTimers() // required for modal to actually close
      fireEvent.click(getByText('Save'))
      await flushPromises()
      const body = JSON.parse(fetch.mock.calls[1][1].body)
      expect(body.length).toBe(1) // second assignment was not selected
      expect(body).toMatchObject([
        {
          id: 'assignment_1',
          all_dates: [
            {
              base: true,
              unlock_at: null,
              due_at: null,
              lock_at: null
            },
            {
              id: 'override_1',
              unlock_at: null,
              due_at: null,
              lock_at: null
            }
          ]
        }
      ])
    })

    it('disables "Ok" when neither checkbox is selected', async () => {
      const {getByText, getByLabelText} = await renderOpenBatchEditDialog([0])
      fireEvent.click(getByLabelText('Remove Dates'))
      fireEvent.click(getByLabelText('Due Dates'))
      expect(getByText('Ok').closest('button').disabled).toBe(true)
    })
  })
})
