require 'etc'
require 'thread'

begin
  require 'ncursesw'
rescue LoadError
  require 'ncurses'
end

if defined? Ncurses
module Ncurses
  def rows
    lame, lamer = [], []
    stdscr.getmaxyx lame, lamer
    lame.first
  end

  def cols
    lame, lamer = [], []
    stdscr.getmaxyx lame, lamer
    lamer.first
  end

  def curx
    lame, lamer = [], []
    stdscr.getyx lame, lamer
    lamer.first
  end

  def mutex; @mutex ||= Mutex.new; end
  def sync &b; mutex.synchronize(&b); end

  ## magically, this stuff seems to work now. i could swear it didn't
  ## before. hm.
  def nonblocking_getch
    ## INSANTIY
    ## it is NECESSARY to wrap Ncurses.getch in a select() otherwise all
    ## background threads will be BLOCKED. (except in very modern versions
    ## of libncurses-ruby. the current one on ubuntu seems to work well.)
    if IO.select([$stdin], nil, nil, 0.5)
      if Redwood::BufferManager.shelled?
        # If we get input while we're shelled, we'll ignore it for the
        # moment and use Ncurses.sync to wait until the shell_out is done.
        Ncurses.sync { nil }
      else
        Ncurses.getch
      end
    end
  end

  ## pretends ctrl-c's are ctrl-g's
  def safe_nonblocking_getch
    nonblocking_getch
  rescue Interrupt
    KEY_CANCEL
  end

  module_function :rows, :cols, :curx, :nonblocking_getch, :safe_nonblocking_getch, :mutex, :sync

  remove_const :KEY_ENTER
  remove_const :KEY_CANCEL

  KEY_ENTER = 10
  KEY_CANCEL = 7 # ctrl-g
  KEY_TAB = 9
end
end

module Redwood

class InputSequenceAborted < StandardError; end

class Buffer
  attr_reader :mode, :x, :y, :width, :height, :title, :atime
  bool_reader :dirty, :system
  bool_accessor :force_to_top, :hidden

  def initialize window, mode, width, height, opts={}
    @w = window
    @mode = mode
    @dirty = true
    @focus = false
    @title = opts[:title] || ""
    @force_to_top = opts[:force_to_top] || false
    @hidden = opts[:hidden] || false
    @x, @y, @width, @height = 0, 0, width, height
    @atime = Time.at 0
    @system = opts[:system] || false
  end

  def content_height; @height - 1; end
  def content_width; @width; end

  def resize rows, cols
    return if cols == @width && rows == @height
    @width = cols
    @height = rows
    @dirty = true
    mode.resize rows, cols
  end

  def redraw status
    if @dirty
      draw status
    else
      draw_status status
    end

    commit
  end

  def mark_dirty; @dirty = true; end

  def commit
    @dirty = false
    @w.noutrefresh
  end

  def draw status
    @mode.draw
    draw_status status
    commit
    @atime = Time.now
  end

  ## s nil means a blank line!
  def write y, x, s, opts={}
    return if x >= @width || y >= @height

    @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
    s ||= ""
    maxl = @width - x # maximum display width width
    stringl = maxl    # string "length"

    # fill up the line with blanks to overwrite old screen contents
    @w.mvaddstr y, x, " " * maxl unless opts[:no_fill]

    ## the next horribleness is thanks to ruby's lack of widechar support
    stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
    @w.mvaddstr y, x, s[0 ... stringl]
  end

  def clear
    @w.clear
  end

  def draw_status status
    write @height - 1, 0, status, :color => :status_color
  end

  def focus
    @focus = true
    @dirty = true
    @mode.focus
  end

  def blur
    @focus = false
    @dirty = true
    @mode.blur
  end
end

class BufferManager
  include Singleton

  attr_reader :focus_buf

  ## we have to define the key used to continue in-buffer search here, because
  ## it has special semantics that BufferManager deals with---current searches
  ## are canceled by any keypress except this one.
  CONTINUE_IN_BUFFER_SEARCH_KEY = "n"

  HookManager.register "status-bar-text", <<EOS
Sets the status bar. The default status bar contains the mode name, the buffer
title, and the mode status. Note that this will be called at least once per
keystroke, so excessive computation is discouraged.

Variables:
         num_inbox: number of messages in inbox
  num_inbox_unread: total number of messages marked as unread
         num_total: total number of messages in the index
          num_spam: total number of messages marked as spam
             title: title of the current buffer
              mode: current mode name (string)
            status: current mode status (string)
Return value: a string to be used as the status bar.
EOS

  HookManager.register "terminal-title-text", <<EOS
Sets the title of the current terminal, if applicable. Note that this will be
called at least once per keystroke, so excessive computation is discouraged.

Variables: the same as status-bar-text hook.
Return value: a string to be used as the terminal title.
EOS

  HookManager.register "extra-contact-addresses", <<EOS
A list of extra addresses to propose for tab completion, etc. when the
user is entering an email address. Can be plain email addresses or can
be full "User Name <email@domain.tld>" entries.

Variables: none
Return value: an array of email address strings.
EOS

  def initialize
    @name_map = {}
    @buffers = []
    @focus_buf = nil
    @dirty = true
    @minibuf_stack = []
    @minibuf_mutex = Mutex.new
    @textfields = {}
    @flash = nil
    @shelled = @asking = false
    @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
    @sigwinch_happened = false
    @sigwinch_mutex = Mutex.new
  end

  def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
  def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end

  def buffers; @name_map.to_a; end
  def shelled?; @shelled; end

  def focus_on buf
    return unless @buffers.member? buf
    return if buf == @focus_buf
    @focus_buf.blur if @focus_buf
    @focus_buf = buf
    @focus_buf.focus
  end

  def raise_to_front buf
    @buffers.delete(buf) or return
    if @buffers.length > 0 && @buffers.last.force_to_top?
      @buffers.insert(-2, buf)
    else
      @buffers.push buf
    end
    focus_on @buffers.last
    @dirty = true
  end

  ## we reset force_to_top when rolling buffers. this is so that the
  ## human can actually still move buffers around, while still
  ## programmatically being able to pop stuff up in the middle of
  ## drawing a window without worrying about covering it up.
  ##
  ## if we ever start calling roll_buffers programmatically, we will
  ## have to change this. but it's not clear that we will ever actually
  ## do that.
  def roll_buffers
    bufs = rollable_buffers
    bufs.last.force_to_top = false
    raise_to_front bufs.first
  end

  def roll_buffers_backwards
    bufs = rollable_buffers
    return unless bufs.length > 1
    bufs.last.force_to_top = false
    raise_to_front bufs[bufs.length - 2]
  end

  def rollable_buffers
    @buffers.select { |b| !(b.system? || b.hidden?) || @buffers.last == b }
  end

  def handle_input c
    if @focus_buf
      if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
        @focus_buf.mode.cancel_search!
        @focus_buf.mark_dirty
      end
      @focus_buf.mode.handle_input c
    end
  end

  def exists? n; @name_map.member? n; end
  def [] n; @name_map[n]; end
  def []= n, b
    raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
    raise ArgumentError, "title must be a string" unless n.is_a? String
    @name_map[n] = b
  end

  def completely_redraw_screen
    return if @shelled

    ## this magic makes Ncurses get the new size of the screen
    Ncurses.endwin
    Ncurses.stdscr.keypad 1
    Ncurses.curs_set 0
    Ncurses.refresh
    @sigwinch_mutex.synchronize { @sigwinch_happened = false }
    debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"

    status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock

    Ncurses.sync do
      @dirty = true
      Ncurses.clear
      draw_screen :sync => false, :status => status, :title => title
    end
  end

  def draw_screen opts={}
    return if @shelled

    status, title =
      if opts.member? :status
        [opts[:status], opts[:title]]
      else
        raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false
        get_status_and_title @focus_buf # must be called outside of the ncurses lock
      end

    ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls)
    print "\033]0;#{title}\07" if title && @in_x

    Ncurses.mutex.lock unless opts[:sync] == false

    ## disabling this for the time being, to help with debugging
    ## (currently we only have one buffer visible at a time).
    ## TODO: reenable this if we allow multiple buffers
    false && @buffers.inject(@dirty) do |dirty, buf|
      buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
      #dirty ? buf.draw : buf.redraw
      buf.draw status
      dirty
    end

    ## quick hack
    if true
      buf = @buffers.last
      buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
      @dirty ? buf.draw(status) : buf.redraw(status)
    end

    draw_minibuf :sync => false unless opts[:skip_minibuf]

    @dirty = false
    Ncurses.doupdate
    Ncurses.refresh if opts[:refresh]
    Ncurses.mutex.unlock unless opts[:sync] == false
  end

  ## if the named buffer already exists, pops it to the front without
  ## calling the block. otherwise, gets the mode from the block and
  ## creates a new buffer. returns two things: the buffer, and a boolean
  ## indicating whether it's a new buffer or not.
  def spawn_unless_exists title, opts={}
    new =
      if @name_map.member? title
        raise_to_front @name_map[title] unless opts[:hidden]
        false
      else
        mode = yield
        spawn title, mode, opts
        true
      end
    [@name_map[title], new]
  end

  def spawn title, mode, opts={}
    raise ArgumentError, "title must be a string" unless title.is_a? String
    realtitle = title
    num = 2
    while @name_map.member? realtitle
      realtitle = "#{title} <#{num}>"
      num += 1
    end

    width = opts[:width] || Ncurses.cols
    height = opts[:height] || Ncurses.rows - 1

    ## since we are currently only doing multiple full-screen modes,
    ## use stdscr for each window. once we become more sophisticated,
    ## we may need to use a new Ncurses::WINDOW
    ##
    ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
    ## (opts[:left] || 0))
    w = Ncurses.stdscr
    b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
    mode.buffer = b
    @name_map[realtitle] = b

    @buffers.unshift b
    if opts[:hidden]
      focus_on b unless @focus_buf
    else
      raise_to_front b
    end
    b
  end

  ## requires the mode to have #done? and #value methods
  def spawn_modal title, mode, opts={}
    b = spawn title, mode, opts
    draw_screen

    until mode.done?
      c = Ncurses.safe_nonblocking_getch
      next unless c # getch timeout
      break if c == Ncurses::KEY_CANCEL
      begin
        mode.handle_input c
      rescue InputSequenceAborted # do nothing
      end
      draw_screen
      erase_flash
    end

    kill_buffer b
    mode.value
  end

  def kill_all_buffers_safely
    until @buffers.empty?
      ## inbox mode always claims it's unkillable. we'll ignore it.
      return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
      kill_buffer @buffers.last
    end
    true
  end

  def kill_buffer_safely buf
    return false unless buf.mode.killable?
    kill_buffer buf
    true
  end

  def kill_all_buffers
    kill_buffer @buffers.first until @buffers.empty?
  end

  def kill_buffer buf
    raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf

    buf.mode.cleanup
    @buffers.delete buf
    @name_map.delete buf.title
    @focus_buf = nil if @focus_buf == buf
    if @buffers.empty?
      ## TODO: something intelligent here
      ## for now I will simply prohibit killing the inbox buffer.
    else
      raise_to_front @buffers.last
    end
  end

  def ask_with_completions domain, question, completions, default=nil
    ask domain, question, default do |s|
      s.force_encoding 'UTF-8' if s.methods.include?(:encoding)
      completions.select { |x| x =~ /^#{Regexp::escape s}/iu }.map { |x| [x, x] }
    end
  end

  def ask_many_with_completions domain, question, completions, default=nil
    ask domain, question, default do |partial|
      prefix, target =
        case partial
        when /^\s*$/
          ["", ""]
        when /^(.*\s+)?(.*?)$/
          [$1 || "", $2]
        else
          raise "william screwed up completion: #{partial.inspect}"
        end

      prefix.force_encoding 'UTF-8' if prefix.methods.include?(:encoding)
      target.force_encoding 'UTF-8' if target.methods.include?(:encoding)
      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
    end
  end

  def ask_many_emails_with_completions domain, question, completions, default=nil
    ask domain, question, default do |partial|
      prefix, target = partial.split_on_commas_with_remainder
      target ||= prefix.pop || ""
      target.force_encoding 'UTF-8' if target.methods.include?(:encoding)

      prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
      prefix.force_encoding 'UTF-8' if prefix.methods.include?(:encoding)

      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
    end
  end

  def ask_for_filename domain, question, default=nil, allow_directory=false
    answer = ask domain, question, default do |s|
      if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
        full = $1
        name = $2.empty? ? Etc.getlogin : $2
        dir = Etc.getpwnam(name).dir rescue nil
        if dir
          [[s.sub(full, dir), "~#{name}"]]
        else
          users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
            [s.sub("~#{name}", "~#{u}"), "~#{u}"]
          end
        end
      else # regular filename completion
        Dir["#{s}*"].sort.map do |fn|
          suffix = File.directory?(fn) ? "/" : ""
          [fn + suffix, File.basename(fn) + suffix]
        end
      end
    end

    if answer
      answer =
        if answer.empty?
          spawn_modal "file browser", FileBrowserMode.new
        elsif File.directory?(answer) && !allow_directory
          spawn_modal "file browser", FileBrowserMode.new(answer)
        else
          File.expand_path answer
        end
    end

    answer
  end

  ## returns an array of labels
  def ask_for_labels domain, question, default_labels, forbidden_labels=[]
    default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
    default = default_labels.to_a.join(" ")
    default += " " unless default.empty?

    # here I would prefer to give more control and allow all_labels instead of
    # user_defined_labels only
    applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }

    answer = ask_many_with_completions domain, question, applyable_labels, default

    return unless answer

    user_labels = answer.to_set_of_symbols
    user_labels.each do |l|
      if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
        BufferManager.flash "'#{l}' is a reserved label!"
        return
      end
    end
    user_labels
  end

  def ask_for_contacts domain, question, default_contacts=[]
    default = default_contacts.is_a?(String) ? default_contacts : default_contacts.map { |s| s.to_s }.join(", ")
    default += " " unless default.empty?

    recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
    contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }

    completions = (recent + contacts).flatten.uniq
    completions += HookManager.run("extra-contact-addresses") || []
    answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default

    if answer
      answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
    end
  end

  def ask_for_account domain, question
    completions = AccountManager.user_emails
    answer = BufferManager.ask_many_emails_with_completions domain, question, completions, ""
    answer = AccountManager.default_account.email if answer == ""
    AccountManager.account_for Person.from_address(answer).email if answer
  end

  ## for simplicitly, we always place the question at the very bottom of the
  ## screen
  def ask domain, question, default=nil, &block
    raise "impossible!" if @asking
    raise "Question too long" if Ncurses.cols <= question.length
    @asking = true

    @textfields[domain] ||= TextField.new
    tf = @textfields[domain]
    completion_buf = nil

    status, title = get_status_and_title @focus_buf

    Ncurses.sync do
      tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block
      @dirty = true # for some reason that blanks the whole fucking screen
      draw_screen :sync => false, :status => status, :title => title
      tf.position_cursor
      Ncurses.refresh
    end

    while true
      c = Ncurses.safe_nonblocking_getch
      next unless c # getch timeout
      break unless tf.handle_input c # process keystroke

      if tf.new_completions?
        kill_buffer completion_buf if completion_buf

        shorts = tf.completions.map { |full, short| short }
        prefix_len = shorts.shared_prefix.length

        mode = CompletionMode.new shorts, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
        completion_buf = spawn "<completions>", mode, :height => 10

        draw_screen :skip_minibuf => true
        tf.position_cursor
      elsif tf.roll_completions?
        completion_buf.mode.roll
        draw_screen :skip_minibuf => true
        tf.position_cursor
      end

      Ncurses.sync { Ncurses.refresh }
    end

    kill_buffer completion_buf if completion_buf

    @dirty = true
    @asking = false
    Ncurses.sync do
      tf.deactivate
      draw_screen :sync => false, :status => status, :title => title
    end
    tf.value.tap { |x| x.force_encoding Encoding::UTF_8 if x && x.respond_to?(:encoding) }
  end

  def ask_getch question, accept=nil
    raise "impossible!" if @asking

    accept = accept.split(//).map { |x| x.ord } if accept

    status, title = get_status_and_title @focus_buf
    Ncurses.sync do
      draw_screen :sync => false, :status => status, :title => title
      Ncurses.mvaddstr Ncurses.rows - 1, 0, question
      Ncurses.move Ncurses.rows - 1, question.length + 1
      Ncurses.curs_set 1
      Ncurses.refresh
    end

    @asking = true
    ret = nil
    done = false
    until done
      key = Ncurses.safe_nonblocking_getch or next
      if key == Ncurses::KEY_CANCEL
        done = true
      elsif accept.nil? || accept.empty? || accept.member?(key)
        ret = key
        done = true
      end
    end

    @asking = false
    Ncurses.sync do
      Ncurses.curs_set 0
      draw_screen :sync => false, :status => status, :title => title
    end

    ret
  end

  ## returns true (y), false (n), or nil (ctrl-g / cancel)
  def ask_yes_or_no question
    case(r = ask_getch question, "ynYN")
    when ?y.ord, ?Y.ord
      true
    when nil
      nil
    else
      false
    end
  end

  ## turns an input keystroke into an action symbol. returns the action
  ## if found, nil if not found, and throws InputSequenceAborted if
  ## the user aborted a multi-key sequence. (Because each of those cases
  ## should be handled differently.)
  ##
  ## this is in BufferManager because multi-key sequences require prompting.
  def resolve_input_with_keymap c, keymap
    action, text = keymap.action_for c
    while action.is_a? Keymap # multi-key commands, prompt
      key = BufferManager.ask_getch text
      unless key # user canceled, abort
        erase_flash
        raise InputSequenceAborted
      end
      action, text = action.action_for(key) if action.has_key?(key)
    end
    action
  end

  def minibuf_lines
    @minibuf_mutex.synchronize do
      [(@flash ? 1 : 0) +
       (@asking ? 1 : 0) +
       @minibuf_stack.compact.size, 1].max
    end
  end

  def draw_minibuf opts={}
    m = nil
    @minibuf_mutex.synchronize do
      m = @minibuf_stack.compact
      m << @flash if @flash
      m << "" if m.empty? unless @asking # to clear it
    end

    Ncurses.mutex.lock unless opts[:sync] == false
    Ncurses.attrset Colormap.color_for(:none)
    adj = @asking ? 2 : 1
    m.each_with_index do |s, i|
      Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
    end
    Ncurses.refresh if opts[:refresh]
    Ncurses.mutex.unlock unless opts[:sync] == false
  end

  def say s, id=nil
    new_id = nil

    @minibuf_mutex.synchronize do
      new_id = id.nil?
      id ||= @minibuf_stack.length
      @minibuf_stack[id] = s
    end

    if new_id
      draw_screen :refresh => true
    else
      draw_minibuf :refresh => true
    end

    if block_given?
      begin
        yield id
      ensure
        clear id
      end
    end
    id
  end

  def erase_flash; @flash = nil; end

  def flash s
    @flash = s
    draw_screen :refresh => true
  end

  ## a little tricky because we can't just delete_at id because ids
  ## are relative (they're positions into the array).
  def clear id
    @minibuf_mutex.synchronize do
      @minibuf_stack[id] = nil
      if id == @minibuf_stack.length - 1
        id.downto(0) do |i|
          break if @minibuf_stack[i]
          @minibuf_stack.delete_at i
        end
      end
    end

    draw_screen :refresh => true
  end

  def shell_out command
    @shelled = true
    Ncurses.sync do
      Ncurses.endwin
      system command
      Ncurses.stdscr.keypad 1
      Ncurses.refresh
      Ncurses.curs_set 0
    end
    @shelled = false
  end

private

  def default_status_bar buf
    " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
  end

  def default_terminal_title buf
    "Sup #{Redwood::VERSION} :: #{buf.title}"
  end

  def get_status_and_title buf
    opts = {
      :num_inbox => lambda { Index.num_results_for :label => :inbox },
      :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] },
      :num_total => lambda { Index.size },
      :num_spam => lambda { Index.num_results_for :label => :spam },
      :title => buf.title,
      :mode => buf.mode.name,
      :status => buf.mode.status
    }

    statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf)
    term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf)

    [statusbar_text, term_title_text]
  end

  def users
    unless @users
      @users = []
      while(u = Etc.getpwent)
        @users << u.name
      end
    end
    @users
  end
end
end
