//  Copyright 2019 Google Inc. All Rights Reserved.
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.

package ospatch

import (
	"context"
	"fmt"

	"github.com/GoogleCloudPlatform/osconfig/clog"
	"github.com/GoogleCloudPlatform/osconfig/packages"
)

const zypper = "/usr/bin/zypper"

var (
	zypperPatchArgs = []string{"patch", "-y"}
)

type zypperPatchOpts struct {
	categories       []string
	severities       []string
	excludes         []*Exclude
	exclusivePatches []string
	withOptional     bool
	withUpdate       bool
	dryrun           bool
}

// ZypperPatchOption is an option for zypper patch.
type ZypperPatchOption func(*zypperPatchOpts)

// ZypperPatchCategories returns a ZypperUpdateOption that specifies what
// categories to add to the --categories flag.
func ZypperPatchCategories(categories []string) ZypperPatchOption {
	return func(args *zypperPatchOpts) {
		args.categories = categories
	}
}

// ZypperPatchSeverities returns a ZypperUpdateOption that specifies what
// categories to add to the --categories flag.
func ZypperPatchSeverities(severities []string) ZypperPatchOption {
	return func(args *zypperPatchOpts) {
		args.severities = severities
	}
}

// ZypperUpdateWithOptional returns a ZypperUpdateOption that specifies the
// --with-optional flag should be used.
func ZypperUpdateWithOptional(withOptional bool) ZypperPatchOption {
	return func(args *zypperPatchOpts) {
		args.withOptional = withOptional
	}
}

// ZypperUpdateWithUpdate returns a ZypperUpdateOption that specifies the
// --with-update flag should be used.
func ZypperUpdateWithUpdate(withUpdate bool) ZypperPatchOption {
	return func(args *zypperPatchOpts) {
		args.withUpdate = withUpdate
	}
}

// ZypperUpdateWithExcludes returns a ZypperUpdateOption that specifies
// list of packages to be excluded from update
func ZypperUpdateWithExcludes(excludes []*Exclude) ZypperPatchOption {
	return func(args *zypperPatchOpts) {
		args.excludes = excludes
	}
}

// ZypperUpdateWithExclusivePatches returns a ZypperUpdateOption that specifies
// list of exclusive packages to be updated
func ZypperUpdateWithExclusivePatches(exclusivePatches []string) ZypperPatchOption {
	return func(args *zypperPatchOpts) {
		args.exclusivePatches = exclusivePatches
	}
}

// ZypperUpdateDryrun returns a ZypperUpdateOption that specifies the runner.
func ZypperUpdateDryrun(dryrun bool) ZypperPatchOption {
	return func(args *zypperPatchOpts) {
		args.dryrun = dryrun
	}
}

// RunZypperPatch runs zypper patch.
func RunZypperPatch(ctx context.Context, opts ...ZypperPatchOption) error {
	zOpts := &zypperPatchOpts{
		excludes:         nil,
		exclusivePatches: nil,
		categories:       nil,
		severities:       nil,
		withOptional:     false,
		withUpdate:       false,
	}

	for _, opt := range opts {
		opt(zOpts)
	}

	zListOpts := []packages.ZypperListOption{
		packages.ZypperListPatchCategories(zOpts.categories),
		packages.ZypperListPatchSeverities(zOpts.severities),
		packages.ZypperListPatchWithOptional(zOpts.withOptional),
		// if there is no filter on category and severity,
		// zypper fetches all available patch updates
	}
	patches, err := packages.ZypperPatches(ctx, zListOpts...)
	if err != nil {
		return err
	}

	// if user specifies, --with-update get the necessary patch/package
	// information and then runfilter on them
	var pkgToPatchesMap map[string][]string
	var pkgUpdates []*packages.PkgInfo
	if zOpts.withUpdate {
		pkgUpdates, err = packages.ZypperUpdates(ctx)
		if err != nil {
			return err
		}
		pkgToPatchesMap, err = packages.ZypperPackagesInPatch(ctx, patches)
		if err != nil {
			return err
		}
	}

	fPatches, fpkgs, err := runFilter(patches, zOpts.exclusivePatches, zOpts.excludes, pkgUpdates, pkgToPatchesMap, zOpts.withUpdate)

	if len(fPatches) == 0 && len(fpkgs) == 0 {
		clog.Infof(ctx, "No updates required.")
		return nil
	}

	var ops opsToReport

	if len(fPatches) == 0 {
		clog.Infof(ctx, "No patches to install.")
	} else {
		msg := fmt.Sprintf("%d patches: %s", len(fPatches), formatPatches(fPatches))
		if zOpts.dryrun {
			clog.Infof(ctx, "Running in dryrun mode, not installing %s", msg)
		} else {
			ops.patches = fPatches
		}
	}

	if len(fpkgs) == 0 {
		clog.Infof(ctx, "No non-patch packages to update.")
	} else {
		msg := fmt.Sprintf("%d patches: %q", len(fpkgs), fpkgs)
		if zOpts.dryrun {
			clog.Infof(ctx, "Running in dryrun mode, not Updating %s", msg)
		} else {
			ops.packages = fpkgs
		}
	}
	logOps(ctx, ops)

	if zOpts.dryrun {
		return nil
	}
	err = packages.ZypperInstall(ctx, fPatches, fpkgs)
	if err == nil {
		logSuccess(ctx, ops)
	} else {
		logFailure(ctx, ops, err)
	}
	return err
}

func runFilter(patches []*packages.ZypperPatch, exclusivePatches []string, excludes []*Exclude, pkgUpdates []*packages.PkgInfo, pkgToPatchesMap map[string][]string, withUpdate bool) ([]*packages.ZypperPatch, []*packages.PkgInfo, error) {
	// exclusive patches
	var fPatches []*packages.ZypperPatch
	var fPkgs []*packages.PkgInfo
	if len(exclusivePatches) > 0 {
		for _, patch := range patches {
			if containsString(exclusivePatches, patch.Name) {
				fPatches = append(fPatches, patch)
			}
		}
		return fPatches, fPkgs, nil
	}

	// if --with-update is specified, filter out the packages
	// that will be updated as a part of a patch update
	if withUpdate {
		for _, pkg := range pkgUpdates {
			if _, ok := pkgToPatchesMap[pkg.Name]; !ok {
				fPkgs = append(fPkgs, pkg)
			}
		}
	}

	// we have the list of patches which is already filtered
	// as per the configurations provided by user;
	// we remove the excluded patches from the list
	for _, patch := range patches {
		// in zypper we're filtering patches instead of packages, but the method is still the same
		if !shouldPackageBeExcluded(excludes, &patch.Name) {
			fPatches = append(fPatches, patch)
		}
	}
	return fPatches, fPkgs, nil
}
