/*
 * Copyright © 2020 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 {
    Configuration,
    configurationValue,
} from "@atomist/automation-client/lib/configuration";
import {
    ConfigurationAware,
    HandlerContext,
} from "@atomist/automation-client/lib/HandlerContext";
import { guid } from "@atomist/automation-client/lib/internal/util/string";
import { ProjectOperationCredentials } from "@atomist/automation-client/lib/operations/common/ProjectOperationCredentials";
import { RemoteRepoRef } from "@atomist/automation-client/lib/operations/common/RepoId";
import { logger } from "@atomist/automation-client/lib/util/logger";
import * as _ from "lodash";
import {
    AddressChannels,
    addressChannelsFor,
} from "../../api/context/addressChannels";
import { ParameterPromptFactory } from "../../api/context/parameterPrompt";
import {
    NoPreferenceStore,
    PreferenceStore,
    PreferenceStoreFactory,
} from "../../api/context/preferenceStore";
import { createSkillContext } from "../../api/context/skillContext";
import { StatefulPushListenerInvocation } from "../../api/dsl/goalContribution";
import { EnrichGoal } from "../../api/goal/enrichGoal";
import {
    Goal,
    GoalDefinition,
    GoalWithPrecondition,
    hasPreconditions,
} from "../../api/goal/Goal";
import { Goals } from "../../api/goal/Goals";
import {
    getGoalDefinitionFrom,
    PlannedGoal,
} from "../../api/goal/GoalWithFulfillment";
import { SdmGoalEvent } from "../../api/goal/SdmGoalEvent";
import {
    SdmGoalFulfillment,
    SdmGoalFulfillmentMethod,
    SdmGoalMessage,
} from "../../api/goal/SdmGoalMessage";
import {
    GoalImplementationMapper,
    isGoalFulfillment,
    isGoalImplementation,
    isGoalSideEffect,
} from "../../api/goal/support/GoalImplementationMapper";
import {
    GoalSetTag,
    TagGoalSet,
} from "../../api/goal/tagGoalSet";
import {
    GoalsSetListener,
    GoalsSetListenerInvocation,
} from "../../api/listener/GoalsSetListener";
import { PushListenerInvocation } from "../../api/listener/PushListener";
import { GoalSetter } from "../../api/mapping/GoalSetter";
import { ProjectLoader } from "../../spi/project/ProjectLoader";
import { RepoRefResolver } from "../../spi/repo-ref/RepoRefResolver";
import {
    PushFields,
    SdmGoalState,
} from "../../typings/types";
import { minimalClone } from "./minimalClone";
import {
    constructGoalSet,
    constructSdmGoal,
    constructSdmGoalImplementation,
    storeGoal,
    storeGoalSet,
} from "./storeGoals";

/**
 * Configuration for handling incoming pushes
 */
export interface ChooseAndSetGoalsRules {

    projectLoader: ProjectLoader;

    repoRefResolver: RepoRefResolver;

    goalsListeners: GoalsSetListener[];

    goalSetter: GoalSetter;

    implementationMapping: GoalImplementationMapper;

    enrichGoal?: EnrichGoal;

    tagGoalSet?: TagGoalSet;

    preferencesFactory?: PreferenceStoreFactory;

    parameterPromptFactory?: ParameterPromptFactory<any>;

}

/**
 * Choose and set goals for this push
 * @param {ChooseAndSetGoalsRules} rules: configuration for handling incoming pushes
 * @param parameters details of incoming request
 * @return {Promise<Goals | undefined>}
 */
export async function chooseAndSetGoals(rules: ChooseAndSetGoalsRules,
                                        parameters: {
        context: HandlerContext,
        credentials: ProjectOperationCredentials,
        push: PushFields.Fragment,
    }): Promise<Goals | undefined> {
    const { projectLoader, goalsListeners, goalSetter, implementationMapping, repoRefResolver, preferencesFactory } = rules;
    const { context, credentials, push } = parameters;
    const enrichGoal = !!rules.enrichGoal ? rules.enrichGoal : async g => g;
    const tagGoalSet = !!rules.tagGoalSet ? rules.tagGoalSet : async () => [];
    const id = repoRefResolver.repoRefFromPush(push);
    const addressChannels = addressChannelsFor(push.repo, context);
    const preferences = !!preferencesFactory ? preferencesFactory(parameters.context) : NoPreferenceStore;
    const configuration = (context as any as ConfigurationAware).configuration;
    const goalSetId = guid();

    const { determinedGoals, goalsToSave, tags } = await determineGoals(
        { projectLoader, repoRefResolver, goalSetter, implementationMapping, enrichGoal, tagGoalSet }, {
        credentials, id, context, push, addressChannels, preferences, goalSetId, configuration,
    });

    if (goalsToSave.length > 0) {
        // First store the goals
        await Promise.all(goalsToSave.map(g => storeGoal(context, g)));

        // And then store the goalSet
        await storeGoalSet(context, constructGoalSet(context, goalSetId, determinedGoals.name, goalsToSave, tags, push));
    }

    // Let GoalSetListeners know even if we determined no goals.
    // This is not an error
    const gsi: GoalsSetListenerInvocation = {
        id,
        context,
        credentials,
        addressChannels,
        configuration,
        preferences,
        goalSetId,
        goalSetName: determinedGoals ? determinedGoals.name : undefined,
        goalSet: determinedGoals,
        push,
        skill: createSkillContext(context),
    };
    await Promise.all(goalsListeners.map(l => l(gsi)));
    return determinedGoals;
}

export async function determineGoals(rules: {
    projectLoader: ProjectLoader,
    repoRefResolver: RepoRefResolver,
    goalSetter: GoalSetter,
    implementationMapping: GoalImplementationMapper,
    enrichGoal: EnrichGoal,
    tagGoalSet?: TagGoalSet,
},
                                     circumstances: {
        credentials: ProjectOperationCredentials,
        id: RemoteRepoRef,
        context: HandlerContext,
        configuration: Configuration,
        push: PushFields.Fragment,
        addressChannels: AddressChannels,
        preferences?: PreferenceStore,
        goalSetId: string,
    }): Promise<{
        determinedGoals: Goals | undefined,
        goalsToSave: SdmGoalMessage[],
        tags: GoalSetTag[],
    }> {
    const { enrichGoal, projectLoader, repoRefResolver, goalSetter, implementationMapping, tagGoalSet } = rules;
    const { credentials, id, context, push, addressChannels, goalSetId, preferences, configuration } = circumstances;
    return projectLoader.doWithProject({
        credentials,
        id,
        context,
        readOnly: true,
        cloneOptions: minimalClone(push, { detachHead: true }),
    },
        async project => {
            const pli: StatefulPushListenerInvocation = {
                project,
                credentials,
                id,
                push,
                context,
                addressChannels,
                configuration,
                preferences: preferences || NoPreferenceStore,
                facts: {},
                skill: createSkillContext(context),
            };
            const determinedGoals = await chooseGoalsForPushOnProject({ goalSetter }, pli);
            if (!determinedGoals) {
                return { determinedGoals: undefined, goalsToSave: [], tags: [] };
            }
            const goalsToSave = await sdmGoalsFromGoals(
                implementationMapping,
                push,
                repoRefResolver,
                pli,
                determinedGoals,
                goalSetId);

            // Enrich all goals before they get saved
            await Promise.all(goalsToSave.map(async g1 => enrichGoal(g1, pli)));

            // Optain tags for the goal set
            let tags: GoalSetTag[] = [];
            if (!!tagGoalSet) {
                tags = (await tagGoalSet(goalsToSave, pli)) || [];
            }

            return { determinedGoals, goalsToSave, tags };
        });

}

async function sdmGoalsFromGoals(implementationMapping: GoalImplementationMapper,
                                 push: PushFields.Fragment,
                                 repoRefResolver: RepoRefResolver,
                                 pli: PushListenerInvocation,
                                 determinedGoals: Goals,
                                 goalSetId: string): Promise<SdmGoalMessage[]> {
    return Promise.all(determinedGoals.goals.map(async g => {
        const ge = constructSdmGoal(pli.context, {
            goalSet: determinedGoals.name,
            goalSetId,
            goal: g,
            state: (hasPreconditions(g) ? SdmGoalState.planned :
                (g.definition.preApprovalRequired ? SdmGoalState.waiting_for_pre_approval : SdmGoalState.requested)) as SdmGoalState,
            id: pli.id,
            providerId: repoRefResolver.providerIdFromPush(pli.push),
            fulfillment: await fulfillment({ implementationMapping }, g, pli),
        });

        if (ge.state === SdmGoalState.requested) {

            const cbs = implementationMapping.findFulfillmentCallbackForGoal({ ...ge, push }) || [];
            let ng: SdmGoalEvent = { ...ge, push };
            for (const cb of cbs) {
                ng = await cb.callback(ng, pli);
            }

            return {
                ...ge,
                data: ng.data,
            };
        } else {
            return ge;
        }
    }));
}

async function fulfillment(rules: {
    implementationMapping: GoalImplementationMapper,
},
                           g: Goal,
                           inv: PushListenerInvocation): Promise<SdmGoalFulfillment> {
    const { implementationMapping } = rules;
    const plan = await implementationMapping.findFulfillmentByPush(g, inv);
    if (isGoalImplementation(plan)) {
        return constructSdmGoalImplementation(plan, inv.configuration.name);
    } else if (isGoalFulfillment(g.definition as any)) {
        const ff = (g.definition as any).fulfillment;
        return {
            method: SdmGoalFulfillmentMethod.SideEffect,
            name: ff.name,
            registration: ff.registration,
        };
    } else if (isGoalSideEffect(plan)) {
        return {
            method: SdmGoalFulfillmentMethod.SideEffect,
            name: plan.sideEffectName,
            registration: plan.registration || configurationValue("name"),
        };
    } else {
        return { method: SdmGoalFulfillmentMethod.Other, name: "unknown", registration: "unknown" };
    }
}

async function chooseGoalsForPushOnProject(rules: { goalSetter: GoalSetter },
                                           pi: PushListenerInvocation): Promise<Goals> {
    const { goalSetter } = rules;
    const { push, id } = pi;

    try {
        const determinedGoals: Goals = await goalSetter.mapping(pi);

        if (!determinedGoals) {
            logger.info("No goals set by push '%s' to '%s/%s/%s'", push.after.sha, id.owner, id.repo, push.branch);
            return determinedGoals;
        } else {
            const filteredGoals: Goal[] = [];
            const plannedGoals = await planGoals(determinedGoals, pi);
            plannedGoals.goals.forEach(g => {
                if ((g as any).dependsOn) {
                    const preConditions = (g as any).dependsOn as Goal[];
                    if (preConditions) {
                        const filteredPreConditions = preConditions.filter(pc => plannedGoals.goals.some(ag =>
                            ag.uniqueName === pc.uniqueName &&
                            ag.environment === pc.environment));
                        if (filteredPreConditions.length > 0) {
                            filteredGoals.push(new GoalWithPrecondition(g.definition, ...filteredPreConditions));
                        } else {
                            filteredGoals.push(new Goal(g.definition));
                        }
                    } else {
                        filteredGoals.push(g);
                    }
                } else {
                    filteredGoals.push(g);
                }
            });
            logger.info("Goals for push '%s' on '%s/%s/%s' are '%s'", push.after.sha, id.owner, id.repo, push.branch, plannedGoals.name);
            return new Goals(plannedGoals.name, ...filteredGoals);
        }

    } catch (err) {
        logger.error("Error determining goals: %s", err);
        logger.error(err.stack);
        throw err;
    }
}

export async function planGoals(goals: Goals, pli: PushListenerInvocation): Promise<Goals> {
    const allGoals = [...goals.goals];
    const names = [];

    for (const dg of goals.goals) {
        if (!!(dg as any).plan) {
            let planResult = await (dg as any).plan(pli, goals);
            if (!!planResult) {

                // Check if planResult is a PlannedGoal or PlannedGoals instance
                if (!_.some(planResult, v => !!v && !!v.goals)) {
                    planResult = { "#": { goals: planResult } };
                }

                const allNewGoals = [];
                const goalMapping = new Map<string, Goal[]>();
                _.forEach(planResult, (planResultGoals, n) => {
                    names.push(n.replace(/_/g, " "));
                    const plannedGoals: Array<PlannedGoal | PlannedGoal[]> = [];
                    if (Array.isArray(planResultGoals.goals)) {
                        plannedGoals.push(...planResultGoals.goals);
                    } else {
                        plannedGoals.push(planResultGoals.goals);
                    }

                    let previousGoals = [];
                    const newGoals = [];
                    plannedGoals.forEach(g => {
                        if (Array.isArray(g)) {
                            const gNewGoals = [];
                            for (const gg of g) {
                                const newGoal = createGoal(
                                    gg,
                                    dg,
                                    planResultGoals.dependsOn,
                                    allNewGoals.length + gNewGoals.length,
                                    previousGoals,
                                    goalMapping);
                                gNewGoals.push(newGoal);
                            }
                            allNewGoals.push(...gNewGoals);
                            newGoals.push(...gNewGoals);
                            previousGoals = [...gNewGoals];
                        } else {
                            const newGoal = createGoal(
                                g,
                                dg,
                                planResultGoals.dependsOn,
                                allNewGoals.length,
                                previousGoals,
                                goalMapping);
                            allNewGoals.push(newGoal);
                            newGoals.push(newGoal);
                            previousGoals = [newGoal];
                        }
                    });

                    goalMapping.set(n, newGoals);
                });

                // Replace existing goal with new instances
                const ix = allGoals.findIndex(g => g.uniqueName === dg.uniqueName);
                allGoals.splice(ix, 1, ...allNewGoals);

                // Replace all preConditions that point back to the original goal with references to new goals
                allGoals.filter(hasPreconditions)
                    .filter(g => (g.dependsOn || []).some(gr => gr.uniqueName === dg.uniqueName))
                    .forEach(g => {
                        _.remove(g.dependsOn, gr => gr.uniqueName === dg.uniqueName);
                        g.dependsOn.push(...allNewGoals);
                    });
            }
        }
    }

    return new Goals(goals.name, ...allGoals);
}

function createGoal(g: PlannedGoal,
                    dg: Goal,
                    preConditions: string | string[],
                    plannedGoalsCounter: number,
                    previousGoals: Goal[],
                    goalMapping: Map<string, Goal[]>): Goal {
    const uniqueName = `${dg.uniqueName}#sdm:${plannedGoalsCounter}`;

    const definition: GoalDefinition & { parameters: PlannedGoal["parameters"], fulfillment: PlannedGoal["fulfillment"] } =
        _.merge(
            {},
            dg.definition,
            getGoalDefinitionFrom(g.details, uniqueName)) as any;

    definition.uniqueName = uniqueName;
    definition.parameters = g.parameters;
    definition.fulfillment = g.fulfillment;

    const dependsOn = [];
    if (hasPreconditions(dg)) {
        dependsOn.push(...dg.dependsOn);
    }
    if (!!previousGoals) {
        dependsOn.push(...previousGoals);
    }
    if (!!preConditions) {
        if (Array.isArray(preConditions)) {
            dependsOn.push(..._.flatten(preConditions.map(d => goalMapping.get(d)).filter(d => !!d)));
        } else {
            dependsOn.push(...goalMapping.get(preConditions));
        }
    }
    return new GoalWithPrecondition(definition, ..._.uniqBy(dependsOn.filter(d => !!d), "uniqueName"));
}
