package verdeps

import (
	"bytes"
	"errors"
	"fmt"
	"log"
	"strconv"
	"sync"
	"time"

	"github.com/gophr-pm/gophr/lib/github"
	"github.com/gophr-pm/gophr/lib/io"
)

type shaFetcher func(args fetchSHAArgs)

type depsReviser func(args reviseDepsArgs)

type packageDirReader func(args readPackageDirArgs)

type specWaitingListCreator func(
	initialSpecs ...*importSpec,
) specWaitingList

type syncedStringMapCreator func() syncedStringMap

type syncedWaitingListMapCreator func() syncedWaitingListMap

type processDepsArgs struct {
	io                      io.IO
	ghSvc                   github.RequestService
	fetchSHA                shaFetcher
	reviseDeps              depsReviser
	packageSHA              string
	packagePath             string
	packageRepo             string
	packageAuthor           string
	readPackageDir          packageDirReader
	packageVersionDate      time.Time
	newSpecWaitingList      specWaitingListCreator
	newSyncedStringMap      syncedStringMapCreator
	newSyncedWaitingListMap syncedWaitingListMapCreator
}

func processDeps(args processDepsArgs) error {
	var (
		revisionChan             = make(chan *revision)
		waitingSpecs             = args.newSyncedWaitingListMap()
		fetchSHAResults          = args.newSyncedStringMap()
		importSpecChan           = make(chan *importSpec)
		packageSpecChan          = make(chan *packageSpec)
		fetchSHAResultChan       = make(chan *fetchSHAResult)
		accumulatedErrors        = newSyncedErrors()
		revisionWaitGroup        = &sync.WaitGroup{}
		syncedImportCounts       = newSyncedImportCounts()
		processedImportsCount    = newSyncedInt()
		generatedInternalDirName = generateInternalDirName()
	)

	go args.readPackageDir(readPackageDirArgs{
		io:                       args.io,
		errors:                   accumulatedErrors,
		importCounts:             syncedImportCounts,
		packageDirPath:           args.packagePath,
		importSpecChan:           importSpecChan,
		packageSpecChan:          packageSpecChan,
		traversePackageDir:       traversePackageDir,
		generatedInternalDirName: generatedInternalDirName,
	})

	revisionWaitGroup.Add(1)
	go args.reviseDeps(reviseDepsArgs{
		io:                 args.io,
		inputChan:          revisionChan,
		composeBytesDiffs:  composeBytesDiffs,
		revisionWaitGroup:  revisionWaitGroup,
		accumulatedErrors:  accumulatedErrors,
		syncedImportCounts: syncedImportCounts,
	})

	for {

		select {
		case result := <-fetchSHAResultChan:

			processedImportsCount.increment()

			if result.successful {

				importPathHash := importPathHashOf(result.importPath)
				fetchSHAResults.set(importPathHash, result.sha)

				if waitingList, exists := waitingSpecs.get(importPathHash); exists {

					if specs := waitingList.clear(); specs != nil {
						for _, spec := range specs {
							enqueueImportRevision(
								revisionChan,
								spec.imports.Path.Value,
								result.sha,
								generatedInternalDirName,
								spec)
						}
					}
				}
			} else {

				log.Printf(
					`Failed to fetch SHA for import path "%s": %v`,
					result.sha,
					result.err)
			}

		case spec, alive := <-packageSpecChan:

			if !alive {
				packageSpecChan = nil
				break
			}

			enqueuePackageRevision(revisionChan, spec)

		case spec, alive := <-importSpecChan:

			if !alive {
				importSpecChan = nil
				break
			}

			importPath := spec.imports.Path.Value
			importPathHash := importPathHashOf(spec.imports.Path.Value)
			if sha, exists := fetchSHAResults.get(importPathHash); !exists {

				if specs, exists := waitingSpecs.get(importPathHash); !exists {

					waitingSpecs.setIfAbsent(
						importPathHash,
						args.newSpecWaitingList(spec))

					go args.fetchSHA(fetchSHAArgs{
						ghSvc:              args.ghSvc,
						outputChan:         fetchSHAResultChan,
						importPath:         importPath,
						packageSHA:         args.packageSHA,
						packageRepo:        args.packageRepo,
						packageAuthor:      args.packageAuthor,
						packageVersionDate: args.packageVersionDate,
					})
				} else {
					if ok := specs.add(spec); !ok {

						if sha, exists = fetchSHAResults.get(importPathHash); !exists {
							accumulatedErrors.add(fmt.Errorf(
								"Could not version dependency %s"+
									" because the SHA did not yet exist.",
								importPath))
						} else {
							enqueueImportRevision(
								revisionChan,
								importPath,
								sha,
								generatedInternalDirName,
								spec)
						}
					}

					processedImportsCount.increment()
				}
			} else {

				enqueueImportRevision(
					revisionChan,
					importPath,
					sha,
					generatedInternalDirName,
					spec)

				processedImportsCount.increment()
			}
		}

		if fetchSHAResultChan != nil &&
			importSpecChan == nil &&
			processedImportsCount.value() == syncedImportCounts.totalCount() {

			waitingSpecs.
				each(func(importPath string, waitingList specWaitingList) {
					waitingList.clear()
				}).
				clear()

			close(fetchSHAResultChan)
			fetchSHAResultChan = nil
		}

		if fetchSHAResultChan == nil &&
			packageSpecChan == nil &&
			importSpecChan == nil {

			close(revisionChan)
			revisionChan = nil

			revisionWaitGroup.Wait()

			break
		}
	}

	if accumulatedErrors.len() > 0 {
		return concatErrors(accumulatedErrors)
	}

	return nil
}

func enqueueImportRevision(
	revisionChan chan *revision,
	importPath string,
	sha string,
	generatedInternalDirName string,
	spec *importSpec,
) {
	author, repo, subpath := parseImportPath(importPath)
	newImportPath := composeNewImportPath(
		author,
		repo,
		sha,
		subpath,
		generatedInternalDirName)

	revisionChan <- newImportRevision(spec, newImportPath)
}

func enqueuePackageRevision(revisionChan chan *revision, spec *packageSpec) {
	revisionChan <- newPackageRevision(spec)
}

func concatErrors(accumulatedErrors *syncedErrors) error {
	errs := accumulatedErrors.get()
	buffer := bytes.Buffer{}

	buffer.WriteString("Failed to process dependencies. Bumped into ")
	buffer.WriteString(strconv.Itoa(len(errs)))
	buffer.WriteString(" problems: [ ")

	for i, err := range errs {
		if i > 0 {
			buffer.WriteString(", ")
		}
		buffer.WriteString(err.Error())
	}

	buffer.WriteString(" ].")

	return errors.New(buffer.String())
}
package verdeps

import (
	"bytes"
	"errors"
	"fmt"
	"log"
	"strconv"
	"sync"
	"time"

	"github.com/gophr-pm/gophr/lib/github"
	"github.com/gophr-pm/gophr/lib/io"
)

// shaFetcher is a function type that de-couples verdeps.processDeps from
// verdeps.fetchSHA.
type shaFetcher func(args fetchSHAArgs)

// depsReviser is a function type that de-couples verdeps.processDeps from
// verdeps.reviseDeps.
type depsReviser func(args reviseDepsArgs)

// packageDirReader is a function type that de-couples verdeps.processDeps from
// verdeps.readPackageDir.
type packageDirReader func(args readPackageDirArgs)

// specWaitingListCreator is a function type that de-couples verdeps.processDeps
// from newSpecWaitingList.
type specWaitingListCreator func(
	initialSpecs ...*importSpec,
) specWaitingList

// syncedStringMapCreator is function type that de-couples verdeps.processDeps
// from newSyncedStringMap.
type syncedStringMapCreator func() syncedStringMap

// syncedWaitingListMapCreator is function type that de-couples
// verdeps.processDeps from newSyncedWaitingListMap.
type syncedWaitingListMapCreator func() syncedWaitingListMap

// processDepsArgs is the arguments struct for processDeps.
type processDepsArgs struct {
	io                      io.IO
	ghSvc                   github.RequestService
	fetchSHA                shaFetcher
	reviseDeps              depsReviser
	packageSHA              string
	packagePath             string
	packageRepo             string
	packageAuthor           string
	readPackageDir          packageDirReader
	packageVersionDate      time.Time
	newSpecWaitingList      specWaitingListCreator
	newSyncedStringMap      syncedStringMapCreator
	newSyncedWaitingListMap syncedWaitingListMapCreator
}

// TODO(skeswa): add a descriptive comment.
func processDeps(args processDepsArgs) error {
	var (
		revisionChan             = make(chan *revision)
		waitingSpecs             = args.newSyncedWaitingListMap()
		fetchSHAResults          = args.newSyncedStringMap()
		importSpecChan           = make(chan *importSpec)
		packageSpecChan          = make(chan *packageSpec)
		fetchSHAResultChan       = make(chan *fetchSHAResult)
		accumulatedErrors        = newSyncedErrors()
		revisionWaitGroup        = &sync.WaitGroup{}
		syncedImportCounts       = newSyncedImportCounts()
		processedImportsCount    = newSyncedInt()
		generatedInternalDirName = generateInternalDirName()
	)

	// Read the package looking for import and package metadata.
	go args.readPackageDir(readPackageDirArgs{
		io:                       args.io,
		errors:                   accumulatedErrors,
		importCounts:             syncedImportCounts,
		packageDirPath:           args.packagePath,
		importSpecChan:           importSpecChan,
		packageSpecChan:          packageSpecChan,
		traversePackageDir:       traversePackageDir,
		generatedInternalDirName: generatedInternalDirName,
	})

	// Revise dependencies in the go source files.
	revisionWaitGroup.Add(1)
	go args.reviseDeps(reviseDepsArgs{
		io:                 args.io,
		inputChan:          revisionChan,
		composeBytesDiffs:  composeBytesDiffs,
		revisionWaitGroup:  revisionWaitGroup,
		accumulatedErrors:  accumulatedErrors,
		syncedImportCounts: syncedImportCounts,
	})

	for {
		// Process incoming data from the channels.
		select {
		case result := <-fetchSHAResultChan:
			// Count this import as processed.
			processedImportsCount.increment()

			// Only continue if an actual importPath-sha mapping came through.
			if result.successful {
				// Create an entry in the map.
				importPathHash := importPathHashOf(result.importPath)
				fetchSHAResults.set(importPathHash, result.sha)

				// Clear away the waiting specs.
				if waitingList, exists := waitingSpecs.get(importPathHash); exists {
					// There is a waiting list, so it needs to be cleared.
					if specs := waitingList.clear(); specs != nil {
						for _, spec := range specs {
							enqueueImportRevision(
								revisionChan,
								spec.imports.Path.Value,
								result.sha,
								generatedInternalDirName,
								spec)
						}
					}
				}
			} else {
				// If not successful, log the error (don't return it since it isn't
				// fatal).
				log.Printf(
					`Failed to fetch SHA for import path "%s": %v`,
					result.sha,
					result.err)
			}

		case spec, alive := <-packageSpecChan:
			// Nil the channel if no longer alive.
			if !alive {
				packageSpecChan = nil
				break
			}

			// Pass the package revision along to the file-writing stage.
			enqueuePackageRevision(revisionChan, spec)

		case spec, alive := <-importSpecChan:
			// Nil the channel if no longer alive.
			if !alive {
				importSpecChan = nil
				break
			}

			// For each incoming spec, make it wait keyed on the import path hash.
			importPath := spec.imports.Path.Value
			importPathHash := importPathHashOf(spec.imports.Path.Value)
			if sha, exists := fetchSHAResults.get(importPathHash); !exists {
				// If we don't presently have the sha, then we have to go out and get
				// it.
				if specs, exists := waitingSpecs.get(importPathHash); !exists {
					// Create a new waiting list for this import path since it does not
					// yet exist.
					waitingSpecs.setIfAbsent(
						importPathHash,
						args.newSpecWaitingList(spec))

					// Start the request itself.
					go args.fetchSHA(fetchSHAArgs{
						ghSvc:              args.ghSvc,
						outputChan:         fetchSHAResultChan,
						importPath:         importPath,
						packageSHA:         args.packageSHA,
						packageRepo:        args.packageRepo,
						packageAuthor:      args.packageAuthor,
						packageVersionDate: args.packageVersionDate,
					})
				} else {
					if ok := specs.add(spec); !ok {
						// If the add failed, assume that it is because the the sha was
						// obtained after we last checked.
						if sha, exists = fetchSHAResults.get(importPathHash); !exists {
							accumulatedErrors.add(fmt.Errorf(
								"Could not version dependency %s"+
									" because the SHA did not yet exist.",
								importPath))
						} else {
							enqueueImportRevision(
								revisionChan,
								importPath,
								sha,
								generatedInternalDirName,
								spec)
						}
					}

					// Count this import as processed.
					processedImportsCount.increment()
				}
			} else {
				// If we got here, it means that the sha has already been obtained, so
				// the new import path exists.
				enqueueImportRevision(
					revisionChan,
					importPath,
					sha,
					generatedInternalDirName,
					spec)

				// Count this import as processed.
				processedImportsCount.increment()
			}
		}

		// Wait until the import spec channel in nil because that means that the
		// import count total in correct. Thereafter, check if the total number
		// of processed import path SHAs matches the total number of imports. In
		// this case, it is time to close the import path SHA channel.
		if fetchSHAResultChan != nil &&
			importSpecChan == nil &&
			processedImportsCount.value() == syncedImportCounts.totalCount() {
			// Clear all the remaining waiting lists since none of the remaining
			// import paths will ever be matched to the corresponding SHAs.
			// TODO(skeswa): find a way to log these.
			waitingSpecs.
				each(func(importPath string, waitingList specWaitingList) {
					waitingList.clear()
				}).
				clear()

			// Finally, close the channel.
			close(fetchSHAResultChan)
			fetchSHAResultChan = nil
		}

		// If both of the channels being selected are nil, its time to stop
		// selecting.
		if fetchSHAResultChan == nil &&
			packageSpecChan == nil &&
			importSpecChan == nil {
			// At this point, the deps chan should be closed since all potential deps
			// have been seen and processed.
			close(revisionChan)
			revisionChan = nil

			// Wait until all the deps are revised before continuing.
			revisionWaitGroup.Wait()

			// Exit the inifinte loop.
			break
		}
	}

	// If there were any errors, compose all the errors into on error.
	if accumulatedErrors.len() > 0 {
		return concatErrors(accumulatedErrors)
	}

	// Otherwise, return without a hitch.
	return nil
}

// enqueueImportRevision is a helper function that puts a revision into the
// revision channel that revises an import statement.
func enqueueImportRevision(
	revisionChan chan *revision,
	importPath string,
	sha string,
	generatedInternalDirName string,
	spec *importSpec,
) {
	author, repo, subpath := parseImportPath(importPath)
	newImportPath := composeNewImportPath(
		author,
		repo,
		sha,
		subpath,
		generatedInternalDirName)

	revisionChan <- newImportRevision(spec, newImportPath)
}

// enqueuePackageRevision is a helper function that puts a revision into the
// revision channel that (potentially) revises a package statement.
func enqueuePackageRevision(revisionChan chan *revision, spec *packageSpec) {
	revisionChan <- newPackageRevision(spec)
}

// concatErrors joins all the accumulated individual errors into one combined
// error.
func concatErrors(accumulatedErrors *syncedErrors) error {
	errs := accumulatedErrors.get()
	buffer := bytes.Buffer{}

	buffer.WriteString("Failed to process dependencies. Bumped into ")
	buffer.WriteString(strconv.Itoa(len(errs)))
	buffer.WriteString(" problems: [ ")

	for i, err := range errs {
		if i > 0 {
			buffer.WriteString(", ")
		}
		buffer.WriteString(err.Error())
	}

	buffer.WriteString(" ].")

	return errors.New(buffer.String())
}
