require 'json'
require 'set'
require 'bpm/version'

module BPM

  class Project < Package

    DEFAULT_CONFIG_PATH = File.expand_path('../default.json', __FILE__)
    DEFAULT_CONFIG = JSON.load File.read(DEFAULT_CONFIG_PATH)
    BPM_DIR = '.bpm'

    def self.project_file_path(path)
      Dir[File.join(path, '*.json')].find{|p| is_project_json?(p) }
    end

    def self.is_project_json?(path)
      json = JSON.load(File.read(path)) rescue nil
      return !!(json && json["bpm"])
    end

    def self.is_project_root?(path)
      !!project_file_path(path) ||
      File.exists?(File.join(path, 'assets', 'bpm_libs.js'))
    end

    def self.nearest_project(path)
      path = File.expand_path path

      last = nil
      while path != last
        return new(path) if is_project_root?(path)
        last = path
        path = File.dirname path
      end
      nil
    end

    attr_reader :name

    def initialize(root_path, name=nil)
      super root_path

      if !name
        # If no name, try to find project json and get name from it
        project_file = self.class.project_file_path(root_path)
        name = File.basename(project_file, '.json') if project_file
      else
        project_file = File.join root_path, "#{name}.json"
      end

      @name = name || File.basename(root_path)
      @json_path = project_file || File.join(root_path, "#{File.basename(root_path)}.json")

      load_json && validate
    end

    def bpm
      @bpm || BPM::COMPAT_VERSION
    end

    def validate_name_and_path
      # do nothing. never an error
    end

    # Returns a fully normalized hash of build settings
    def build_settings(mode=:debug)
      ret = {}
      deps = mode == :debug ? sorted_deps : sorted_runtime_deps
      deps.each { |dep| merge_build_settings(ret, dep, mode) }
      merge_build_settings ret, self, mode, false
      ret
    end

    def all_dependencies
      deps = dependencies.merge(dependencies_development || {})
      deps.merge(dependencies_build)
    end

    def vendor_root
      File.join @root_path, 'vendor'
    end

    def vendored_projects
      @vendored_projects ||= begin
        Dir.glob(File.join(vendor_root, '*')).
          select{|p| Project.is_project_root?(p) }.
          map{|p| Project.new(p) }
      end
    end

    def vendored_packages
      @vendored_packages ||= begin
        search_paths = [vendor_root, File.join(@root_path, 'packages')]
        paths = search_paths.map{|p| Dir.glob(File.join(p, '*')) }.flatten
        pkgs = paths.select{|p| Package.is_package_root?(p) }.map{|p| Package.new(p) }
        pkgs += vendored_projects.map{|p| p.vendored_packages }.flatten
        pkgs.select do |p|
          begin
            p.load_json
          rescue BPM::InvalidPackageError => e
            raise e if ENV['DEBUG']
            false
          end
        end
      end
    end

    def find_vendored_project(name)
      vendored_projects.find{|p| p.name == name }
    end

    def find_vendored_package(name)
      vendored_packages.find{|p| p.name == name }
    end

    def package_manifest_path
      File.join(@root_path, BPM_DIR, 'package_manifest.json')
    end

    def assets_path
      'assets'
    end

    def assets_root(*paths)
      File.join @root_path, assets_path, *paths
    end

    def preview_root(*paths)
      File.join @root_path, '.bpm', 'preview', *paths
    end

    def build_app?
      # Make sure we have some lib files
      !!(bpm_build &&
          bpm_build["bpm_libs.js"] &&
          ((bpm_build["bpm_libs.js"]["files"] && bpm_build["bpm_libs.js"]["files"].size>0) ||
            (bpm_build["bpm_libs.js"]["directories"] && bpm_build["bpm_libs.js"]["directories"].size>0)))
    end

    def build_app=(value)
      bpm_libs = "bpm_libs.js"
      bpm_styles = "bpm_styles.css"

      if value
        bpm_build[bpm_libs] ||= {}
        hash = bpm_build[bpm_libs]
        hash['files'] ||= hash['directories'] || []
        hash['files'] << 'lib' if hash['files'].size==0
        hash['minifier']    ||= 'uglify-js'

        bpm_build[bpm_styles] ||= {}
        hash = bpm_build[bpm_styles]
        hash['files'] ||= hash['directories'] || []
        hash['files'] << 'css' if hash['files'].size==0

        directories ||= {}
        directories['lib'] ||= ['lib']
      else
        bpm_build[bpm_libs]['files'] = []
        bpm_build[bpm_styles]['files'] = []
      end
      value
    end

    # returns array of all assets that should be generated for this project
    def buildable_asset_filenames(mode)
      build_settings(mode).keys.reject { |x| !(x =~ /\..+$/) }
    end

    # Validates that all required files are present in the project needed
    # for compile to run.  This will not fetch new dependencies from remote.
    def verify_and_repair(mode=:debug, verbose=false)
      valid_deps = find_dependencies rescue nil
      fetch_dependencies(verbose) if valid_deps.nil?
      rebuild_dependency_list nil, verbose
      rebuild_preview verbose
    end

    def rebuild_preview(verbose=false)

      needs_rebuild = true

      if File.directory?(preview_root)
        cur_previews  = Dir[preview_root('**', '*')].sort.reject { |x| File.directory?(x) }
        exp_filenames = buildable_asset_filenames(:debug)
        exp_previews  = exp_filenames.map { |x| preview_root(x) }.sort
        needs_rebuild = cur_previews != exp_previews
      end

      if needs_rebuild
        FileUtils.rm_r preview_root if File.exists? preview_root
        buildable_asset_filenames(:debug).each do |filename|
          next if File.exists? preview_root(filename)
          FileUtils.mkdir_p File.dirname(preview_root(filename))
          FileUtils.touch preview_root(filename)
        end
      end

    end

    # Add a new dependency
    #
    # Adds to the project json and installs dependency

    def add_dependencies(new_deps, development=false, verbose=false)
      old_deps  = build_local_dependency_list(false) || []
      hard_deps = (development ? dependencies_development : dependencies).merge(new_deps)
      all_hard_deps = all_dependencies.merge(new_deps)
      exp_deps = find_non_local_dependencies(all_hard_deps, true)

      say "Fetching packages from remote..." if verbose
      core_fetch_dependencies(exp_deps, verbose)

      if development
        self.dependencies_development = hard_deps
      else
        self.dependencies = hard_deps
      end

      rebuild_dependency_list(all_hard_deps, verbose)

      local_deps.each do |dep|
        next if old_deps.find { |pkg| (pkg.name == dep.name) && (pkg.version == dep.version) }
        say "Added #{development ? "development " : ""}package '#{dep.name}' (#{dep.version})"
      end

      save!
    end


    # Remove a dependency
    #
    # Remove dependency from json. Does not remove from system.

    def remove_dependencies(package_names, verbose=false)

      hard_deps = dependencies.dup
      old_deps = build_local_dependency_list(false)

      package_names.each do |pkg_name|
        raise "'#{pkg_name}' is not a dependency" if hard_deps[pkg_name].nil?
        hard_deps.delete pkg_name
      end

      @dependencies = hard_deps
      rebuild_dependency_list hard_deps, verbose

      old_deps.each do |dep|
        next if local_deps.find { |pkg| (pkg.name == dep.name) && (pkg.version == dep.version) }
        say "Removed package '#{dep.name}' (#{dep.version})"
      end

      save!

    end


    # Save to json

    def save!
      @had_changes = false
      File.open @json_path, 'w+' do |fd|
        fd.write JSON.pretty_generate as_json
      end
    end


    # Get dependencies from server if not installed

    def fetch_dependencies(verbose=false)
      say "Fetching packages from remote..." if verbose
      exp_deps = find_non_local_dependencies(all_dependencies, true)
      core_fetch_dependencies(exp_deps, verbose)
    end


    # Builds assets directory for dependent packages

    def build(mode=:debug, verbose=false)

      verify_and_repair mode, verbose

      say "Building static assets..." if verbose

      report_package_locations if verbose

      # Seed the project with any required files to ensure they are built
      buildable_asset_filenames(mode).each do |filename|
        dst_path = assets_root filename
        next if File.exists? dst_path
        FileUtils.mkdir_p File.dirname(dst_path)
        FileUtils.touch dst_path
      end

      pipeline = BPM::Pipeline.new self, mode
      pipeline.buildable_assets.each do |asset|
        dst_path = assets_root asset.logical_path
        FileUtils.mkdir_p File.dirname(dst_path)

        if asset.kind_of? Sprockets::StaticAsset
          say "~ Copying #{asset.logical_path}" if verbose
          FileUtils.rm dst_path if File.exists? dst_path
          FileUtils.cp asset.pathname, dst_path
        else
          $stdout << "~ Building #{asset.logical_path}..." if verbose
          File.open(dst_path, 'w+') { |fd| fd << asset.to_s }
          if verbose
            gzip_size = LibGems.gzip(asset.to_s).bytesize
            gzip_size = gzip_size < 1024 ? "#{gzip_size} bytes" : "#{gzip_size / 1024} Kb"
            $stdout << " (gzipped size: #{gzip_size})\n"
          end
        end
      end

      say "\n" if verbose
    end


    # Removes any built assets from the project.  Usually called before a
    # package is removed from the project to cleanup any assets

    def unbuild(verbose=false)
      say "Removing stale assets..." if verbose

      pipeline = BPM::Pipeline.new self
      asset_root = File.join root_path, 'assets'
      pipeline.buildable_assets.each do |asset|
        next if asset.logical_path =~ /^bpm_/
        asset_path = File.join asset_root, asset.logical_path
        next unless File.exists? asset_path
        say "~ Removing #{asset.logical_path}" if verbose
        FileUtils.rm asset_path

        # cleanup empty directories
        while !File.exists?(asset_path)
          asset_path = File.dirname asset_path
          FileUtils.rmdir(asset_path) if File.directory?(asset_path)
          if verbose && !File.exists?(asset_path)
            say "~ Removed empty directory #{File.basename asset_path}"
          end
        end
      end

      say "\n" if verbose
    end


    # Find package with name

    def package_from_name(package_name)
      return self if package_name == self.name
      local_deps.find { |pkg| pkg.name == package_name }
    end


    # Returns the path on disk to reach a given package name

    def path_from_package(package_name)
      ret = package_from_name package_name
      ret && ret.root_path
    end


    # Returns the path on disk for a given module id (relative to the project)

    def path_from_module(module_path)
      path_parts   = module_path.to_s.split '/'
      package_name = path_parts.shift
      module_path = path_from_package(package_name)

      if module_path
        # expand package_name => package_name/main
        path_parts = ['main'] if path_parts.size == 0

        # expand package_name/~dirname => package_name/mapped_dirname
        dirname = (path_parts.first && path_parts.first =~ /^~/) ?
                    path_parts.shift[1..-1] : 'lib'

        pkg = BPM::Package.new(module_path)
        pkg.load_json
        dirname = (pkg && pkg.directories[dirname]) || dirname

        # join the rest of the path
        module_path = [package_name, dirname, *path_parts] * '/'
      end

      module_path
    end


    # Returns the package object and module id for the path. Path must match
    # a package known to the project.

    def package_and_module_from_path(path)
      path = File.expand_path path.to_s
      pkg = local_deps.find {|cur| path =~ /^#{Regexp.escape cur.root_path.to_s}\//}
      pkg = self if pkg.nil? && path =~ /^#{Regexp.escape root_path.to_s}/
      raise "#{path} is not within a known package" if pkg.nil?

      dir_name = nil
      pkg.directories.each do |dname, dpath|
        dpaths = Array(dpath).map{|d| File.expand_path(d, pkg.root_path) }
        dpaths.each do |d|
          # Find a match and see if we can replace
          if path.gsub!(/^#{Regexp.escape(d)}\//, "#{dname}/")
            dir_name = dname
            break
          end
        end
        break if dir_name
      end

      if dir_name
        parts = path.split("/")
      else
        parts = Pathname.new(path).relative_path_from(Pathname.new(pkg.root_path)).to_s.split("/")
        dir_name = parts.first
      end

      if dir_name == 'lib'
        parts.shift
      else
        parts[0] = "~#{dir_name}"
      end

      parts[parts.size-1] = File.basename(parts.last, '.*')
      [pkg, parts.join('/')]
    end


    # List local dependency names, rebuilds list first time

    def local_deps
      @local_deps ||= build_local_dependency_list
    end

    def map_to_packages(deps)
      Array(deps).map do |dep_name, vers|
        local_deps.find { |x| x.name==dep_name }
      end
    end

    # List of local dependency names in order of dependency

    def sorted_deps
      dep_names = (dependencies.keys + dependencies_development.keys).uniq
      ret       = []

      dep_names.each do |dep_name|
        dep = local_deps.find { |x| x.name == dep_name }
        add_sorted_dep(dep, local_deps, :both, ret)
      end
      ret
    end

    def sorted_runtime_deps

      dep_names = dependencies.map { |name, vers| name }
      deps = local_deps.reject { |dep| !dep_names.include?(dep.name) }

      deps.inject([]) do |ret, dep|
        add_sorted_dep(dep, local_deps, :runtime, ret)
        ret
      end

    end

    def sorted_development_deps
      dep_names = dependencies_development.map { |name, vers| name }
      deps = local_deps.reject { |dep| !dep_names.include?(dep.name) }

      deps = deps.inject([]) do |ret, dep|
        add_sorted_dep(dep, local_deps, :both, ret)
        ret
      end

      # development deps should include all dependencies of dev deps excluding
      # those that are bonafide runtime deps
      runtime_deps = sorted_runtime_deps
      deps.reject! { |pkg| runtime_deps.include?(pkg) }
      deps
    end


    # Verifies that packages are available to meet all the dependencies
    def rebuild_dependency_list(deps=nil, verbose=false)
      found = find_dependencies(deps, verbose)

      json = {}
      found.each do |f|
        json[f.name] = { :version => f.version.to_s, :path => f.root_path }
      end

      FileUtils.mkdir_p(File.dirname(package_manifest_path))
      File.open(package_manifest_path, 'w') do |f|
        f.puts JSON.pretty_generate(json)
      end

      @local_deps = nil
    end


    # Hash for conversion to json

    def as_json
      json = super
      json["bpm"] = self.bpm
      json
    end


    # Name of minifier

    def minifier_name(asset_name)
      build_settings[asset_name] &&
        build_settings[asset_name]['bpm:provides'] &&
        build_settings[asset_name]['bpm:provides']['minifier']
    end

    def load_json
      return super if has_json?
      (FIELDS.keys + %w(description summary homepage)).each do |f|
        send("#{c2u(f)}=", DEFAULT_CONFIG[f])
      end

      self.name = File.basename(@json_path, '.json')
      self.version = "0.0.1"
      true
    end

    # Tell if package is vendored

    def has_local_package?(package_name)
      !!find_vendored_package(package_name)
    end




  private

    # Make sure fields are set up properly

    def validate_fields
      # TODO: Define other fields that are required for projects
      %w[name].all? do |field|
        value = send(field)
        if value.nil? || value.size.zero?
          add_error "Projects requires a '#{field}' field"
        else
          true
        end
      end
    end


    # builds a set of dependencies that excludes locally installed packages
    # and includes their dependencies instead.

    def find_non_local_dependencies(deps, verbose)
      search_list = Array(deps)
      seen = []
      ret = {}

      until search_list.empty?
        name, version = search_list.shift
        version = check_version(version)
        next if seen.include?(name)
        seen << name

        pkg = find_vendored_package(name)
        if pkg
          pkg.load_json

          unless satisfied_by?(version, pkg.version)
            raise LocalPackageConflictError.new(pkg.name, version, pkg.version)
          end

          search_list += Array(pkg.dependencies)
          search_list += Array(pkg.dependencies_development)
          search_list += Array(pkg.dependencies_build)
        else
          ret[name] = version
        end
      end

      ret
    end


    # Fetch any dependencies into local cache for the passed set of deps

    def core_fetch_dependencies(deps, verbose)
      deps.each do |pkg_name, pkg_version|
        core_fetch_dependency pkg_name, pkg_version, :runtime, verbose
      end
    end


    # Fetch a single dependency into local cache

    def core_fetch_dependency(package_name, vers, type, verbose)
      vers = check_version(vers)
      prerelease = false
      if vers == '>= 0.pre'
        prerelease = true
        vers = '>= 0'
      else
        prerelease = vers =~ /[a-zA-Z]/
      end

      dep = LibGems::Dependency.new(package_name, vers, type)
      cur_installed = LibGems.source_index.search(dep)

      begin
        installed = BPM::Remote.new.install(package_name, vers, prerelease)
      rescue LibGems::GemNotFoundException
        # If we have it locally but not remote, that's ok
        installed = []
      end

      cur_installed.each do |ci|
        installed.reject! { |i| ci.name == i.name && ci.version == i.version }
      end

      installed.each do |i|
        say "~ Fetched #{i.name} (#{i.version}) from remote" if verbose
      end

    end


    # Get list of local dep names from the .bpm directory
    #
    # Pass +false+ to prevent the list from being rebuilt

    def build_local_dependency_list(force=true)
      unless File.exist?(package_manifest_path)
        return nil unless force
        rebuild_dependency_list
      end

      manifest = JSON.parse(File.read(package_manifest_path))
      manifest.map do |name, data|
        pkg = BPM::Package.new(data['path'])
        pkg.load_json
        pkg
      end
    end

    # Get list of dependencies, searching only the project and fetched
    # packages.  Raises if not found or
    # conflicting.
    def find_dependencies(deps=nil, verbose=false)

      deps ||= all_dependencies

      search_list = Array(deps)
      found = []
      ret = []

      # if we discover a new local package via indirect dependencies then
      # it's dependencies will be fetchable one time.
      fetchable = Set.new

      until search_list.empty?
        name, version = search_list.shift

        if dup = found.find{|p| p.name == name}
          # already found, check for conflicts
          next if satisfied_by?(version, dup.version)
          raise PackageConflictError.new(name, dup.version, version)
        end

        pkg = locate_package(name, version, verbose)
        if pkg.nil? && fetchable.include?(name)
          fetchable.reject! { |x| x == name }
          core_fetch_dependency(name, version, :runtime, true)
          pkg = locate_package name, version, verbose
        end

        raise PackageNotFoundError.new(name, version) unless pkg

        found << pkg

        # Look up dependencies of dependencies
        new_deps = Array(pkg.dependencies) + Array(pkg.dependencies_build)
        if has_local_package? pkg.name
          new_deps += Array(pkg.dependencies_development)
          new_deps.each { |dep| fetchable.add dep.first }
        end

        search_list += new_deps

        ret << pkg
      end

      ret
    end


    # Find package locally or in global cache

    def locate_package(package_name, vers, verbose)
      pkg = find_vendored_package(package_name)
      # FIXME: Make sure that local packages match specified version as well

      unless pkg
        # It's true that we don't have a prerelase check here, but the
        # previous one we had didn't do anything, so it's better to have
        # none than one that doesn't work
        vers = check_version(vers)
        vers = ">= 0" if vers == ">= 0.pre"

        src_path = BPM::Local.new.source_root(package_name, vers)
        pkg = BPM::Package.new(src_path) if src_path # Do we need this check?
        pkg.load_json if pkg
      end

      return nil unless pkg

      pkg
    end

    # pass in a hash of dependencies and versions
    def report_package_locations(deps=nil)
      deps ||= local_deps
      deps.each do |dep|
        is_local = has_local_package?(dep.name) ? 'local' : 'fetched'
        say "~ Using #{is_local} package '#{dep.name}' (#{dep.version})"
      end
    end

    # Method for help in sorting dependencies

    def add_sorted_dep(dep, deps, type, sorted, seen=[])
      return if seen.include? dep
      seen << dep # we want to do this first to avoid cylical refs

      list = {}
      list.merge!(dep.dependencies) if [:both, :runtime].include?(type)
      list.merge!(dep.dependencies_development) if [:both, :development].include?(type)
      list.each do |dep_name, dep_vers|
        found_dep = deps.find { |cur| cur.name == dep_name }
        add_sorted_dep(found_dep, deps, type, sorted, seen) if found_dep
      end
      sorted << dep unless sorted.include? dep
    end

    def has_mode(opts, mode)
      return false if opts.nil?
      modes = Array(opts['modes'])
      modes.size==0 || modes.include?('*') || modes.include?(mode.to_s)
    end


    ## BUILD OPTIONS

    def merge_build_opts(ret, dep_name, target_name, opts, mode)
      ret[target_name] ||= {}

      if opts['assets']
        ret[target_name] = opts['assets']
        return
      end

      if opts['directories']
        BPM.deprecation_warning "Use 'files' array instead of 'directories' array in #{dep_name} config"
        opts['files'] ||= []
        opts['files'] += opts.delete('directories')
      end

      if opts['files'] && opts['files'].size>0
        ret[target_name][dep_name] = opts['files']
      end

      if opts['minifier']
        ret[target_name]['bpm:provides'] ||= {}
        if opts['minifier'].is_a? String
          ret[target_name]['bpm:provides']['minifier'] = {}
          ret[target_name]['bpm:provides']['minifier'][opts['minifier']] = '>= 0'
        else
          ret[target_name]['bpm:provides']['minifier'] = opts['minifier']
        end
      end

      Array(opts['include']).each do |package_name|
        dep = local_deps.find { |cur_dep| cur_dep.name == package_name }
        raise PackageNotFoundError.new(package_name, '>= 0') if dep.nil?
        tmp_settings = {}
        merge_build_settings(tmp_settings, dep, mode, true, true)
        if target_name =~ /\.css$/ && tmp_settings['bpm_styles.css']
          ret[target_name].merge! tmp_settings['bpm_styles.css']
        elsif target_name =~ /\.js$/ && tmp_settings['bpm_libs.js']
          ret[target_name].merge! tmp_settings['bpm_libs.js']
        end
      end

      bpm_settings = ret[target_name]['bpm:settings'] ||= {}
      ret[target_name]['bpm:settings'] = soft_merge(bpm_settings, opts)

    end

    def project_settings_excludes(dep_name, target_name)
      exclusions = bpm_build[target_name] && bpm_build[target_name]['exclude']
      exclusions && exclusions.include?(dep_name)
    end

    DEFAULT_BUILD_OPTS = {
      'bpm_libs.js' => {
        'files' =>  ['lib'],
        'modes' =>  ['*']
      },

      'bpm_styles.css' => {
        'files' =>  ['css'],
        'modes' =>  ['*']
      }
    }

    def soft_merge(base, ext)
      ret = base.dup
      ext.each do |key, value|
        if ret[key].is_a?(Hash) && value.is_a?(Hash)
          ret[key] = soft_merge(ret[key], value)
        else
          ret[key] = value
        end
      end
      ret
    end

    def default_build_opts(dep_name)
      @default_build_opts ||= {}
      ret = @default_build_opts[dep_name]
      if ret.nil?
        ret = DEFAULT_BUILD_OPTS.dup
        ret[dep_name] = {
          "assets" => %w(resources assets),
          "modes"  => ["*"]
        }

        ret["#{dep_name}/bpm_tests.js"] = {
          'files' => ['tests'],
          'modes' => ['debug']
        }

        @default_build_opts[dep_name] = ret
      end
      ret
    end

    def merge_build_settings(ret, dep, mode, include_defaults=true, ignore_excludes=false)

      bpm_opts = dep.bpm_build
      if include_defaults
        bpm_opts = soft_merge default_build_opts(dep.name), bpm_opts
      end

      bpm_opts.each do |target_name, opts|
        next if !has_mode(opts, mode)
        next if !ignore_excludes && project_settings_excludes(dep.name, target_name)
        merge_build_opts ret, dep.name, target_name, opts, mode
      end
    end

    def check_version(vers)
      if vers == '>= 0-pre'
        BPM.deprecation_warning "Use '>= 0.pre' in your JSON config instead of '>= 0-pre'."
        vers = '>= 0.pre'
      end
      vers
    end

  end

end
