Index =
  showHiddenThreads: false
  changed: {}

  enabledOn: ({siteID, boardID}) ->
    Conf['JSON Index'] and g.sites[siteID].software is 'yotsuba' and boardID isnt 'f'

  init: ->
    return unless g.VIEW is 'index'

    # For IndexRefresh events
    $.one d, '4chanXInitFinished', @cb.initFinished
    $.on  d, 'PostsInserted',      @cb.postsInserted

    return unless @enabledOn g.BOARD

    @enabled = true

    Callbacks.Post.push
      name: 'Index Page Numbers'
      cb:   @node
    Callbacks.CatalogThread.push
      name: 'Catalog Features'
      cb:   @catalogNode

    @search = history.state?.searched or ''
    if history.state?.mode
      Conf['Index Mode'] = history.state?.mode
    @currentSort = history.state?.sort
    @currentSort or=
      if typeof Conf['Index Sort'] is 'object' then (
        Conf['Index Sort'][g.BOARD.ID] or 'bump'
      ) else (
        Conf['Index Sort']
      )
    @currentPage = @getCurrentPage()
    @processHash()

    $.addClass doc, 'index-loading', "#{Conf['Index Mode'].replace /\ /g, '-'}-mode"
    $.on window, 'popstate', @cb.popstate
    $.on d, 'scroll', @scroll
    $.on d, 'SortIndex', @cb.resort

    # Header refresh button
    @button = $.el 'a',
      className: 'fa fa-refresh'
      title: 'Refresh'
      href: 'javascript:;'
      textContent: 'Refresh Index'
    $.on @button, 'click', -> Index.update()
    Header.addShortcut 'index-refresh', @button, 590

    # Header "Index Navigation" submenu
    entries = []
    @inputs = inputs = $.dict()
    for name, arr of Config.Index when arr instanceof Array
      label = UI.checkbox name, "#{name[0]}#{name[1..].toLowerCase()}"
      label.title = arr[1]
      entries.push {el: label}
      input = label.firstChild
      $.on input, 'change', $.cb.checked
      inputs[name] = input
    $.on inputs['Show Replies'], 'change', @cb.replies
    $.on inputs['Catalog Hover Expand'], 'change', @cb.hover
    $.on inputs['Pin Watched Threads'], 'change', @cb.resort
    $.on inputs['Anchor Hidden Threads'], 'change', @cb.resort

    watchSettings = (e) ->
      if (input = $.getOwn(inputs, e.target.name))
        input.checked = e.target.checked
        $.event 'change', null, input
    $.on d, 'OpenSettings', ->
      $.on $.id('fourchanx-settings'), 'change', watchSettings

    sortEntry = UI.checkbox 'Per-Board Sort Type', 'Per-board sort type', (typeof Conf['Index Sort'] is 'object')
    sortEntry.title = 'Set the sorting order of each board independently.'
    $.on sortEntry.firstChild, 'change', @cb.perBoardSort
    entries.splice 3, 0, {el: sortEntry}

    Header.menu.addEntry
      el: $.el 'span',
        textContent: 'Index Navigation'
      order: 100
      subEntries: entries

    # Navigation links at top of index
    @navLinks = $.el 'div', className: 'navLinks json-index'
    $.extend @navLinks, `<%= readHTML('NavLinks.html') %>`
    $('.cataloglink a', @navLinks).href = CatalogLinks.catalog()
    $('.archlistlink', @navLinks).hidden = true unless BoardConfig.isArchived(g.BOARD.ID)
    $.on $('#index-last-refresh a', @navLinks), 'click', @cb.refreshFront

    # Search field
    @searchInput = $ '#index-search', @navLinks
    @setupSearch()
    $.on @searchInput, 'input', @onSearchInput
    $.on $('#index-search-clear', @navLinks), 'click', @clearSearch

    # Hidden threads toggle
    @hideLabel = $ '#hidden-label', @navLinks
    $.on $('#hidden-toggle a', @navLinks), 'click', @cb.toggleHiddenThreads

    # Drop-down menus and reverse sort toggle
    @selectRev   = $ '#index-rev',  @navLinks
    @selectMode  = $ '#index-mode', @navLinks
    @selectSort  = $ '#index-sort', @navLinks
    @selectSize  = $ '#index-size', @navLinks
    $.on @selectRev,  'change', @cb.sort
    $.on @selectMode, 'change', @cb.mode
    $.on @selectSort, 'change', @cb.sort
    $.on @selectSize, 'change', $.cb.value
    $.on @selectSize, 'change', @cb.size
    for select in [@selectMode, @selectSize]
      select.value = Conf[select.name]
    @selectRev.checked = /-rev$/.test Index.currentSort
    @selectSort.value  = Index.currentSort.replace /-rev$/, ''

    # Last Long Reply options
    @lastLongOptions = $ '#lastlong-options', @navLinks
    @lastLongInputs = $$ 'input', @lastLongOptions
    @lastLongThresholds = [0, 0]
    @lastLongOptions.hidden = (@selectSort.value isnt 'lastlong')
    for input, i in @lastLongInputs
      $.on input, 'change', @cb.lastLongThresholds
      tRaw = Conf["Last Long Reply Thresholds #{i}"]
      input.value = @lastLongThresholds[i] =
        if typeof tRaw is 'object' then (tRaw[g.BOARD.ID] ? 100) else tRaw

    # Thread container
    @root = $.el 'div', className: 'board json-index'
    $.on @root, 'click', @cb.hoverToggle
    @cb.size()
    @cb.hover()

    # Page list
    @pagelist = $.el 'div', className: 'pagelist json-index'
    $.extend @pagelist, `<%= readHTML('PageList.html') %>`
    $('.cataloglink a', @pagelist).href = CatalogLinks.catalog()
    $.on @pagelist, 'click', @cb.pageNav

    @update true

    $.onExists doc, 'title + *', ->
      d.title = d.title.replace /\ -\ Page\ \d+/, ''

    $.onExists doc, '.board > .thread > .postContainer, .board + *', ->
      g.SITE.Build.hat = $ '.board > .thread > img:first-child'
      if g.SITE.Build.hat
        g.BOARD.threads.forEach (thread) ->
          if thread.nodes.root
            $.prepend thread.nodes.root, g.SITE.Build.hat.cloneNode false
        $.addClass doc, 'hats-enabled'
        $.addStyle ".catalog-thread::after {background-image: url(#{g.SITE.Build.hat.src});}"

      board = $ '.board'
      $.replace board, Index.root
      if Index.loaded
        $.event 'PostsInserted', null, Index.root
      # Hacks:
      # - When removing an element from the document during page load,
      #   its ancestors will still be correctly created inside of it.
      # - Creating loadable elements inside of an origin-less document
      #   will not download them.
      # - Combine the two and you get a download canceller!
      #   Does not work on Firefox unfortunately. bugzil.la/939713
      try
        d.implementation.createDocument(null, null, null).appendChild board

      $.rm el for el in $$ '.navLinks'
      $.rm $.id('ctrl-top')
      topNavPos = $.id('delform').previousElementSibling
      $.before topNavPos, $.el 'hr'
      $.before topNavPos, Index.navLinks
      timeEl = $ '#index-last-refresh time', Index.navLinks
      RelativeDates.update timeEl if timeEl.dataset.utc

    Main.ready ->
      if (pagelist = $ '.pagelist')
        $.replace pagelist, Index.pagelist
      $.rmClass doc, 'index-loading'

  scroll: ->
    return if Index.req or !Index.liveThreadData or Conf['Index Mode'] isnt 'infinite' or (window.scrollY <= doc.scrollHeight - (300 + window.innerHeight))
    Index.pageNum ?= Index.currentPage # Avoid having to pushState to keep track of the current page

    pageNum = ++Index.pageNum
    return Index.endNotice() if pageNum > Index.pagesNum

    threadIDs = Index.threadsOnPage pageNum
    Index.buildStructure threadIDs
    
  endNotice: do ->
    notify = false
    reset = -> notify = false
    return ->
      return if notify
      notify = true
      new Notice 'info', "Last page reached.", 2
      setTimeout reset, 3 * $.SECOND

  menu:
    init: ->
      return unless g.VIEW is 'index' and Conf['Menu'] and Conf['Thread Hiding Link'] and Index.enabledOn(g.BOARD)

      Menu.menu.addEntry
        el: $.el 'a',
          href:      'javascript:;'
          className: 'has-shortcut-text'
        , `<%= html('<span></span><span class="shortcut-text">Shift+click</span>') %>`
        order: 20
        open: ({thread}) ->
          return false if Conf['Index Mode'] isnt 'catalog'
          @el.firstElementChild.textContent = if thread.isHidden
            'Unhide'
          else
            'Hide'
          $.off @el, 'click', @cb if @cb
          @cb = ->
            $.event 'CloseMenu'
            Index.toggleHide thread
          $.on @el, 'click', @cb
          true

  node: ->
    return if @isReply or @isClone or not (Index.threadPosition[@ID]?)
    @thread.setPage(Index.threadPosition[@ID] // Index.threadsNumPerPage + 1)

  catalogNode: ->
    $.on @nodes.root, 'mousedown click', (e) =>
      return unless e.button is 0 and e.shiftKey
      Index.toggleHide @thread if e.type is 'click'
      e.preventDefault() # Also on mousedown to prevent highlighting text.

  toggleHide: (thread) ->
    if Index.showHiddenThreads
      ThreadHiding.show thread
      return unless ThreadHiding.db.get {boardID: thread.board.ID, threadID: thread.ID}
      # Don't save when un-hiding filtered threads.
    else
      ThreadHiding.hide thread
    ThreadHiding.saveHiddenState thread

  cycleSortType: ->
    types = [Index.selectSort.options...].filter (option) -> !option.disabled
    for type, i in types
      break if type.selected
    types[(i + 1) % types.length].selected = true
    $.event 'change', null, Index.selectSort

  cb:
    initFinished: ->
      Index.initFinishedFired = true
      $.queueTask -> Index.cb.postsInserted()

    postsInserted: ->
      return unless Index.initFinishedFired
      n = 0
      g.posts.forEach (post) ->
        if !post.isFetchedQuote and !post.indexRefreshSeen and doc.contains(post.nodes.root)
          post.indexRefreshSeen = true
          n++
      $.event 'IndexRefresh' if n

    toggleHiddenThreads: ->
      $('#hidden-toggle a', Index.navLinks).textContent = if Index.showHiddenThreads = !Index.showHiddenThreads
        'Hide'
      else
        'Show'
      Index.sort()
      Index.buildIndex()

    mode: ->
      Index.pushState {mode: @value}
      Index.pageLoad false

    sort: ->
      value = if Index.selectRev.checked then Index.selectSort.value + "-rev" else Index.selectSort.value
      Index.pushState {sort: value}
      Index.pageLoad false

    resort: (e) ->
      Index.changed.order = true
      Index.pageLoad false unless e?.detail?.deferred

    perBoardSort: ->
      Conf['Index Sort'] = if @checked then $.dict() else ''
      Index.saveSort()
      for i in [0...2]
        Conf["Last Long Reply Thresholds #{i}"] = if @checked then $.dict() else ''
        Index.saveLastLongThresholds i
      return

    lastLongThresholds: ->
      i = [@parentNode.children...].indexOf @
      value = +@value
      unless Number.isFinite(value)
        @value = Index.lastLongThresholds[i]
        return
      Index.lastLongThresholds[i] = value
      Index.saveLastLongThresholds i
      Index.changed.order = true
      Index.pageLoad false

    size: (e) ->
      if Conf['Index Mode'] isnt 'catalog'
        $.rmClass Index.root, 'catalog-small'
        $.rmClass Index.root, 'catalog-large'
      else if Conf['Index Size'] is 'small'
        $.addClass Index.root, 'catalog-small'
        $.rmClass Index.root,  'catalog-large'
      else
        $.addClass Index.root, 'catalog-large'
        $.rmClass Index.root,  'catalog-small'
      Index.buildIndex() if e

    replies: ->
      Index.buildIndex()

    hover: ->
      doc.classList.toggle 'catalog-hover-expand', Conf['Catalog Hover Expand']

    hoverToggle: (e) ->
      if Conf['Catalog Hover Toggle'] and $.hasClass(doc, 'catalog-mode') and !$.modifiedClick(e) and !$.x('ancestor-or-self::a', e.target)
        input = Index.inputs['Catalog Hover Expand']
        input.checked = !input.checked
        $.event 'change', null, input
        if (thread = Get.threadFromNode e.target)
          Index.cb.catalogReplies.call thread
          Index.cb.hoverAdjust.call thread.OP.nodes

    popstate: (e) ->
      if e?.state
        {searched, mode, sort} = e.state
        page = Index.getCurrentPage()
        Index.setState {search: searched, mode, sort, page}
        Index.pageLoad false
      else
        # page load or hash change
        nCommands = Index.processHash()
        if Conf['Refreshed Navigation'] and nCommands
          Index.update()
        else
          Index.pageLoad()

    pageNav: (e) ->
      return if $.modifiedClick e
      switch e.target.nodeName
        when 'BUTTON'
          e.target.blur()
          a = e.target.parentNode
        when 'A'
          a = e.target
        else
          return
      return if a.textContent is 'Catalog'
      e.preventDefault()
      Index.userPageNav +a.pathname.split(/\/+/)[2] or 1

    refreshFront: ->
      Index.pushState {page: 1}
      Index.update()

    catalogReplies: ->
      if Conf['Show Replies'] and $.hasClass(doc, 'catalog-hover-expand') and !@catalogView.nodes.replies
        Index.buildCatalogReplies @

    hoverAdjust: ->
      # Prevent hovered catalog threads from going offscreen.
      return unless $.hasClass(doc, 'catalog-hover-expand')
      rect = @post.getBoundingClientRect()
      if (x = $.minmax 0, -rect.left, doc.clientWidth - rect.right)
        {style} = @post
        style.left = "#{x}px"
        style.right = "#{-x}px"
        $.one @root, 'mouseleave', -> style.left = style.right = null

  scrollToIndex: ->
    # Scroll to navlinks, or top of board if navlinks are hidden.
    Header.scrollToIfNeeded (if Index.navLinks.getBoundingClientRect().height then Index.navLinks else Index.root)

  getCurrentPage: ->
    +window.location.pathname.split(/\/+/)[2] or 1

  userPageNav: (page) ->
    Index.pushState {page}
    if Conf['Refreshed Navigation']
      Index.update()
    else
      Index.pageLoad()

  hashCommands:
    mode:
      'paged':         'paged'
      'infinite-scrolling': 'infinite'
      'infinite':      'infinite'
      'all-threads':   'all pages'
      'all-pages':     'all pages'
      'catalog':       'catalog'
    sort:
      'bump-order':        'bump'
      'last-reply':        'lastreply'
      'last-long-reply':   'lastlong'
      'creation-date':     'birth'
      'reply-count':       'replycount'
      'file-count':        'filecount'

  processHash: ->
    # XXX https://bugzilla.mozilla.org/show_bug.cgi?id=483304
    hash = location.href.match(/#.*/)?[0] or ''
    state =
      replace: true
    commands = hash[1..].split '/'
    leftover = []
    for command in commands
      if (mode = $.getOwn(Index.hashCommands.mode, command))
        state.mode = mode
      else if command is 'index'
        state.mode = Conf['Previous Index Mode']
        state.page = 1
      else if (sort = $.getOwn(Index.hashCommands.sort, command.replace(/-rev$/, '')))
        state.sort = sort
        state.sort += '-rev' if /-rev$/.test(command)
      else if /^s=/.test command
        state.search = decodeURIComponent(command[2..]).replace(/\+/g, ' ').trim()
      else
        leftover.push command
    hash = leftover.join '/'
    state.hash = "##{hash}" if hash
    Index.pushState state
    commands.length - leftover.length

  pushState: (state) ->
    {search, hash, replace} = state
    pageBeforeSearch = history.state?.oldpage
    if search? and search isnt Index.search
      state.page = if search then 1 else (pageBeforeSearch or 1)
      if !search
        pageBeforeSearch = undefined
      else if !Index.search
        pageBeforeSearch = Index.currentPage
    Index.setState state
    pathname = if Index.currentPage is 1 then "/#{g.BOARD}/" else "/#{g.BOARD}/#{Index.currentPage}"
    hash or= ''
    history[if replace then 'replaceState' else 'pushState']
      mode:     Conf['Index Mode']
      sort:     Index.currentSort
      searched: Index.search
      oldpage:  pageBeforeSearch
    , '', "#{location.protocol}//#{location.host}#{pathname}#{hash}"

  setState: ({search, mode, sort, page, hash}) ->
    if search? and search isnt Index.search
      Index.changed.search = true
      Index.search = search
    if mode? and mode isnt Conf['Index Mode']
      Index.changed.mode = true
      Conf['Index Mode'] = mode
      $.set 'Index Mode', mode
      unless mode is 'catalog' or Conf['Previous Index Mode'] is mode
        Conf['Previous Index Mode'] = mode
        $.set 'Previous Index Mode', mode
    if sort? and sort isnt Index.currentSort
      Index.changed.sort = true
      Index.currentSort = sort
      Index.saveSort()
    page = 1 if Conf['Index Mode'] in ['all pages', 'catalog']
    if page? and page isnt Index.currentPage
      Index.changed.page = true
      Index.currentPage = page
    if hash?
      Index.changed.hash = true

  savePerBoard: (key, value) ->
    if typeof Conf[key] is 'object'
      Conf[key][g.BOARD.ID] = value
    else
      Conf[key] = value
    $.set key, Conf[key]

  saveSort: ->
    Index.savePerBoard 'Index Sort', Index.currentSort

  saveLastLongThresholds: (i) ->
    Index.savePerBoard "Last Long Reply Thresholds #{i}", Index.lastLongThresholds[i]

  pageLoad: (scroll=true) ->
    return unless Index.liveThreadData
    {threads, order, search, mode, sort, page, hash} = Index.changed
    threads or= search
    order   or= sort
    Index.sort()          if threads or order
    Index.buildPagelist() if threads
    Index.setupSearch()   if search
    Index.setupMode()     if mode
    Index.setupSort()     if sort
    Index.buildIndex()    if threads or mode or page or order
    Index.setPage()       if threads or page
    Index.scrollToIndex() if scroll and not hash
    Header.hashScroll()   if hash
    Index.changed = {}

  setupMode: ->
    for mode in ['paged', 'infinite', 'all pages', 'catalog']
      $[if mode is Conf['Index Mode'] then 'addClass' else 'rmClass'] doc, "#{mode.replace /\ /g, '-'}-mode"
    Index.selectMode.value = Conf['Index Mode']
    Index.cb.size()
    Index.showHiddenThreads = false
    $('#hidden-toggle a', Index.navLinks).textContent = 'Show'

  setupSort: ->
    Index.selectRev.checked = /-rev$/.test Index.currentSort
    Index.selectSort.value  = Index.currentSort.replace /-rev$/, ''
    Index.lastLongOptions.hidden = (Index.selectSort.value isnt 'lastlong')

  getPagesNum: ->
    if Index.search
      Math.ceil Index.sortedThreadIDs.length / Index.threadsNumPerPage
    else
      Index.pagesNum

  getMaxPageNum: ->
    Math.max 1, Index.getPagesNum()

  buildPagelist: ->
    pagesRoot = $ '.pages', Index.pagelist
    maxPageNum = Index.getMaxPageNum()
    if pagesRoot.childElementCount isnt maxPageNum
      nodes = []
      for i in [1..maxPageNum] by 1
        a = $.el 'a',
          textContent: i
          href: if i is 1 then './' else i
        nodes.push $.tn('['), a, $.tn '] '
      $.rmAll pagesRoot
      $.add pagesRoot, nodes

  setPage: ->
    pageNum    = Index.currentPage
    maxPageNum = Index.getMaxPageNum()
    pagesRoot  = $ '.pages', Index.pagelist

    # Previous/Next buttons
    prev = pagesRoot.previousSibling.firstChild
    next = pagesRoot.nextSibling.firstChild
    href = Math.max pageNum - 1, 1
    prev.href = if href is 1 then './' else href
    prev.firstChild.disabled = href is pageNum
    href = Math.min pageNum + 1, maxPageNum
    next.href = if href is 1 then './' else href
    next.firstChild.disabled = href is pageNum

    # <strong> current page
    if strong = $ 'strong', pagesRoot
      return if +strong.textContent is pageNum
      $.replace strong, strong.firstChild
    else
      strong = $.el 'strong'

    if (a = pagesRoot.children[pageNum - 1])
      $.before a, strong
      $.add strong, a

  updateHideLabel: ->
    return unless Index.hideLabel
    hiddenCount = 0
    for threadID in Index.liveThreadIDs when Index.isHidden(threadID)
      hiddenCount++
    unless hiddenCount
      Index.hideLabel.hidden = true
      Index.cb.toggleHiddenThreads() if Index.showHiddenThreads
      return
    Index.hideLabel.hidden = false
    $('#hidden-count', Index.navLinks).textContent = if hiddenCount is 1
      '1 hidden thread'
    else
      "#{hiddenCount} hidden threads"

  update: (firstTime) ->
    if (oldReq = Index.req)
      delete Index.req
      oldReq.abort()

    if Conf['Index Refresh Notifications']
      # Optional notification for manual refreshes
      Index.notice or= new Notice 'info', 'Refreshing index...'
      Index.nTimeout or= setTimeout ->
        Index.notice?.el.lastElementChild.textContent += ' (disable JSON Index if this takes too long)'
      , 3 * $.SECOND
    else
      # Also display notice if Index Refresh is taking too long
      Index.nTimeout or= setTimeout ->
        Index.notice or= new Notice 'info', 'Refreshing index... (disable JSON Index if this takes too long)'
      , 3 * $.SECOND

    # Hard refresh in case of incomplete page load.
    if not firstTime and d.readyState isnt 'loading' and not $('.board + *')
      location.reload()
      return

    Index.req = $.whenModified(
      g.SITE.urls.catalogJSON({boardID: g.BOARD.ID}),
      'Index',
      Index.load
    )
    $.addClass Index.button, 'fa-spin'

  load: ->
    return if @ isnt Index.req # aborted

    $.rmClass Index.button, 'fa-spin'
    {notice, nTimeout} = Index
    clearTimeout nTimeout if nTimeout
    delete Index.nTimeout
    delete Index.req
    delete Index.notice

    if @status not in [200, 304]
      err = "Index refresh failed. #{if @status then "Error #{@statusText} (#{@status})" else 'Connection Error'}"
      if notice
        notice.setType 'warning'
        notice.el.lastElementChild.textContent = err
        setTimeout notice.close, $.SECOND
      else
        new Notice 'warning', err, 1
      return

    try
      if @status is 200
        Index.parse @response
      else if @status is 304
        Index.pageLoad()
    catch err
      c.error "Index failure: #{err.message}", err.stack
      if notice
        notice.setType 'error'
        notice.el.lastElementChild.textContent = 'Index refresh failed.'
        setTimeout notice.close, $.SECOND
      else
        new Notice 'error', 'Index refresh failed.', 1
      return

    if notice
      if Conf['Index Refresh Notifications']
        notice.setType 'success'
        notice.el.lastElementChild.textContent = 'Index refreshed!'
        setTimeout notice.close, $.SECOND
      else
        notice.close()

    timeEl = $ '#index-last-refresh time', Index.navLinks
    timeEl.dataset.utc = Date.parse @getResponseHeader 'Last-Modified'
    RelativeDates.update timeEl

  parse: (pages) ->
    $.cleanCache (url) -> /^https?:\/\/a\.4cdn\.org\//.test url
    Index.parseThreadList pages
    Index.changed.threads = true
    Index.pageLoad()

  parseThreadList: (pages) ->
    Index.pagesNum          = pages.length
    Index.threadsNumPerPage = pages[0]?.threads.length or 1
    Index.liveThreadData    = pages.reduce ((arr, next) -> arr.concat next.threads), []
    Index.liveThreadIDs     = Index.liveThreadData.map (data) -> data.no
    Index.liveThreadDict    = $.dict()
    Index.threadPosition    = $.dict()
    Index.parsedThreads     = $.dict()
    Index.replyData         = $.dict()
    for data, i in Index.liveThreadData
      Index.liveThreadDict[data.no] = data
      Index.threadPosition[data.no] = i
      Index.parsedThreads[data.no] = obj = g.SITE.Build.parseJSON data, g.BOARD
      obj.filterResults = results = Filter.test obj
      obj.isOnTop  = results.top
      obj.isHidden = results.hide or ThreadHiding.isHidden(obj.boardID, obj.threadID)
      if data.last_replies
        for reply in data.last_replies
          Index.replyData["#{g.BOARD}.#{reply.no}"] = reply
    if Index.liveThreadData[0]
      g.SITE.Build.spoilerRange[g.BOARD.ID] = Index.liveThreadData[0].custom_spoiler
    g.BOARD.threads.forEach (thread) ->
      (thread.collect() unless thread.ID in Index.liveThreadIDs)
    $.event 'IndexUpdate',
      threads: ("#{g.BOARD}.#{ID}" for ID in Index.liveThreadIDs)
    return

  isHidden: (threadID) ->
    if (thread = g.BOARD.threads.get(threadID)) and thread.OP and not thread.OP.isFetchedQuote
      thread.isHidden
    else
      Index.parsedThreads[threadID].isHidden

  isHiddenReply: (threadID, replyData) ->
    PostHiding.isHidden(g.BOARD.ID, threadID, replyData.no) or Filter.isHidden(g.SITE.Build.parseJSON replyData, g.BOARD)

  buildThreads: (threadIDs, isCatalog, withReplies) ->
    threads    = []
    newThreads = []
    newPosts   = []
    for ID in threadIDs
      try
        threadData = Index.liveThreadDict[ID]

        if (thread = g.BOARD.threads.get(ID))
          isStale = (thread.json isnt threadData) and (JSON.stringify(thread.json) isnt JSON.stringify(threadData))
          if isStale
            thread.setCount 'post', threadData.replies + 1,                threadData.bumplimit
            thread.setCount 'file', threadData.images  + !!threadData.ext, threadData.imagelimit
            thread.setStatus 'Sticky', !!threadData.sticky
            thread.setStatus 'Closed', !!threadData.closed
          if thread.catalogView
            $.rm thread.catalogView.nodes.replies
            thread.catalogView.nodes.replies = null
        else
          thread = new Thread ID, g.BOARD
          newThreads.push thread
        lastPost = if threadData.last_replies then threadData.last_replies[threadData.last_replies.length - 1].no else ID
        thread.lastPost = lastPost if lastPost > thread.lastPost
        thread.json = threadData
        threads.push thread

        if ((OP = thread.OP) and not OP.isFetchedQuote)
          OP.setCatalogOP isCatalog
          thread.setPage(Index.threadPosition[ID] // Index.threadsNumPerPage + 1)
        else
          obj = Index.parsedThreads[ID]
          opRoot = g.SITE.Build.post(obj)
          OP = new Post opRoot, thread, g.BOARD
          OP.filterResults = obj.filterResults
          newPosts.push OP

        unless isCatalog and thread.nodes.root
          g.SITE.Build.thread thread, threadData, withReplies
      catch err
        # Skip posts that we failed to parse.
        errors = [] unless errors
        errors.push
          message: "Parsing of Thread No.#{thread} failed. Thread will be skipped."
          error: err
          html: opRoot?.outerHTML
    Main.handleErrors errors if errors

    if withReplies
      newPosts = newPosts.concat(Index.buildReplies threads)

    Main.callbackNodes 'Thread', newThreads
    Main.callbackNodes 'Post',   newPosts
    Index.updateHideLabel()
    $.event 'IndexRefreshInternal', {threadIDs: (t.fullID for t in threads), isCatalog}

    threads

  buildReplies: (threads) ->
    posts = []
    for thread in threads
      continue if not (lastReplies = Index.liveThreadDict[thread.ID].last_replies)
      nodes = []
      for data in lastReplies
        if (post = thread.posts.get(data.no)) and not post.isFetchedQuote
          nodes.push post.nodes.root
          continue
        nodes.push node = g.SITE.Build.postFromObject data, thread.board.ID
        try
          posts.push new Post node, thread, thread.board
        catch err
          # Skip posts that we failed to parse.
          errors = [] unless errors
          errors.push
            message: "Parsing of Post No.#{data.no} failed. Post will be skipped."
            error: err
            html: node?.outerHTML
      $.add thread.nodes.root, nodes

    Main.handleErrors errors if errors
    posts

  buildCatalogViews: (threads) ->
    catalogThreads = []
    for thread in threads when !thread.catalogView
      {ID} = thread
      page = Index.threadPosition[ID] // Index.threadsNumPerPage + 1
      root = g.SITE.Build.catalogThread thread, Index.liveThreadDict[ID], page
      catalogThreads.push new CatalogThread root, thread
    Main.callbackNodes 'CatalogThread', catalogThreads
    return

  sizeCatalogViews: (threads) ->
    # XXX When browsers support CSS3 attr(), use it instead.
    size = if Conf['Index Size'] is 'small' then 150 else 250
    for thread in threads
      {thumb} = thread.catalogView.nodes
      {width, height} = thumb.dataset
      continue unless width
      ratio = size / Math.max width, height
      thumb.style.width  = width  * ratio + 'px'
      thumb.style.height = height * ratio + 'px'
    return

  buildCatalogReplies: (thread) ->
    {nodes} = thread.catalogView
    return if not (lastReplies = Index.liveThreadDict[thread.ID].last_replies)

    replies = []
    for data in lastReplies
      continue if Index.isHiddenReply thread.ID, data
      reply = g.SITE.Build.catalogReply thread, data
      RelativeDates.update $('time', reply)
      $.on $('.catalog-reply-preview', reply), 'mouseover', QuotePreview.mouseover
      replies.push reply

    nodes.replies = $.el 'div', className: 'catalog-replies'
    $.add nodes.replies, replies
    $.add thread.OP.nodes.post, nodes.replies
    return

  sort: ->
    {liveThreadIDs, liveThreadData} = Index
    return unless liveThreadData
    sortType = Index.currentSort.replace(/-rev$/, '')
    Index.sortedThreadIDs = switch sortType
      when 'lastreply', 'lastlong'
        lastlong = (thread) ->
          for r, i in (thread.last_replies or []) by -1
            continue if Index.isHiddenReply thread.no, r
            if sortType is 'lastreply'
              return r
            len = if r.com then g.SITE.Build.parseComment(r.com).replace(/[^a-z]/ig, '').length else 0
            if len >= Index.lastLongThresholds[+!!r.ext]
              return r
          if thread.omitted_posts then thread.last_replies[0] else thread
        lastlongD = $.dict()
        for thread in liveThreadData
          lastlongD[thread.no] = lastlong(thread).no
        [liveThreadData...].sort((a, b) ->
          lastlongD[b.no] - lastlongD[a.no]
        ).map (post) -> post.no
      when 'bump'       then liveThreadIDs
      when 'birth'      then [liveThreadIDs... ].sort (a, b) -> b - a
      when 'replycount' then [liveThreadData...].sort((a, b) -> b.replies - a.replies).map (post) -> post.no
      when 'filecount'  then [liveThreadData...].sort((a, b) -> b.images  - a.images ).map (post) -> post.no
      else liveThreadIDs
    if /-rev$/.test(Index.currentSort)
      Index.sortedThreadIDs = [Index.sortedThreadIDs...].reverse()
    if Index.search and (threadIDs = Index.querySearch Index.search)
      Index.sortedThreadIDs = threadIDs
    # Sticky threads
    Index.sortOnTop (obj) -> obj.isSticky
    # Highlighted threads
    Index.sortOnTop (obj) -> obj.isOnTop or Conf['Pin Watched Threads'] and ThreadWatcher.isWatchedRaw(obj.boardID, obj.threadID)
    # Non-hidden threads
    Index.sortOnTop((obj) -> !Index.isHidden(obj.threadID)) if Conf['Anchor Hidden Threads']

  sortOnTop: (match) ->
    topThreads    = []
    bottomThreads = []
    for ID in Index.sortedThreadIDs
      (if match Index.parsedThreads[ID] then topThreads else bottomThreads).push ID
    Index.sortedThreadIDs = topThreads.concat bottomThreads

  buildIndex: ->
    return unless Index.liveThreadData
    switch Conf['Index Mode']
      when 'all pages'
        threadIDs = Index.sortedThreadIDs
      when 'catalog'
        threadIDs = Index.sortedThreadIDs.filter (ID) -> !Index.isHidden(ID) isnt Index.showHiddenThreads
      else
        threadIDs = Index.threadsOnPage Index.currentPage
    delete Index.pageNum
    $.rmAll Index.root
    $.rmAll Header.hover
    if Index.loaded and Index.root.parentNode
      $.event 'PostsRemoved', null, Index.root
    if Conf['Index Mode'] is 'catalog'
      Index.buildCatalog threadIDs
    else
      Index.buildStructure threadIDs
    return

  threadsOnPage: (pageNum) ->
    nodesPerPage = Index.threadsNumPerPage
    offset = nodesPerPage * (pageNum - 1)
    Index.sortedThreadIDs[offset ... offset + nodesPerPage]

  buildStructure: (threadIDs) ->
    threads = Index.buildThreads threadIDs, false, Conf['Show Replies']
    nodes = []
    for thread in threads
      nodes.push thread.nodes.root, $.el('hr')
    $.add Index.root, nodes
    if Index.root.parentNode
      $.event 'PostsInserted', null, Index.root
    Index.loaded = true
    return

  buildCatalog: (threadIDs) ->
    i = 0
    n = threadIDs.length
    node0 = null
    fn = ->
      return if node0 and !node0.parentNode # Index.root cleared
      j = if i > 0 and Index.root.parentNode then n else i + 30
      node0 = Index.buildCatalogPart(threadIDs[i...j])[0]
      i = j
      if i < n
        $.queueTask fn
      else
        if Index.root.parentNode
          $.event 'PostsInserted', null, Index.root
        Index.loaded = true
    fn()
    return

  buildCatalogPart: (threadIDs) ->
    threads = Index.buildThreads threadIDs, true
    Index.buildCatalogViews threads
    Index.sizeCatalogViews threads
    nodes = []
    for thread in threads
      thread.OP.setCatalogOP true
      $.add thread.catalogView.nodes.root, thread.OP.nodes.root
      nodes.push thread.catalogView.nodes.root
      $.on thread.catalogView.nodes.root, 'mouseenter', Index.cb.catalogReplies.bind(thread)
      $.on thread.OP.nodes.root, 'mouseenter', Index.cb.hoverAdjust.bind(thread.OP.nodes)
    $.add Index.root, nodes
    nodes

  clearSearch: ->
    Index.searchInput.value = ''
    Index.onSearchInput()
    Index.searchInput.focus()

  setupSearch: ->
    Index.searchInput.value = Index.search
    if Index.search
      Index.searchInput.dataset.searching = 1
    else
      # XXX https://bugzilla.mozilla.org/show_bug.cgi?id=1021289
      Index.searchInput.removeAttribute 'data-searching'

  onSearchInput: ->
    search = Index.searchInput.value.trim()
    return if search is Index.search
    Index.pushState
      search:  search
      replace: !!search is !!Index.search
    Index.pageLoad false

  querySearch: (query) ->
    if (match = query.match /^([\w+]+):\/(.*)\/(\w*)$/)
      try
        regexp = RegExp match[2], match[3]
      catch
        return []
      return Index.sortedThreadIDs.filter (ID) ->
        regexp.test(Filter.values(match[1], Index.parsedThreads[ID]).join('\n'))
    return if not (keywords = query.toLowerCase().match /\S+/g)
    Index.sortedThreadIDs.filter (ID) ->
      Index.searchMatch Index.parsedThreads[ID], keywords

  searchMatch: (obj, keywords) ->
    {info, file} = obj
    info.comment ?= g.SITE.Build.parseComment info.commentHTML.innerHTML
    text = []
    for key in ['comment', 'subject', 'name', 'tripcode']
      text.push info[key] if key of info
    text.push file.name if file
    text = text.join(' ').toLowerCase()
    for keyword in keywords
      return false if -1 is text.indexOf keyword
    return true
