// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1

package planfile

import (
	"archive/zip"
	"bytes"
	"fmt"
	"io"
	"os"

	"github.com/hashicorp/terraform/internal/configs/configload"
	"github.com/hashicorp/terraform/internal/depsfile"
	"github.com/hashicorp/terraform/internal/plans"
	"github.com/hashicorp/terraform/internal/states/statefile"
	"github.com/hashicorp/terraform/internal/tfdiags"
)

const tfstateFilename = "tfstate"
const tfstatePreviousFilename = "tfstate-prev"
const dependencyLocksFilename = ".terraform.lock.hcl" // matches the conventional name in an input configuration

// ErrUnusableLocalPlan is an error wrapper to indicate that we *think* the
// input represents plan file data, but can't use it for some reason (as
// explained in the error text). Callers can check against this type with
// errors.As() if they need to distinguish between corrupt plan files and more
// fundamental problems like an empty file.
type ErrUnusableLocalPlan struct {
	inner error
}

func errUnusable(err error) *ErrUnusableLocalPlan {
	return &ErrUnusableLocalPlan{inner: err}
}
func (e *ErrUnusableLocalPlan) Error() string {
	return e.inner.Error()
}
func (e *ErrUnusableLocalPlan) Unwrap() error {
	return e.inner
}

// Reader is the main type used to read plan files. Create a Reader by calling
// Open.
//
// A plan file is a random-access file format, so methods of Reader must
// be used to access the individual portions of the file for further
// processing.
type Reader struct {
	zip *zip.ReadCloser
}

// Open creates a Reader for the file at the given filename, or returns an error
// if the file doesn't seem to be a planfile. NOTE: Most commands that accept a
// plan file should use OpenWrapped instead, so they can support both local and
// cloud plan files.
func Open(filename string) (*Reader, error) {
	r, err := zip.OpenReader(filename)
	if err != nil {
		// To give a better error message, we'll sniff to see if this looks
		// like our old plan format from versions prior to 0.12.
		if b, sErr := os.ReadFile(filename); sErr == nil {
			if bytes.HasPrefix(b, []byte("tfplan")) {
				return nil, errUnusable(fmt.Errorf("the given plan file was created by an earlier version of Terraform; plan files cannot be shared between different Terraform versions"))
			}
		}
		return nil, err
	}

	// Sniff to make sure this looks like a plan file, as opposed to any other
	// random zip file the user might have around.
	var planFile *zip.File
	for _, file := range r.File {
		if file.Name == tfplanFilename {
			planFile = file
			break
		}
	}
	if planFile == nil {
		return nil, fmt.Errorf("the given file is not a valid plan file")
	}

	// For now, we'll just accept the presence of the tfplan file as enough,
	// and wait to validate the version when the caller requests the plan
	// itself.

	return &Reader{
		zip: r,
	}, nil
}

// ReadPlan reads the plan embedded in the plan file.
//
// Errors can be returned for various reasons, including if the plan file
// is not of an appropriate format version, if it was created by a different
// version of Terraform, if it is invalid, etc.
func (r *Reader) ReadPlan() (*plans.Plan, error) {
	var planFile *zip.File
	for _, file := range r.zip.File {
		if file.Name == tfplanFilename {
			planFile = file
			break
		}
	}
	if planFile == nil {
		// This should never happen because we checked for this file during
		// Open, but we'll check anyway to be safe.
		return nil, errUnusable(fmt.Errorf("the plan file is invalid"))
	}

	pr, err := planFile.Open()
	if err != nil {
		return nil, errUnusable(fmt.Errorf("failed to retrieve plan from plan file: %s", err))
	}
	defer pr.Close()

	// There's a slight mismatch in how plans.Plan is modeled vs. how
	// the underlying plan file format works, because the "tfplan" embedded
	// file contains only some top-level metadata and the planned changes,
	// and not the previous run or prior states. Therefore we need to
	// build this up in multiple steps.
	// This is some technical debt because historically we considered the
	// planned changes and prior state as totally separate, but later realized
	// that it made sense for a plans.Plan to include the prior state directly
	// so we can see what state the plan applies to. Hopefully later we'll
	// clean this up some more so that we don't have two different ways to
	// access the prior state (this and the ReadStateFile method).
	ret, err := readTfplan(pr)
	if err != nil {
		return nil, errUnusable(err)
	}

	prevRunStateFile, err := r.ReadPrevStateFile()
	if err != nil {
		return nil, errUnusable(fmt.Errorf("failed to read previous run state from plan file: %s", err))
	}
	priorStateFile, err := r.ReadStateFile()
	if err != nil {
		return nil, errUnusable(fmt.Errorf("failed to read prior state from plan file: %s", err))
	}

	ret.PrevRunState = prevRunStateFile.State
	ret.PriorState = priorStateFile.State

	return ret, nil
}

// ReadStateFile reads the state file embedded in the plan file, which
// represents the "PriorState" as defined in plans.Plan.
//
// If the plan file contains no embedded state file, the returned error is
// statefile.ErrNoState.
func (r *Reader) ReadStateFile() (*statefile.File, error) {
	for _, file := range r.zip.File {
		if file.Name == tfstateFilename {
			r, err := file.Open()
			if err != nil {
				return nil, errUnusable(fmt.Errorf("failed to extract state from plan file: %s", err))
			}
			return statefile.Read(r)
		}
	}
	return nil, errUnusable(statefile.ErrNoState)
}

// ReadPrevStateFile reads the previous state file embedded in the plan file, which
// represents the "PrevRunState" as defined in plans.Plan.
//
// If the plan file contains no embedded previous state file, the returned error is
// statefile.ErrNoState.
func (r *Reader) ReadPrevStateFile() (*statefile.File, error) {
	for _, file := range r.zip.File {
		if file.Name == tfstatePreviousFilename {
			r, err := file.Open()
			if err != nil {
				return nil, errUnusable(fmt.Errorf("failed to extract previous state from plan file: %s", err))
			}
			return statefile.Read(r)
		}
	}
	return nil, errUnusable(statefile.ErrNoState)
}

// ReadConfigSnapshot reads the configuration snapshot embedded in the plan
// file.
//
// This is a lower-level alternative to ReadConfig that just extracts the
// source files, without attempting to parse them.
func (r *Reader) ReadConfigSnapshot() (*configload.Snapshot, error) {
	return ReadConfigSnapshot(&r.zip.Reader)
}

// ReadDependencyLocks reads the dependency lock information embedded in
// the plan file.
//
// Some test codepaths create plan files without dependency lock information,
// but all main codepaths should populate this. If reading a file without
// the dependency information, this will return error diagnostics.
func (r *Reader) ReadDependencyLocks() (*depsfile.Locks, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	for _, file := range r.zip.File {
		if file.Name == dependencyLocksFilename {
			r, err := file.Open()
			if err != nil {
				diags = diags.Append(tfdiags.Sourceless(
					tfdiags.Error,
					"Failed to read dependency locks from plan file",
					fmt.Sprintf("Couldn't read the dependency lock information embedded in the plan file: %s.", err),
				))
				return nil, diags
			}
			src, err := io.ReadAll(r)
			if err != nil {
				diags = diags.Append(tfdiags.Sourceless(
					tfdiags.Error,
					"Failed to read dependency locks from plan file",
					fmt.Sprintf("Couldn't read the dependency lock information embedded in the plan file: %s.", err),
				))
				return nil, diags
			}
			locks, moreDiags := depsfile.LoadLocksFromBytes(src, "<saved-plan>")
			diags = diags.Append(moreDiags)
			return locks, diags
		}
	}

	// If we fall out here then this is a file without dependency information.
	diags = diags.Append(tfdiags.Sourceless(
		tfdiags.Error,
		"Saved plan has no dependency lock information",
		"The specified saved plan file does not include any dependency lock information. This is a bug in the previous run of Terraform that created this file.",
	))
	return nil, diags
}

// Close closes the file, after which no other operations may be performed.
func (r *Reader) Close() error {
	return r.zip.Close()
}
