/*
 * Copyright © 2019 Atomist, Inc.
 *
 * 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.
 */

import { logger } from "@atomist/automation-client/lib/util/logger";
import * as _ from "lodash";
import { computeShaOf } from "../../api-helper/misc/sha";
import { SdmContext } from "../context/SdmContext";
import { Locking } from "../goal/common/Locking";
import { Goal } from "../goal/Goal";
import { Goals } from "../goal/Goals";
import { PushListenerInvocation } from "../listener/PushListener";
import {
    GoalSetter,
    GoalSettingCompositionStyle,
    GoalSettingStructure,
} from "../mapping/GoalSetter";
import {
    mapMapping,
    Mapping,
    NeverMatch,
} from "../mapping/Mapping";
import { Predicated } from "../mapping/PredicateMapping";
import {
    GoalComponent,
    toGoals,
} from "./GoalComponent";

export interface GoalContribution<F> extends Mapping<F, GoalComponent>, Predicated<F> {

}

/**
 * Add state to an invocation. Only available in memory.
 * @param S type of the fact to add.
 */
export interface StatefulInvocation<S> extends SdmContext {

    facts?: S;
}

export type DefaultFacts = Record<string, any>;

/**
 * Within evaluation of push rules we can manage state on a push.
 * This interface allows state. This state will not be persisted.
 */
export interface StatefulPushListenerInvocation<S = DefaultFacts> extends PushListenerInvocation, StatefulInvocation<S> {

}

/**
 * Enrich the invocation, attaching some facts.
 * The returned object will be merged with any facts already on the invocation.
 * @param {(f: (StatefulInvocation<FACT>)) => Promise<FACT>} compute additional facts.
 * @return {GoalContribution<F>}
 */
export function attachFacts<FACT, F extends SdmContext = PushListenerInvocation>(compute: (f: F) => Promise<FACT>): GoalContribution<F> {
    return {
        name: "attachFacts-" + computeShaOf(compute.toString()),
        mapping: async f => {
            const withAdditionalFact = f as F & StatefulInvocation<FACT>;
            if (!withAdditionalFact.facts) {
                withAdditionalFact.facts = {} as any;
            }
            const additionalState = await compute(withAdditionalFact);
            _.merge(withAdditionalFact.facts, additionalState);
            // The GoalContribution itself will be ignored
            return undefined;
        },
    };
}

/**
 * An additive goal setter assembles the goals contributed by all the contributors.
 */
class AdditiveGoalSetter<F extends SdmContext> implements GoalSetter<F>, GoalSettingStructure<F, Goals> {

    public get label(): string {
        return this.contributors.filter(c => (c as any).label)
            .map(c => (c as any).label).join(", ");
    }

    constructor(public readonly name: string, public readonly contributors: Array<GoalContribution<F>>) {
    }

    get structure(): { components: any, compositionStyle: GoalSettingCompositionStyle } {
        return {
            components: this.contributors.map(vague => mapMapping(vague, toGoals)),
            compositionStyle: GoalSettingCompositionStyle.AllMatches,
        };
    }

    public async mapping(p: F): Promise<NeverMatch | Goals | undefined> {
        const names = [];
        const contributorGoals: Goal[][] = [];

        for (const c of this.contributors) {
            const mapping = await c.mapping(p);
            if (mapping) {
                const goals = toGoals(mapping);
                if ((c as any).label) {
                    names.push((c as any).label);
                } else {
                    names.push(c.name);
                }
                contributorGoals.push(goals.goals.filter(g => g !== Locking));
                // If we find the special locking goal, don't add any further goals
                if (goals.goals.includes(Locking)) {
                    logger.debug("Stopping goal contribution analysis, because %s has locked the goal set", c.name);
                    break;
                }
            }
        }

        const uniqueGoals: Goal[] = _.uniq(_.flatten(contributorGoals.filter(x => !!x)));
        logger.debug("%d contributors (%s): Contributor goal names=[%s]; Unique goal names=[%s]; correlationId=%s",
            this.contributors.length,
            this.contributors.map(c => c.name),
            contributorGoals.map(a => !!a ? a.map(b => b.name).join() : "undefined").join(": "),
            uniqueGoals.map(g => g.name),
            p.context.correlationId);
        return uniqueGoals.length === 0 ?
            undefined :
            new Goals(names.join(", "), ...uniqueGoals);
    }

}

/**
 * Contribute goals based on a series of contribution rules.
 *
 * Instead of stopping at the first match, each push will get _all_ the goals it qualifies for.
 *
 * Duplicates will be removed.
 *
 * @param contributor first contributor
 * @param {GoalContribution<F>} contributors
 * @return a mapping to goals
 */
export function goalContributors<F extends SdmContext = StatefulPushListenerInvocation<any>>(
    contributor: GoalContribution<F>,
    ...contributors: Array<GoalContribution<F>>): Mapping<F, Goals> {
    if (contributors.length === 0) {
        return mapMapping(contributor, toGoals);
    }
    return enrichGoalSetters(contributor, contributors[0], ...contributors.slice(1));
}

/**
 * Enrich the given push mapping with our own contributions
 * @param {Mapping<F extends SdmContext, Goals>} mapping
 * @param {GoalContribution<F extends SdmContext>} contributor
 * @param {GoalContribution<F extends SdmContext>} contributors
 * @return {Mapping<F extends SdmContext, Goals>}
 */
export function enrichGoalSetters<F extends SdmContext = StatefulPushListenerInvocation<any>>(
    mapping: GoalContribution<F>,
    contributor: GoalContribution<F>,
    ...contributors: Array<GoalContribution<F>>): Mapping<F, Goals> & GoalSettingStructure<F, Goals> {
    if (isAdditiveGoalSetter(mapping)) {
        return new AdditiveGoalSetter(`${mapping.name}-enriched`,
            [...mapping.contributors, contributor, ...contributors],
        );
    }
    return new AdditiveGoalSetter(`${mapping.name}-enriched`,
        [mapping, contributor].concat(contributors),
    );
}

function isAdditiveGoalSetter(a: GoalContribution<any>): a is AdditiveGoalSetter<any> {
    const maybe = a as AdditiveGoalSetter<any>;
    return !!maybe && !!maybe.contributors && !!maybe.mapping;
}
