package git_pages

import (
	"context"
	"errors"
	"fmt"
	"io"
	"strings"

	"google.golang.org/protobuf/proto"
)

type UpdateOutcome int

const (
	UpdateError UpdateOutcome = iota
	UpdateTimeout
	UpdateCreated
	UpdateReplaced
	UpdateDeleted
	UpdateNoChange
)

type UpdateResult struct {
	outcome  UpdateOutcome
	manifest *Manifest
	err      error
}

func Update(
	ctx context.Context, webRoot string, oldManifest, newManifest *Manifest,
	opts ModifyManifestOptions,
) UpdateResult {
	var err error
	var storedManifest *Manifest

	outcome := UpdateError
	if IsManifestEmpty(newManifest) {
		storedManifest, err = newManifest, backend.DeleteManifest(ctx, webRoot, opts)
		if err == nil {
			if oldManifest == nil {
				outcome = UpdateNoChange
			} else {
				outcome = UpdateDeleted
			}
		}
	} else if err = PrepareManifest(ctx, newManifest); err == nil {
		storedManifest, err = StoreManifest(ctx, webRoot, newManifest, opts)
		if err == nil {
			domain, _, _ := strings.Cut(webRoot, "/")
			err = backend.CreateDomain(ctx, domain)
		}
		if err == nil {
			if oldManifest == nil {
				outcome = UpdateCreated
			} else if CompareManifest(oldManifest, storedManifest) {
				outcome = UpdateNoChange
			} else {
				outcome = UpdateReplaced
			}
		}
	}

	if err == nil {
		status := ""
		switch outcome {
		case UpdateCreated:
			status = "created"
		case UpdateReplaced:
			status = "replaced"
		case UpdateDeleted:
			status = "deleted"
		case UpdateNoChange:
			status = "unchanged"
		}
		if storedManifest.Commit != nil {
			logc.Printf(ctx, "update %s ok: %s %s", webRoot, *storedManifest.Commit, status)
		} else {
			logc.Printf(ctx, "update %s ok: %s", webRoot, status)
		}
	} else {
		logc.Printf(ctx, "update %s err: %s", webRoot, err)
	}

	return UpdateResult{outcome, storedManifest, err}
}

func UpdateFromRepository(
	ctx context.Context,
	webRoot string,
	repoURL string,
	branch string,
) (result UpdateResult) {
	span, ctx := ObserveFunction(ctx, "UpdateFromRepository", "repo.url", repoURL)
	defer span.Finish()

	logc.Printf(ctx, "update %s: %s %s\n", webRoot, repoURL, branch)

	// Ignore errors; worst case we have to re-fetch all of the blobs.
	oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{})

	newManifest, err := FetchRepository(ctx, repoURL, branch, oldManifest)
	if errors.Is(err, context.DeadlineExceeded) {
		result = UpdateResult{UpdateTimeout, nil, fmt.Errorf("update timeout")}
	} else if err != nil {
		result = UpdateResult{UpdateError, nil, err}
	} else {
		result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{})
	}

	observeUpdateResult(result)
	return result
}

var errArchiveFormat = errors.New("unsupported archive format")

func UpdateFromArchive(
	ctx context.Context,
	webRoot string,
	contentType string,
	reader io.Reader,
) (result UpdateResult) {
	var err error

	// Ignore errors; worst case we have to re-fetch all of the blobs.
	oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{})

	extractTar := func(ctx context.Context, reader io.Reader) (*Manifest, error) {
		return ExtractTar(ctx, reader, oldManifest)
	}

	var newManifest *Manifest
	switch contentType {
	case "application/x-tar":
		logc.Printf(ctx, "update %s: (tar)", webRoot)
		newManifest, err = extractTar(ctx, reader) // yellow?
	case "application/x-tar+gzip":
		logc.Printf(ctx, "update %s: (tar.gz)", webRoot)
		newManifest, err = ExtractGzip(ctx, reader, extractTar) // definitely yellow.
	case "application/x-tar+zstd":
		logc.Printf(ctx, "update %s: (tar.zst)", webRoot)
		newManifest, err = ExtractZstd(ctx, reader, extractTar)
	case "application/zip":
		logc.Printf(ctx, "update %s: (zip)", webRoot)
		newManifest, err = ExtractZip(ctx, reader, oldManifest)
	default:
		err = errArchiveFormat
	}

	if err != nil {
		logc.Printf(ctx, "update %s err: %s", webRoot, err)
		result = UpdateResult{UpdateError, nil, err}
	} else {
		result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{})
	}

	observeUpdateResult(result)
	return
}

func PartialUpdateFromArchive(
	ctx context.Context,
	webRoot string,
	contentType string,
	reader io.Reader,
	parents CreateParentsMode,
) (result UpdateResult) {
	var err error

	// Here the old manifest is used both as a substrate to which a patch is applied, as well
	// as a "load linked" operation for a future "store conditional" update which, taken together,
	// create an atomic compare-and-swap operation.
	oldManifest, oldMetadata, err := backend.GetManifest(ctx, webRoot,
		GetManifestOptions{BypassCache: true})
	if err != nil {
		logc.Printf(ctx, "patch %s err: %s", webRoot, err)
		return UpdateResult{UpdateError, nil, err}
	}

	applyTarPatch := func(ctx context.Context, reader io.Reader) (*Manifest, error) {
		// Clone the manifest before starting to mutate it. `GetManifest` may return cached
		// `*Manifest` objects, which should never be mutated.
		newManifest := &Manifest{}
		proto.Merge(newManifest, oldManifest)
		newManifest.RepoUrl = nil
		newManifest.Branch = nil
		newManifest.Commit = nil
		if err := ApplyTarPatch(newManifest, reader, parents); err != nil {
			return nil, err
		} else {
			return newManifest, nil
		}
	}

	var newManifest *Manifest
	switch contentType {
	case "application/x-tar":
		logc.Printf(ctx, "patch %s: (tar)", webRoot)
		newManifest, err = applyTarPatch(ctx, reader)
	case "application/x-tar+gzip":
		logc.Printf(ctx, "patch %s: (tar.gz)", webRoot)
		newManifest, err = ExtractGzip(ctx, reader, applyTarPatch)
	case "application/x-tar+zstd":
		logc.Printf(ctx, "patch %s: (tar.zst)", webRoot)
		newManifest, err = ExtractZstd(ctx, reader, applyTarPatch)
	default:
		err = errArchiveFormat
	}

	if err != nil {
		logc.Printf(ctx, "patch %s err: %s", webRoot, err)
		result = UpdateResult{UpdateError, nil, err}
	} else {
		result = Update(ctx, webRoot, oldManifest, newManifest,
			ModifyManifestOptions{
				IfUnmodifiedSince: oldMetadata.LastModified,
				IfMatch:           oldMetadata.ETag,
			})
		// The `If-Unmodified-Since` precondition is internally generated here, which means its
		// failure shouldn't be surfaced as-is in the HTTP response. If we also accepted options
		// from the client, then that precondition failure should surface in the response.
		if errors.Is(result.err, ErrPreconditionFailed) {
			result.err = ErrWriteConflict
		}
	}

	observeUpdateResult(result)
	return
}

func observeUpdateResult(result UpdateResult) {
	var unresolvedRefErr UnresolvedRefError
	if errors.As(result.err, &unresolvedRefErr) {
		// This error is an expected outcome of an incremental update's probe phase.
	} else if errors.Is(result.err, ErrWriteConflict) {
		// This error is an expected outcome of an incremental update losing a race.
	} else if result.err != nil {
		ObserveError(result.err)
	}
}
