package main

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

func TestIndexServerReadCommand(t *testing.T) {
	// Start a read-only server
	addr, cancel := startIndexServer(t, "-s", "testdata")
	defer cancel()

	// Run a "list-chunks" command on a valid index to confirm it can be read
	listCmd := newListCommand(context.Background())
	listCmd.SetArgs([]string{fmt.Sprintf("http://%s/blob1.caibx", addr)})
	stdout = io.Discard
	listCmd.SetOutput(io.Discard)
	_, err := listCmd.ExecuteC()
	require.NoError(t, err)

	// The index server should not be serving up arbitrary files from disk even if
	// they're in the store. Try to HTTP GET a non-index file expecting a 400 from
	// the server.
	resp, err := http.Get(fmt.Sprintf("http://%s/blob1", addr))
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusBadRequest, resp.StatusCode)

	// This server shouldn't allow writing. Confirm by trying to chunk a file with
	// the "make" command and storing a new index on the index server.
	makeCmd := newMakeCommand(context.Background())
	makeCmd.SetArgs([]string{fmt.Sprintf("http://%s/new.caibx", addr), "testdata/blob1"})
	makeCmd.SetOutput(io.Discard)
	_, err = makeCmd.ExecuteC()
	require.Error(t, err)
	require.Contains(t, err.Error(), "writing to upstream")
}

func TestIndexServerWriteCommand(t *testing.T) {
	// Create an empty store to be used for writing
	store := t.TempDir()

	// Start a read-write server
	addr, cancel := startIndexServer(t, "-s", store, "-w")
	defer cancel()

	// This server should allow writing. Confirm by trying to chunk a file with
	// the "make" command and storing a new index on the index server.
	makeCmd := newMakeCommand(context.Background())
	makeCmd.SetArgs([]string{fmt.Sprintf("http://%s/new.caibx", addr), "testdata/blob1"})
	makeCmd.SetOutput(io.Discard)
	_, err := makeCmd.ExecuteC()
	require.NoError(t, err)

	// The index server should not accept arbitrary (non-index) files.
	req, _ := http.NewRequest("PUT", fmt.Sprintf("http://%s/invalid.caibx", addr), strings.NewReader("invalid"))
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode)
}

// TestIndexServerPathTraversal ensures the index server rejects index names
// derived from request paths that contain path separators. A backslash is a
// path separator on Windows but not on POSIX; path.Base (forward-slash only)
// leaves it intact, so without validation a payload like
// "/..%5C..%5Cevil.caibx" would escape the store directory on a Windows host.
// The handler-level check rejects "/" and "\" regardless of host OS, so this
// test asserts a flat 400 (and that nothing is written outside the store) on
// all platforms.
func TestIndexServerPathTraversal(t *testing.T) {
	base := t.TempDir()
	store := filepath.Join(base, "store")
	require.NoError(t, os.Mkdir(store, 0755))

	// Sentinel path one level above the store that must never be created.
	target := filepath.Join(base, "evil.caibx")

	addr, cancel := startIndexServer(t, "-s", store, "-w")
	defer cancel()

	traversalURL := fmt.Sprintf("http://%s/..%%5C..%%5Cevil.caibx", addr)

	// PUT must be rejected with 400 and must not truncate/create a file
	// outside the store directory.
	req, _ := http.NewRequest("PUT", traversalURL, strings.NewReader("not-a-real-index"))
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
	require.NoFileExists(t, target)

	// GET and HEAD must also return a flat 400, leaking no file-existence
	// information (no 404-vs-400 oracle).
	resp, err = http.Get(traversalURL)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusBadRequest, resp.StatusCode)

	req, _ = http.NewRequest("HEAD", traversalURL, nil)
	resp, err = http.DefaultClient.Do(req)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
}

// TestIndexServerHead confirms HEAD accurately reflects index existence: an
// existing index returns 200 OK, a missing one returns 404 Not Found. This
// guards against the response inversion that previously made HEAD-before-PUT
// clients clobber existing indexes.
func TestIndexServerHead(t *testing.T) {
	addr, cancel := startIndexServer(t, "-s", "testdata")
	defer cancel()

	// Existing index -> 200 OK
	req, _ := http.NewRequest("HEAD", fmt.Sprintf("http://%s/blob1.caibx", addr), nil)
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusOK, resp.StatusCode)

	// Missing index -> 404 Not Found
	req, _ = http.NewRequest("HEAD", fmt.Sprintf("http://%s/does-not-exist.caibx", addr), nil)
	resp, err = http.DefaultClient.Do(req)
	require.NoError(t, err)
	resp.Body.Close()
	require.Equal(t, http.StatusNotFound, resp.StatusCode)
}

func startIndexServer(t *testing.T, args ...string) (string, context.CancelFunc) {
	// Find a free local port to be used to run the index server on
	l, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	addr := l.Addr().String()
	l.Close()

	// Flush any handlers that were registered in the default mux before
	http.DefaultServeMux = &http.ServeMux{}

	// Start the server in a goroutine. Cancel the context when done
	ctx, cancel := context.WithCancel(context.Background())
	cmd := newIndexServerCommand(ctx)
	cmd.SetArgs(append(args, "-l", addr))
	go func() {
		if _, err := cmd.ExecuteC(); err != nil && ctx.Err() == nil {
			t.Errorf("index server error: %v", err)
		}
	}()

	// Wait a little for the server to start
	time.Sleep(time.Second)
	return addr, cancel
}
