package dockerfile

import (
	"context"
	"encoding/json"
	"testing"

	"github.com/containerd/continuity/fs/fstest"
	"github.com/moby/buildkit/client"
	"github.com/moby/buildkit/frontend/dockerui"
	gateway "github.com/moby/buildkit/frontend/gateway/client"
	"github.com/moby/buildkit/frontend/subrequests"
	"github.com/moby/buildkit/frontend/subrequests/outline"
	"github.com/moby/buildkit/util/testutil/integration"
	"github.com/moby/buildkit/util/testutil/workers"
	"github.com/pkg/errors"
	"github.com/stretchr/testify/require"
	"github.com/tonistiigi/fsutil"
)

var outlineTests = integration.TestFuncs(
	testOutlineArgs,
	testOutlineSecrets,
	testOutlineDescribeDefinition,
	testOutlineRecursiveArgs,
)

func testOutlineArgs(t *testing.T, sb integration.Sandbox) {
	workers.CheckFeatureCompat(t, sb, workers.FeatureFrontendOutline)
	f := getFrontend(t, sb)
	if _, ok := f.(*clientFrontend); !ok {
		t.Skip("only test with client frontend")
	}

	dockerfile := []byte(integration.UnixOrWindows(
		`
ARG inherited=box
ARG inherited2=box2
ARG unused=abc${inherited2}
# sfx is a suffix
ARG sfx="usy${inherited}"

FROM b${sfx} AS first
# this is not assigned to anything
ARG FOO=123
# BAR is a number
ARG BAR=456
RUN true

FROM alpine${unused} AS second
ARG BAZ
RUN true

FROM scratch AS third
ARG ABC=a
# check=skip=all
ARG password
# alternate_password will show up in the outline
# check=skip=all
ARG alternate_password

# target defines build target
FROM third AS target
COPY --from=first /etc/passwd /

FROM second
`,
		`
ARG inherited=server
ARG inherited2=server2
ARG unused=abc${inherited2}
# sfx is a suffix
ARG sfx="ano${inherited}"

FROM n${sfx} AS first
# this is not assigned to anything
ARG FOO=123
# BAR is a number
ARG BAR=456
RUN exit 0

FROM nanoserver${unused} AS second
ARG BAZ
RUN exit 0

FROM nanoserver AS third
ARG ABC=a
# check=skip=all
ARG password
# alternate_password will show up in the outline
# check=skip=all
ARG alternate_password

# target defines build target
FROM third AS target
COPY --from=first /License.txt /license

FROM second
`,
	))

	dir := integration.Tmpdir(
		t,
		fstest.CreateFile("Dockerfile", dockerfile, 0600),
	)

	c, err := client.New(sb.Context(), sb.Address())
	require.NoError(t, err)
	defer c.Close()

	called := false
	frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
		res, err := c.Solve(ctx, gateway.SolveRequest{
			FrontendOpt: map[string]string{
				"frontend.caps": "moby.buildkit.frontend.subrequests",
				"requestid":     "frontend.outline",
				"build-arg:BAR": "678",
				"target":        "target",
			},
			Frontend: "dockerfile.v0",
		})
		require.NoError(t, err)

		outline, err := unmarshalOutline(res)
		require.NoError(t, err)

		require.Equal(t, "target", outline.Name)
		require.Equal(t, "defines build target", outline.Description)

		require.Equal(t, 1, len(outline.Sources))
		require.Equal(t, dockerfile, outline.Sources[0])

		require.Equal(t, 7, len(outline.Args))

		arg := outline.Args[0]
		require.Equal(t, "inherited", arg.Name)
		require.Equal(t, integration.UnixOrWindows("box", "server"), arg.Value)
		require.Equal(t, "", arg.Description)
		require.Equal(t, int32(0), arg.Location.SourceIndex)
		require.Equal(t, int32(2), arg.Location.Ranges[0].Start.Line)

		arg = outline.Args[1]
		require.Equal(t, "sfx", arg.Name)
		require.Equal(t, integration.UnixOrWindows("usybox", "anoserver"), arg.Value)
		require.Equal(t, "is a suffix", arg.Description)
		require.Equal(t, int32(6), arg.Location.Ranges[0].Start.Line)

		arg = outline.Args[2]
		require.Equal(t, "FOO", arg.Name)
		require.Equal(t, "123", arg.Value)
		require.Equal(t, "", arg.Description)
		require.Equal(t, int32(10), arg.Location.Ranges[0].Start.Line)

		arg = outline.Args[3]
		require.Equal(t, "BAR", arg.Name)
		require.Equal(t, "678", arg.Value)
		require.Equal(t, "is a number", arg.Description)

		arg = outline.Args[4]
		require.Equal(t, "ABC", arg.Name)
		require.Equal(t, "a", arg.Value)

		arg = outline.Args[5]
		require.Equal(t, "password", arg.Name)
		require.Equal(t, "", arg.Value)
		require.Equal(t, "", arg.Description)
		require.Equal(t, int32(22), arg.Location.Ranges[0].Start.Line)

		arg = outline.Args[6]
		require.Equal(t, "alternate_password", arg.Name)
		require.Equal(t, "", arg.Value)
		require.Equal(t, "will show up in the outline", arg.Description)
		require.Equal(t, int32(25), arg.Location.Ranges[0].Start.Line)

		called = true
		return nil, nil
	}

	_, err = c.Build(sb.Context(), client.SolveOpt{
		LocalMounts: map[string]fsutil.FS{
			dockerui.DefaultLocalNameDockerfile: dir,
		},
	}, "", frontend, nil)
	require.NoError(t, err)

	require.True(t, called)
}

// testOutlineSecrets checks that BuildKit can look at a Dockerfile and correctly
// list the secrets and SSH mounts that a given build target needs.
//
// The outline feature only reads the Dockerfile — it never actually runs the build.
// So this test makes sure that:
//   - Only secrets/SSH used by the target stage (and its dependencies) are listed.
//     Secrets and SSH declared in unrelated stages are ignored.
//   - Each secret and SSH entry has the right name, required flag, and line number.
//   - Build args inside mount ids (like id=second${BAR}) get expanded correctly.
//
// Because nothing is executed, the Linux and Windows versions of the Dockerfile
// only differ in cosmetic ways (base images and the dummy command). The line
// numbers are kept identical so the same assertions work on both platforms.
func testOutlineSecrets(t *testing.T, sb integration.Sandbox) {
	workers.CheckFeatureCompat(t, sb, workers.FeatureFrontendOutline)
	f := getFrontend(t, sb)
	if _, ok := f.(*clientFrontend); !ok {
		t.Skip("only test with client frontend")
	}

	dockerfile := []byte(integration.UnixOrWindows(
		`
FROM busybox AS first
RUN --mount=type=secret,target=/etc/passwd,required=true --mount=type=ssh true

FROM alpine AS second
RUN --mount=type=secret,id=unused --mount=type=ssh,id=ssh2 true

FROM scratch AS third
ARG BAR
RUN --mount=type=secret,id=second${BAR} true

FROM third AS target
COPY --from=first /foo /
RUN --mount=type=ssh,id=ssh3,required true

FROM second
`,
		`
FROM nanoserver AS first
RUN --mount=type=secret,target=/etc/passwd,required=true --mount=type=ssh exit 0

FROM nanoserver AS second
RUN --mount=type=secret,id=unused,target=C:/unused --mount=type=ssh,id=ssh2 exit 0

FROM nanoserver AS third
ARG BAR
RUN --mount=type=secret,id=second${BAR},target=C:/second exit 0

FROM third AS target
COPY --from=first /License.txt /
RUN --mount=type=ssh,id=ssh3,required exit 0

FROM second
`,
	))

	dir := integration.Tmpdir(
		t,
		fstest.CreateFile("Dockerfile", dockerfile, 0600),
	)

	c, err := client.New(sb.Context(), sb.Address())
	require.NoError(t, err)
	defer c.Close()

	called := false
	frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
		res, err := c.Solve(ctx, gateway.SolveRequest{
			FrontendOpt: map[string]string{
				"frontend.caps": "moby.buildkit.frontend.subrequests",
				"requestid":     "frontend.outline",
				"build-arg:BAR": "678",
				"target":        "target",
			},
			Frontend: "dockerfile.v0",
		})
		require.NoError(t, err)

		outline, err := unmarshalOutline(res)
		require.NoError(t, err)

		require.Equal(t, 1, len(outline.Sources))
		require.Equal(t, dockerfile, outline.Sources[0])

		require.Equal(t, 2, len(outline.Secrets))

		secret := outline.Secrets[0]
		require.Equal(t, "passwd", secret.Name)
		require.Equal(t, true, secret.Required)
		require.Equal(t, int32(0), secret.Location.SourceIndex)
		require.Equal(t, int32(3), secret.Location.Ranges[0].Start.Line)

		secret = outline.Secrets[1]
		require.Equal(t, "second678", secret.Name)
		require.Equal(t, false, secret.Required)
		require.Equal(t, int32(0), secret.Location.SourceIndex)
		require.Equal(t, int32(10), secret.Location.Ranges[0].Start.Line)

		require.Equal(t, 2, len(outline.SSH))

		ssh := outline.SSH[0]
		require.Equal(t, "default", ssh.Name)
		require.Equal(t, false, ssh.Required)
		require.Equal(t, int32(0), ssh.Location.SourceIndex)
		require.Equal(t, int32(3), ssh.Location.Ranges[0].Start.Line)

		ssh = outline.SSH[1]
		require.Equal(t, "ssh3", ssh.Name)
		require.Equal(t, true, ssh.Required)
		require.Equal(t, int32(0), ssh.Location.SourceIndex)
		require.Equal(t, int32(14), ssh.Location.Ranges[0].Start.Line)

		called = true
		return nil, nil
	}

	_, err = c.Build(sb.Context(), client.SolveOpt{
		LocalMounts: map[string]fsutil.FS{
			dockerui.DefaultLocalNameDockerfile: dir,
		},
	}, "", frontend, nil)
	require.NoError(t, err)

	require.True(t, called)
}

func testOutlineRecursiveArgs(t *testing.T, sb integration.Sandbox) {
	workers.CheckFeatureCompat(t, sb, workers.FeatureFrontendOutline)
	f := getFrontend(t, sb)
	if _, ok := f.(*clientFrontend); !ok {
		t.Skip("only test with client frontend")
	}

	dockerfile := []byte(`
ARG FOO=123
ARG ABC=abc
ARG DEF=def
ARG FOO=${FOO}${ABC}
ARG BAR=${FOO}456
ARG FOO=${FOO}456${BAR}
FROM scratch
ARG FOO
ARG INFOO=123
ARG INBAR=${INFOO}456
ARG INFOO=${INFOO}456${INBAR}
`)

	dir := integration.Tmpdir(
		t,
		fstest.CreateFile("Dockerfile", dockerfile, 0600),
	)

	c, err := client.New(sb.Context(), sb.Address())
	require.NoError(t, err)
	defer c.Close()

	called := false
	frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
		res, err := c.Solve(ctx, gateway.SolveRequest{
			FrontendOpt: map[string]string{
				"frontend.caps": "moby.buildkit.frontend.subrequests",
				"requestid":     "frontend.outline",
			},
			Frontend: "dockerfile.v0",
		})
		require.NoError(t, err)

		outline, err := unmarshalOutline(res)
		require.NoError(t, err)

		require.Len(t, outline.Args, 5)

		require.Equal(t, "ABC", outline.Args[0].Name)
		require.Equal(t, "abc", outline.Args[0].Value)

		require.Equal(t, "BAR", outline.Args[1].Name)
		require.Equal(t, "123abc456", outline.Args[1].Value)

		require.Equal(t, "FOO", outline.Args[2].Name)
		require.Equal(t, "123abc456123abc456", outline.Args[2].Value)

		require.Equal(t, "INBAR", outline.Args[3].Name)
		require.Equal(t, "123456", outline.Args[3].Value)

		require.Equal(t, "INFOO", outline.Args[4].Name)
		require.Equal(t, "123456123456", outline.Args[4].Value)

		called = true
		return nil, nil
	}

	_, err = c.Build(sb.Context(), client.SolveOpt{
		LocalMounts: map[string]fsutil.FS{
			dockerui.DefaultLocalNameDockerfile: dir,
		},
	}, "", frontend, nil)
	require.NoError(t, err)

	require.True(t, called)
}

func testOutlineDescribeDefinition(t *testing.T, sb integration.Sandbox) {
	workers.CheckFeatureCompat(t, sb, workers.FeatureFrontendOutline)
	f := getFrontend(t, sb)
	if _, ok := f.(*clientFrontend); !ok {
		t.Skip("only test with client frontend")
	}

	c, err := client.New(sb.Context(), sb.Address())
	require.NoError(t, err)
	defer c.Close()

	dockerfile := []byte(integration.UnixOrWindows(
		`
FROM scratch
COPY Dockerfile Dockerfile
`,
		`
FROM nanoserver
COPY Dockerfile Dockerfile
`,
	))

	dir := integration.Tmpdir(
		t,
		fstest.CreateFile("Dockerfile", dockerfile, 0600),
	)

	called := false

	frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
		reqs, err := subrequests.Describe(ctx, c)
		require.NoError(t, err)

		require.Greater(t, len(reqs), 0)

		hasOutline := false

		for _, req := range reqs {
			if req.Name != "frontend.outline" {
				continue
			}
			hasOutline = true
			require.Equal(t, subrequests.RequestType("rpc"), req.Type)
			require.NotEqual(t, "", req.Version)
		}
		require.True(t, hasOutline)

		called = true
		return nil, nil
	}

	_, err = c.Build(sb.Context(), client.SolveOpt{
		LocalMounts: map[string]fsutil.FS{
			dockerui.DefaultLocalNameDockerfile: dir,
		},
	}, "", frontend, nil)
	require.NoError(t, err)

	require.True(t, called)
}

func unmarshalOutline(res *gateway.Result) (*outline.Outline, error) {
	dt, ok := res.Metadata["result.json"]
	if !ok {
		return nil, errors.New("missing frontend.outline")
	}
	var o outline.Outline
	if err := json.Unmarshal(dt, &o); err != nil {
		return nil, err
	}
	return &o, nil
}
