// Copyright 2018 Google Inc. All rights reserved.
//
// 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.

package main

import (
	"bytes"
	"fmt"
	"hash/crc32"
	"os"
	"strconv"
	"strings"
	"testing"
	"time"

	"android/soong/jar"
	"android/soong/third_party/zip"
)

type testZipEntry struct {
	name      string
	mode      os.FileMode
	data      []byte
	method    uint16
	timestamp time.Time
}

var (
	A     = testZipEntry{"A", 0755, []byte("foo"), zip.Deflate, jar.DefaultTime}
	a     = testZipEntry{"a", 0755, []byte("foo"), zip.Deflate, jar.DefaultTime}
	a2    = testZipEntry{"a", 0755, []byte("FOO2"), zip.Deflate, jar.DefaultTime}
	a3    = testZipEntry{"a", 0755, []byte("Foo3"), zip.Deflate, jar.DefaultTime}
	bDir  = testZipEntry{"b/", os.ModeDir | 0755, nil, zip.Deflate, jar.DefaultTime}
	bbDir = testZipEntry{"b/b/", os.ModeDir | 0755, nil, zip.Deflate, jar.DefaultTime}
	bbb   = testZipEntry{"b/b/b", 0755, nil, zip.Deflate, jar.DefaultTime}
	ba    = testZipEntry{"b/a", 0755, []byte("foo"), zip.Deflate, jar.DefaultTime}
	bc    = testZipEntry{"b/c", 0755, []byte("bar"), zip.Deflate, jar.DefaultTime}
	bd    = testZipEntry{"b/d", 0700, []byte("baz"), zip.Deflate, jar.DefaultTime}
	be    = testZipEntry{"b/e", 0700, []byte(""), zip.Deflate, jar.DefaultTime}

	withTimestamp    = testZipEntry{"timestamped", 0755, nil, zip.Store, jar.DefaultTime.Add(time.Hour)}
	withoutTimestamp = testZipEntry{"timestamped", 0755, nil, zip.Store, jar.DefaultTime}

	service1a        = testZipEntry{"META-INF/services/service1", 0755, []byte("class1\nclass2\n"), zip.Store, jar.DefaultTime}
	service1b        = testZipEntry{"META-INF/services/service1", 0755, []byte("class1\nclass3\n"), zip.Deflate, jar.DefaultTime}
	service1combined = testZipEntry{"META-INF/services/service1", 0755, []byte("class1\nclass2\nclass3\n"), zip.Store, jar.DefaultTime}
	service2         = testZipEntry{"META-INF/services/service2", 0755, []byte("class1\nclass2\n"), zip.Deflate, jar.DefaultTime}

	metainfDir     = testZipEntry{jar.MetaDir, os.ModeDir | 0755, nil, zip.Deflate, jar.DefaultTime}
	manifestFile   = testZipEntry{jar.ManifestFile, 0755, []byte("manifest"), zip.Deflate, jar.DefaultTime}
	manifestFile2  = testZipEntry{jar.ManifestFile, 0755, []byte("manifest2"), zip.Deflate, jar.DefaultTime}
	moduleInfoFile = testZipEntry{jar.ModuleInfoClass, 0755, []byte("module-info"), zip.Deflate, jar.DefaultTime}
)

type testInputZip struct {
	name    string
	entries []testZipEntry
	reader  *zip.Reader
}

func (tiz *testInputZip) Name() string {
	return tiz.name
}

func (tiz *testInputZip) Open() error {
	if tiz.reader == nil {
		tiz.reader = testZipEntriesToZipReader(tiz.entries)
	}
	return nil
}

func (tiz *testInputZip) Close() error {
	tiz.reader = nil
	return nil
}

func (tiz *testInputZip) Entries() []*zip.File {
	if tiz.reader == nil {
		panic(fmt.Errorf("%s: should be open to get entries", tiz.Name()))
	}
	return tiz.reader.File
}

func (tiz *testInputZip) IsOpen() bool {
	return tiz.reader != nil
}

func TestMergeZips(t *testing.T) {
	testCases := []struct {
		name             string
		in               [][]testZipEntry
		stripFiles       []string
		stripDirs        []string
		jar              bool
		par              bool
		sort             bool
		ignoreDuplicates bool
		stripDirEntries  bool
		zipsToNotStrip   map[string]bool

		out []testZipEntry
		err string
	}{
		{
			name: "duplicates error",
			in: [][]testZipEntry{
				{a},
				{a2},
				{a3},
			},
			out: []testZipEntry{a},
			err: "duplicate",
		},
		{
			name: "duplicates take first",
			in: [][]testZipEntry{
				{a},
				{a2},
				{a3},
			},
			out: []testZipEntry{a},

			ignoreDuplicates: true,
		},
		{
			name: "duplicates identical",
			in: [][]testZipEntry{
				{a},
				{a},
			},
			out: []testZipEntry{a},
		},
		{
			name: "sort",
			in: [][]testZipEntry{
				{be, bc, bDir, bbDir, bbb, A, metainfDir, manifestFile},
			},
			out: []testZipEntry{A, metainfDir, manifestFile, bDir, bbDir, bbb, bc, be},

			sort: true,
		},
		{
			name: "jar sort",
			in: [][]testZipEntry{
				{be, bc, bDir, A, metainfDir, manifestFile},
			},
			out: []testZipEntry{metainfDir, manifestFile, A, bDir, bc, be},

			jar: true,
		},
		{
			name: "jar merge",
			in: [][]testZipEntry{
				{metainfDir, manifestFile, bDir, be},
				{metainfDir, manifestFile2, bDir, bc},
				{metainfDir, manifestFile2, A},
			},
			out: []testZipEntry{metainfDir, manifestFile, A, bDir, bc, be},

			jar: true,
		},
		{
			name: "merge",
			in: [][]testZipEntry{
				{bDir, be},
				{bDir, bc},
				{A},
			},
			out: []testZipEntry{bDir, be, bc, A},
		},
		{
			name: "strip dir entries",
			in: [][]testZipEntry{
				{a, bDir, bbDir, bbb, bc, bd, be},
			},
			out: []testZipEntry{a, bbb, bc, bd, be},

			stripDirEntries: true,
		},
		{
			name: "strip files",
			in: [][]testZipEntry{
				{a, bDir, bbDir, bbb, bc, bd, be},
			},
			out: []testZipEntry{a, bDir, bbDir, bbb, bc},

			stripFiles: []string{"b/d", "b/e"},
		},
		{
			// merge_zips used to treat -stripFile a as stripping any file named a, it now only strips a in the
			// root of the zip.
			name: "strip file name",
			in: [][]testZipEntry{
				{a, bDir, ba},
			},
			out: []testZipEntry{bDir, ba},

			stripFiles: []string{"a"},
		},
		{
			name: "strip files glob",
			in: [][]testZipEntry{
				{a, bDir, ba},
			},
			out: []testZipEntry{bDir},

			stripFiles: []string{"**/a"},
		},
		{
			name: "strip dirs",
			in: [][]testZipEntry{
				{a, bDir, bbDir, bbb, bc, bd, be},
			},
			out: []testZipEntry{a},

			stripDirs: []string{"b"},
		},
		{
			name: "strip dirs glob",
			in: [][]testZipEntry{
				{a, bDir, bbDir, bbb, bc, bd, be},
			},
			out: []testZipEntry{a, bDir, bc, bd, be},

			stripDirs: []string{"b/*"},
		},
		{
			name: "zips to not strip",
			in: [][]testZipEntry{
				{a, bDir, bc},
				{bDir, bd},
				{bDir, be},
			},
			out: []testZipEntry{a, bDir, bd},

			stripDirs: []string{"b"},
			zipsToNotStrip: map[string]bool{
				"in1": true,
			},
		},
		{
			name: "services",
			in: [][]testZipEntry{
				{service1a, service2},
				{service1b},
			},
			jar: true,
			out: []testZipEntry{service1combined, service2},
		},
		{
			name: "strip timestamps",
			in: [][]testZipEntry{
				{withTimestamp},
				{a},
			},
			out: []testZipEntry{withoutTimestamp, a},
		},
		{
			name: "emulate par",
			in: [][]testZipEntry{
				{
					testZipEntry{name: "3/main.py"},
					testZipEntry{name: "c/main.py"},
					testZipEntry{name: "a/main.py"},
					testZipEntry{name: "2/main.py"},
					testZipEntry{name: "b/main.py"},
					testZipEntry{name: "1/main.py"},
				},
			},
			out: []testZipEntry{
				testZipEntry{name: "3/__init__.py", mode: 0700, timestamp: jar.DefaultTime},
				testZipEntry{name: "c/__init__.py", mode: 0700, timestamp: jar.DefaultTime},
				testZipEntry{name: "a/__init__.py", mode: 0700, timestamp: jar.DefaultTime},
				testZipEntry{name: "2/__init__.py", mode: 0700, timestamp: jar.DefaultTime},
				testZipEntry{name: "b/__init__.py", mode: 0700, timestamp: jar.DefaultTime},
				testZipEntry{name: "1/__init__.py", mode: 0700, timestamp: jar.DefaultTime},
				testZipEntry{name: "3/main.py", timestamp: jar.DefaultTime},
				testZipEntry{name: "c/main.py", timestamp: jar.DefaultTime},
				testZipEntry{name: "a/main.py", timestamp: jar.DefaultTime},
				testZipEntry{name: "2/main.py", timestamp: jar.DefaultTime},
				testZipEntry{name: "b/main.py", timestamp: jar.DefaultTime},
				testZipEntry{name: "1/main.py", timestamp: jar.DefaultTime},
			},
			par: true,
		},
	}

	for _, test := range testCases {
		t.Run(test.name, func(t *testing.T) {
			inputZips := make([]InputZip, len(test.in))
			for i, in := range test.in {
				inputZips[i] = &testInputZip{name: "in" + strconv.Itoa(i), entries: in}
			}

			want := testZipEntriesToBuf(test.out)

			out := &bytes.Buffer{}
			writer := zip.NewWriter(out)

			err := mergeZips(inputZips, writer, "", "",
				test.sort, test.jar, test.par, test.stripDirEntries, test.ignoreDuplicates,
				test.stripFiles, test.stripDirs, test.zipsToNotStrip)

			closeErr := writer.Close()
			if closeErr != nil {
				t.Fatal(closeErr)
			}

			if test.err != "" {
				if err == nil {
					t.Fatal("missing err, expected: ", test.err)
				} else if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.err)) {
					t.Fatal("incorrect err, want:", test.err, "got:", err)
				}
				return
			} else if err != nil {
				t.Fatal("unexpected err: ", err)
			}

			if !bytes.Equal(want, out.Bytes()) {
				t.Error("incorrect zip output")
				t.Errorf("want:\n%s", dumpZip(want))
				t.Errorf("got:\n%s", dumpZip(out.Bytes()))
				os.WriteFile("/tmp/got.zip", out.Bytes(), 0755)
				os.WriteFile("/tmp/want.zip", want, 0755)
			}
		})
	}
}

func testZipEntriesToBuf(entries []testZipEntry) []byte {
	b := &bytes.Buffer{}
	zw := zip.NewWriter(b)

	for _, e := range entries {
		fh := zip.FileHeader{
			Name: e.name,
		}
		fh.SetMode(e.mode)
		fh.Method = e.method
		fh.SetModTime(e.timestamp)
		fh.UncompressedSize64 = uint64(len(e.data))
		fh.CRC32 = crc32.ChecksumIEEE(e.data)
		if fh.Method == zip.Store {
			fh.CompressedSize64 = fh.UncompressedSize64
		}

		w, err := zw.CreateHeaderAndroid(&fh)
		if err != nil {
			panic(err)
		}

		_, err = w.Write(e.data)
		if err != nil {
			panic(err)
		}
	}

	err := zw.Close()
	if err != nil {
		panic(err)
	}

	return b.Bytes()
}

func testZipEntriesToZipReader(entries []testZipEntry) *zip.Reader {
	b := testZipEntriesToBuf(entries)
	r := bytes.NewReader(b)

	zr, err := zip.NewReader(r, int64(len(b)))
	if err != nil {
		panic(err)
	}

	return zr
}

func dumpZip(buf []byte) string {
	r := bytes.NewReader(buf)
	zr, err := zip.NewReader(r, int64(len(buf)))
	if err != nil {
		panic(err)
	}

	var ret string

	for _, f := range zr.File {
		ret += fmt.Sprintf("%v: %v %v %08x %s\n", f.Name, f.Mode(), f.UncompressedSize64, f.CRC32, f.ModTime())
	}

	return ret
}

type DummyInpuZip struct {
	isOpen bool
}

func (diz *DummyInpuZip) Name() string {
	return "dummy"
}

func (diz *DummyInpuZip) Open() error {
	diz.isOpen = true
	return nil
}

func (diz *DummyInpuZip) Close() error {
	diz.isOpen = false
	return nil
}

func (DummyInpuZip) Entries() []*zip.File {
	panic("implement me")
}

func (diz *DummyInpuZip) IsOpen() bool {
	return diz.isOpen
}

func TestInputZipsManager(t *testing.T) {
	const nInputZips = 20
	const nMaxOpenZips = 10
	izm := NewInputZipsManager(20, 10)
	managedZips := make([]InputZip, nInputZips)
	for i := 0; i < nInputZips; i++ {
		managedZips[i] = izm.Manage(&DummyInpuZip{})
	}

	t.Run("InputZipsManager", func(t *testing.T) {
		for i, iz := range managedZips {
			if err := iz.Open(); err != nil {
				t.Fatalf("Step %d: open failed: %s", i, err)
				return
			}
			if izm.nOpenZips > nMaxOpenZips {
				t.Errorf("Step %d: should be <=%d open zips", i, nMaxOpenZips)
			}
		}
		if !managedZips[nInputZips-1].IsOpen() {
			t.Error("The last input should stay open")
		}
		for _, iz := range managedZips {
			iz.Close()
		}
		if izm.nOpenZips > 0 {
			t.Error("Some input zips are still open")
		}
	})
}
