require "bundler/setup"
require "pathological"

require "bourbon"
require "coffee-script"
require "methodchain"
require "nokogiri"
require "open-uri"
require "pinion"
require "pinion/sinatra_helpers"
require "redcarpet"
require "redis"
require "sass"
require "sinatra/base"
require "sinatra/reloader"
require "uglifier"
require "set"

require "environment"
require "lib/ruby_extensions"
require "lib/git_helper"
require "lib/git_diff_utils"
require "lib/oauth2_provider"
require "lib/keyboard_shortcuts"
require "lib/meta_repo"
require "lib/openid_provider"
require "lib/pretty_date"
require "lib/script_environment"
require "lib/stats"
require "lib/statusz"
require "lib/string_helper"
require "lib/string_filter"
require "lib/filters"
require "lib/inspire"
require "lib/redis_manager"
require "lib/redcarpet_extensions"
require "lib/mustache_renderer"
require "resque_jobs/deliver_review_request_emails.rb"

NODE_MODULES_BIN_PATH = "./node_modules/.bin"
UNAUTHENTICATED_ROUTES = ["/signin", "/signout", "/inspire", "/statusz", "/api/"]
# NOTE(philc): Currently we let you see previews of individual commits and the code review stats without
# being logged in, as a friendly UX. When we flesh out our authorization model, we should intentionally make
# this configurable.
UNAUTHENTICATED_PREVIEW_ROUTES = ["/commits/", "/stats"]

class BarkeepServer < Sinatra::Base
  attr_accessor :current_user

  # Set up Pinion to manage static and compiled assets.
  set :pinion, Pinion::Server.new("/assets")
  configure do
    pinion.convert :scss => :css
    pinion.convert :coffee => :js
    pinion.watch "public"
    pinion.watch "#{Gem.loaded_specs["bourbon"].full_gem_path}/app/assets/stylesheets"

    # Set up asset bundles
    pinion.create_bundle :vendor_js, :concatenate_and_uglify_js, [
      "/vendor/jquery-1.7.min.js",
      "/vendor/jquery-ui-1.8.19.custom.min.js",
      "/vendor/jquery.json-2.2.min.js",
      "/vendor/jquery.tipsy.js",
      "/vendor/jquery.hotkeys.js"
    ]
    pinion.create_bundle :app_js, :concatenate_and_uglify_js, [
      "/coffee/constants.js",
      "/coffee/util.js",
      "/coffee/snippets.js"
    ]
    pinion.create_bundle :repo_app_js, :concatenate_and_uglify_js, ["/coffee/repos.js"]
    pinion.create_bundle :users_admin_js, :concatenate_and_uglify_js, ["/coffee/users_admin.js"]
    pinion.create_bundle :commit_app_js, :concatenate_and_uglify_js, ["/coffee/commit.js"]
    pinion.create_bundle :commit_vendor_js, :concatenate_and_uglify_js, [
      "/vendor/jquery.easing.1.3.js",
      "/vendor/mustache.js"
    ]
    pinion.create_bundle :commit_search_app_js, :concatenate_and_uglify_js, [
      "/coffee/smart_search.js",
      "/coffee/commit_search.js"
    ]
    pinion.create_bundle :commit_search_vendor_js, :concatenate_and_uglify_js, [
      "/vendor/jquery.easing.1.3.js"
    ]
    pinion.create_bundle :stats_app_js, :concatenate_and_uglify_js, ["/coffee/stats.js"]
    pinion.create_bundle :stats_vendor_js, :concatenate_and_uglify_js, [
      "/vendor/flot/jquery.flot.min.js",
      "/vendor/flot/jquery.flot.pie.min.js"
    ]
    pinion.create_bundle :user_settings_app_js, :concatenate_and_uglify_js, ["/coffee/user_settings.js"]
  end

  helpers Pinion::SinatraHelpers

  # Pinion will handle all static routes
  disable :static
  set :views, "views"
  enable :sessions

  # Session hijacking protection breaks Chrome sessions and offers little protection anyway
  set :protection, except: :session_hijacking

  configure :development do
    STDOUT.sync = true # Flush any output right away when running via Foreman.
    enable :logging
    set :show_exceptions, false
    set :dump_errors, false
    set :session_secret, COOKIE_SESSION_SECRET if defined?(COOKIE_SESSION_SECRET)

    GitDiffUtils.setup(RedisManager.redis_instance)

    error do
      # Show a more developer-friendly error page and stack traces.
      content_type "text/plain"
      error = request.env["sinatra.error"]
      message = ([error.class, error.message] + error.backtrace).join("\n")
      puts message
      message
    end

    register Sinatra::Reloader
    also_reload "lib/*.rb"
    also_reload "models/*.rb"
    also_reload "environment.rb"
    also_reload "resque_jobs/*.rb"
  end

  configure :test do
    set :show_exceptions, false
    set :dump_errors, false
    GitDiffUtils.setup(nil)
  end

  configure :production do
    set :logging, Logger::INFO
    set :session_secret, COOKIE_SESSION_SECRET if defined?(COOKIE_SESSION_SECRET)
    GitDiffUtils.setup(RedisManager.redis_instance)
  end

  # Register the authentication provider specified by AUTHENTICATION_PROTOCOL.
  AUTHENTICATION_PROVIDERS = {"openid" => OpenidProvider, "oauth2" => OAuth2Provider }
  authentication_provider = AUTHENTICATION_PROVIDERS[AUTHENTICATION_PROTOCOL]
  register authentication_provider

  helpers do
    def current_page_if_url(text) request.url.include?(text) ? "currentPage" : "" end

    def find_commit(repo_name, sha, zero_commits_ok)
      commit = MetaRepo.instance.db_commit(repo_name, sha)
      unless commit
        begin
          commit = Commit.prefix_match(repo_name, sha, zero_commits_ok)
        rescue RuntimeError => e
          halt 404, e.message
        end
      end
      commit
    end

    def ensure_required_params(*required_params)
      required_params.each do |param|
        unless params[param] && !params[param].strip.empty?
          message = "Missing required parameter '#{param}'."
          if content_type == "application/json"
            halt 400, { :error => message }.to_json
          else
            halt 400, message
          end
        end
      end
    end
  end

  before do
    # When running in read-only demo mode, if the user is not logged in, treat them as a demo user.
    self.current_user ||= User.find(:email => session[:email])
    if current_user.nil? && (defined?(ENABLE_READONLY_DEMO_MODE) && ENABLE_READONLY_DEMO_MODE)
      self.current_user = User.first(:permission => "demo")
      current_user.rack_session = session
      # Setting this to false silences the exception that Sequel generates when we cancel the default save
      # behavior for demo searches.
      SavedSearch.raise_on_save_failure = false
    else
      SavedSearch.raise_on_save_failure = true
    end

    next if UNAUTHENTICATED_ROUTES.any? { |route| request.path =~ /^#{route}/ }
    if (!defined?(PERMITTED_USERS) || PERMITTED_USERS.empty?) &&
      UNAUTHENTICATED_PREVIEW_ROUTES.any? { |route| request.path =~ /^#{route}/ }
      next
    end

    if current_user
      halt 401, "This account has been deleted." if current_user.deleted?
    else
      # TODO(philc): Revisit this UX. Dumping the user into Google with no explanation is not what we want.

      # Save url to return to it after login completes.
      session[:login_started_url] = request.url
      redirect authentication_provider::signin_url(request, session)
    end
  end

  get("/favicon.ico") { redirect asset_url("favicon.ico") }

  get("/") { redirect "/commits" }

  get "/signin" do
    session.clear
    session[:login_started_url] = request.referrer
    redirect authentication_provider::signin_url(request, session)
  end

  get "/signout" do
    session.clear
    redirect request.referrer
  end

  get("/settings") { erb :user_settings }

  put "/settings/:preference" do
    preference = params[:preference]
    if preference == "displayname"
      current_user.name = params[:value]
      current_user.save
    elsif ["line_length", "default_to_side_by_side"].include? preference
      current_user.send :"#{preference}=", params[:value]
      current_user.save
    else
      halt 400, "Bad preference."
    end
    nil
  end

  get "/commits" do
    erb :commit_search, :locals => { :saved_searches => current_user ? current_user.saved_searches : [] }
  end

  # get the one commit that the user is looking for.
  get "/commits/search/by_sha" do
    ensure_required_params :sha
    partial_sha = params[:sha]

    repos = MetaRepo.instance.repos.map(&:name)

    repo_name, sha = repos.each do |repo|
      commit = find_commit(repo, partial_sha, true)
      if commit
        break [repo, commit.sha]
      end
    end

    if sha
      redirect "/commits/#{repo_name}/#{sha}"
    else
      halt 404, "No such sha #{partial_sha}"
    end
  end

  get "/commits/:repo_name/:sha" do
    MetaRepo.instance.scan_for_new_repos
    repo_name = params[:repo_name]
    sha = params[:sha]
    halt 404, "No such repository: #{repo_name}" unless GitRepo[:name => repo_name]
    commit = MetaRepo.instance.db_commit(repo_name, sha)
    unless commit
      begin
        commit = Commit.prefix_match(repo_name, sha)
      rescue RuntimeError => e
        halt 404, e.message
      end
      redirect "/commits/#{repo_name}/#{commit.sha}"
    end
    tagged_diff = GitDiffUtils::get_tagged_commit_diffs(repo_name, commit.grit_commit,
        :use_syntax_highlighting => true)
    erb :commit, :locals => { :tagged_diff => tagged_diff, :commit => commit }
  end

  get "/comment_form" do
    erb :_comment_form, :layout => false, :locals => {
      :repo_name => params[:repo_name],
      :sha => params[:sha],
      :filename => params[:filename],
      :line_number => params[:line_number]
    }
  end

  # I'm using POST even though this is idempotent to avoid massive urls.
  post "/comment_preview" do
    ensure_required_params :text
    commit = MetaRepo.instance.db_commit(params[:repo_name], params[:sha])
    halt 400, "No such commit." unless commit
    Comment.new(:text => params[:text], :commit => commit).filter_text
  end

  post "/comment" do
    if params[:comment_id]
      comment = validate_comment(params[:comment_id])
      comment.text = params[:text]
      comment.save
      next comment.filter_text
    end
    begin
      comment = create_comment(*[:repo_name, :sha, :filename, :line_number, :text].map { |f| params[f] })
    rescue RuntimeError => e
      halt 400, e.message
    end
    erb :_comment, :layout => false, :locals => { :comment => comment }
  end

  post "/delete_comment" do
    comment = validate_comment(params[:comment_id])
    comment.destroy
    nil
  end

  post "/approve_commit" do
    commit = MetaRepo.instance.db_commit(params[:repo_name], params[:commit_sha])
    halt 400 unless commit
    commit.approve(current_user)
    erb :_approved_banner, :layout => false, :locals => { :commit => commit }
  end

  post "/disapprove_commit" do
    commit = MetaRepo.instance.db_commit(params[:repo_name], params[:commit_sha])
    halt 400 unless commit
    commit.disapprove
    nil
  end

  # Saves changes to the user-level search options.
  post "/user_search_options" do
    saved_search_time_period = params[:saved_search_time_period].to_i
    # TODO(philc): We should move this into the model's validations.
    current_user.saved_search_time_period = saved_search_time_period
    current_user.save
    nil
  end

  # POST because this creates a saved search on the server.
  post "/search" do
    MetaRepo.instance.scan_for_new_repos
    options = {}
    [:repos, :authors, :messages].each do |option|
      options[option] = params[option].then { strip.empty? ? nil : strip }
    end
    # Paths is a list
    options[:paths] = params[:paths].to_json if params[:paths] && !params[:paths].empty?
    # Default to only searching master unless branches are explicitly specified.
    options[:branches] = params[:branches].else { "master" }.then { self == "all" ? nil : self }
    saved_search = current_user.new_saved_search(options)
    saved_search.save
    erb :_saved_search, :layout => false, :locals => { :current_user => current_user,
      :saved_search => saved_search, :token => nil, :direction => "before", :page_number => 1 }
  end

  # Gets a page of a saved search.
  # - token: a paging token representing the current page.
  # - direction: the direction of the page to fetch -- either "before" or "after" the given page token.
  # - current_page_number: the current page number the client is showing. This page number is for display
  #   purposes only, because new commits which have been recently ingested will make the page number
  #   inaccurate.
  get "/saved_searches/:id" do
    MetaRepo.instance.scan_for_new_repos
    saved_search = current_user.find_saved_search(params[:id].to_i)
    halt 400, "Bad saved search id." unless saved_search
    token = params[:token] && !params[:token].empty? ? params[:token] : nil
    direction = params[:direction] || "before"
    page_number = params[:current_page_number].to_i + (direction == "before" ? 1 : -1)
    page_number = [page_number, 1].max
    erb :_saved_search, :layout => false, :locals => { :current_user => current_user,
      :saved_search => saved_search, :token => token, :direction => direction, :page_number => page_number }
  end

  # Change the order of saved searches.
  # I'm sure there's a more RESTFUl way to do this call.
  post "/saved_searches/reorder" do
    searches = JSON.parse(request.body.read)
    previous_searches = current_user.saved_searches
    halt 401, "Mismatch in the number of saved searches" unless searches.size == previous_searches.size
    previous_searches.each do |search|
      search.user_order = searches.index(search.id)
      search.save
    end
    nil
  end

  delete "/saved_searches/:id" do
    id = params[:id].to_i
    current_user.delete_saved_search(params[:id].to_i)
    nil
  end

  # Toggles the "unapproved_only" checkbox and renders the first page of the saved search.
  post "/saved_searches/:id/search_options" do
    saved_search = current_user.find_saved_search(params[:id].to_i)
    body_params = JSON.parse(request.body.read)
    [:unapproved_only, :email_commits, :email_comments].each do |setting|
      saved_search.send("#{setting}=", body_params[setting.to_s]) unless body_params[setting.to_s].nil?
    end
    saved_search.save
    nil
  end

  get "/stats" do
    # TODO(dmac): Allow users to change range of stats page without logging in.
    stats_time_range = current_user ? current_user.stats_time_range : "month"
    since = case stats_time_range
            when "hour" then Time.now - 60 * 60
            when "day" then Time.now - 60 * 60 * 24
            when "week" then Time.now - 60 * 60 * 24 * 7
            when "month" then Time.now - 60 * 60 * 24 * 30
            when "year" then Time.now - 60 * 60 * 24 * 30 * 365
            when "all" then Time.at(0)
            else Time.at(0)
            end
    num_commits = Stats.num_commits(since)
    erb :stats, :locals => {
      :num_commits => num_commits,
      :unreviewed_percent => Stats.num_unreviewed_commits(since).to_f / num_commits,
      :commented_percent => Stats.num_reviewed_without_lgtm_commits(since).to_f / num_commits,
      :approved_percent => Stats.num_lgtm_commits(since).to_f / num_commits,
      :chatty_commits => Stats.chatty_commits(since),
      :top_reviewers => Stats.top_reviewers(since),
      :top_approvers => Stats.top_approvers(since)
    }
  end

  post "/set_stats_time_range" do
    halt 400 unless ["hour", "day", "week", "month", "year", "all"].include? params[:since]
    current_user.stats_time_range = params[:since]
    current_user.save
    redirect "/stats"
  end

  get "/profile/:id" do
    user = User[params[:id]]
    halt 404 unless user
    erb :profile, :locals => { :user => user }
  end

  get "/inspire/?" do
    erb :inspire, :locals => { :quote => Inspire.new.quote }
  end

  get %r{/statusz$} do
    statusz_file = File.join(settings.root, "statusz.html")
    File.file?(statusz_file) ? send_file(statusz_file) : "No deploy data."
  end

  post "/request_review" do
    next nil if current_user.demo?
    commit = Commit.first(:sha => params[:sha])
    halt 404 unless commit
    emails = params[:emails].split(",").map(&:strip).reject(&:empty?)
    Resque.enqueue(DeliverReviewRequestEmails, commit.git_repo.name, commit.sha, current_user.email, emails)
    nil
  end

  #
  # Routes for autocompletion.
  #

  get "/autocomplete/authors" do
    users = User.filter("`email` LIKE ?", "%#{params[:substring]}%").
        or("`name` LIKE ?", "%#{params[:substring]}%").distinct(:email).limit(10)
    { :values => users.map { |user| "#{user.name} <#{user.email}>" } }.to_json
  end

  get "/autocomplete/repos" do
    repo_names =  MetaRepo.instance.repos.map {|repo| repo.name}
    { :values => repo_names.select{ |name| name.include?(params[:substring]) } }.to_json
  end

  # Branch autocompletion only works if the query is already scoped to a repo via the "repo:" keyword.
  get "/autocomplete/branches" do
    branch_names = Set.new
    repo_names = params[:repos].split(",").map(&:strip)
    repo_names.each do |repo_name|
      repo = MetaRepo.instance.get_grit_repo(repo_name)
      next unless repo
      repo.remotes.each do |remote|
        branch_name = remote.name.sub("origin/", "")
        branch_names.add(branch_name) unless branch_name == "HEAD"
      end
    end
    { :values => branch_names.to_a.select { |name| name.include?(params[:substring]) }}.to_json
  end

  #
  # Routes used for development purposes.
  #

  # For testing and styling emails.
  # - send_email: set to true to actually send the email for this comment.
  get "/dev/latest_comment_email_preview" do
    comment = Comment.order(:id.desc).first
    next "No comments have been created yet." unless comment
    Emails.send_comment_email(comment.commit, [comment]) if params[:send_email] == "true"
    Emails.comment_email_body(comment.commit, [comment])
  end

  # For testing and styling emails.
  # - send_email: set to true to actually send the email for this commit.
  # - commit: the sha of the commit you want to preview.
  get "/dev/latest_commit_email_preview" do
    commit = params[:commit] ? Commit.first(:sha => params[:commit]) : Commit.order(:id.desc).first
    next "No commits have been created yet." unless commit
    Emails.send_commit_email(commit) if params[:send_email] == "true"
    Emails.commit_email_body(commit)
  end

  private

  def logged_in?() current_user && !current_user.demo? end

  def create_comment(repo_name, sha, filename, line_number_string, text)
    commit = MetaRepo.instance.db_commit(repo_name, sha)
    raise "No such commit." unless commit
    file = nil
    if filename && !filename.empty?
      file = commit.commit_files_dataset.filter(:filename => filename).first
      file ||= CommitFile.new(:filename => filename, :commit => commit).save
    end
    line_number = (line_number_string && !line_number_string.empty?) ? line_number_string.to_i : nil
    comment = Comment.create(
      :commit => commit,
      :commit_file => file,
      :line_number => line_number,
      :user => current_user,
      :text => text,
      :has_been_emailed => current_user.demo?) # Don't email comments made by demo users.
    comment
  end

  def validate_comment(comment_id)
    comment = Comment[comment_id]
    halt 404, "This comment no longer exists." unless comment
    halt 403, "Comment not originated from this user." unless comment.user.id == current_user.id
    comment
  end
end

# These are extra routes. Require them after the main routes and before filters have been defined, so
# Sinatra's before filters run in the order you would expect.
require "lib/api_routes"
require "lib/admin_routes"
