/*
Copyright The ORAS Authors.
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 trace

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"testing"
)

var errMockRead = errors.New("mock read error")

type errorReader struct{}

func (e *errorReader) Read(p []byte) (n int, err error) {
	return 0, errMockRead
}

func Test_isPrintableContentType(t *testing.T) {
	tests := []struct {
		name        string
		contentType string
		want        bool
	}{
		{
			name:        "Empty content type",
			contentType: "",
			want:        false,
		},
		{
			name:        "General JSON type",
			contentType: "application/json",
			want:        true,
		},
		{
			name:        "General JSON type with charset",
			contentType: "application/json; charset=utf-8",
			want:        true,
		},
		{
			name:        "Random type with application/json prefix",
			contentType: "application/jsonwhatever",
			want:        false,
		},
		{
			name:        "Manifest type in JSON",
			contentType: "application/vnd.oci.image.manifest.v1+json",
			want:        true,
		},
		{
			name:        "Manifest type in JSON with charset",
			contentType: "application/vnd.oci.image.manifest.v1+json; charset=utf-8",
			want:        true,
		},
		{
			name:        "Random content type in JSON",
			contentType: "application/whatever+json",
			want:        true,
		},
		{
			name:        "Plain text type",
			contentType: "text/plain",
			want:        true,
		},
		{
			name:        "Plain text type with charset",
			contentType: "text/plain; charset=utf-8",
			want:        true,
		},
		{
			name:        "Random type with text/plain prefix",
			contentType: "text/plainnnnn",
			want:        false,
		},
		{
			name:        "HTML type",
			contentType: "text/html",
			want:        true,
		},
		{
			name:        "Plain text type with charset",
			contentType: "text/html; charset=utf-8",
			want:        true,
		},
		{
			name:        "Random type with text/html prefix",
			contentType: "text/htmlllll",
			want:        false,
		},
		{
			name:        "Binary type",
			contentType: "application/octet-stream",
			want:        false,
		},
		{
			name:        "Unknown type",
			contentType: "unknown/unknown",
			want:        false,
		},
		{
			name:        "Invalid type",
			contentType: "text/",
			want:        false,
		},
		{
			name:        "Random string",
			contentType: "random123!@#",
			want:        false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := isPrintableContentType(tt.contentType); got != tt.want {
				t.Errorf("isPrintableContentType() = %v, want %v", got, tt.want)
			}
		})
	}
}

func Test_logResponseBody(t *testing.T) {
	tests := []struct {
		name     string
		resp     *http.Response
		want     string
		wantData []byte
	}{
		{
			name: "Nil body",
			resp: &http.Response{
				Body:   nil,
				Header: http.Header{"Content-Type": []string{"application/json"}},
			},
			want: "   No response body to print",
		},
		{
			name:     "No body",
			wantData: nil,
			resp: &http.Response{
				Body:          http.NoBody,
				ContentLength: 100, // in case of HEAD response, the content length is set but the body is empty
				Header:        http.Header{"Content-Type": []string{"application/json"}},
			},
			want: "   No response body to print",
		},
		{
			name:     "Empty body",
			wantData: []byte(""),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte(""))),
				ContentLength: 0,
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: "   Response body is empty",
		},
		{
			name:     "Unknown content length",
			wantData: []byte("whatever"),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte("whatever"))),
				ContentLength: -1,
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: "whatever",
		},
		{
			name:     "Missing content type header",
			wantData: []byte("whatever"),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte("whatever"))),
				ContentLength: 8,
			},
			want: "   Response body without a content type is not printed",
		},
		{
			name:     "Empty content type header",
			wantData: []byte("whatever"),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte("whatever"))),
				ContentLength: 8,
				Header:        http.Header{"Content-Type": []string{""}},
			},
			want: "   Response body without a content type is not printed",
		},
		{
			name:     "Non-printable content type",
			wantData: []byte("binary data"),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte("binary data"))),
				ContentLength: 11,
				Header:        http.Header{"Content-Type": []string{"application/octet-stream"}},
			},
			want: "   Response body of content type \"application/octet-stream\" is not printed",
		},
		{
			name:     "Body at the limit",
			wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit)),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit)))),
				ContentLength: payloadSizeLimit,
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))),
		},
		{
			name:     "Body larger than limit",
			wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)))), // 1 byte larger than limit
				ContentLength: payloadSizeLimit + 1,
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))) + "\n...(truncated)",
		},
		{
			name:     "Printable content type within limit",
			wantData: []byte("data"),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte("data"))),
				ContentLength: 4,
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: "data",
		},
		{
			name:     "Actual body size is larger than content length",
			wantData: []byte("data"),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte("data"))),
				ContentLength: 3, // mismatched content length
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: "data",
		},
		{
			name:     "Actual body size is larger than content length and exceeds limit",
			wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)))), // 1 byte larger than limit
				ContentLength: 1,                                                                                 // mismatched content length
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))) + "\n...(truncated)",
		},
		{
			name:     "Actual body size is smaller than content length",
			wantData: []byte("data"),
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte("data"))),
				ContentLength: 5, // mismatched content length
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: "data",
		},
		{
			name: "Body contains token",
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte(`{"token":"12345"}`))),
				ContentLength: 17,
				Header:        http.Header{"Content-Type": []string{"application/json"}},
			},
			wantData: []byte(`{"token":"12345"}`),
			want:     "   Response body redacted due to potential credentials",
		},
		{
			name: "Body contains access_token",
			resp: &http.Response{
				Body:          io.NopCloser(bytes.NewReader([]byte(`{"access_token":"12345"}`))),
				ContentLength: 17,
				Header:        http.Header{"Content-Type": []string{"application/json"}},
			},
			wantData: []byte(`{"access_token":"12345"}`),
			want:     "   Response body redacted due to potential credentials",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := logResponseBody(tt.resp); got != tt.want {
				t.Errorf("logResponseBody() = %v, want %v", got, tt.want)
			}
			// validate the response body
			if tt.resp.Body != nil {
				readBytes, err := io.ReadAll(tt.resp.Body)
				if err != nil {
					t.Errorf("failed to read body after logResponseBody(), err= %v", err)
				}
				if !bytes.Equal(readBytes, tt.wantData) {
					t.Errorf("resp.Body after logResponseBody() = %v, want %v", readBytes, tt.wantData)
				}
				if closeErr := tt.resp.Body.Close(); closeErr != nil {
					t.Errorf("failed to close body after logResponseBody(), err= %v", closeErr)
				}
			}
		})
	}
}

func Test_logResponseBody_error(t *testing.T) {
	tests := []struct {
		name string
		resp *http.Response
		want string
	}{
		{
			name: "Error reading body",
			resp: &http.Response{
				Body:          io.NopCloser(&errorReader{}),
				ContentLength: 10,
				Header:        http.Header{"Content-Type": []string{"text/plain"}},
			},
			want: "   Error reading response body: mock read error",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := logResponseBody(tt.resp); got != tt.want {
				t.Errorf("logResponseBody() = %v, want %v", got, tt.want)
			}
			if closeErr := tt.resp.Body.Close(); closeErr != nil {
				t.Errorf("failed to close body after logResponseBody(), err= %v", closeErr)
			}
		})
	}
}

func Test_containsCredentials(t *testing.T) {
	tests := []struct {
		name string
		body string
		want bool
	}{
		{
			name: "Contains token keyword",
			body: `{"token": "12345"}`,
			want: true,
		},
		{
			name: "Contains quoted token keyword",
			body: `whatever "token" blah`,
			want: true,
		},
		{
			name: "Contains unquoted token keyword",
			body: `whatever token blah`,
			want: false,
		},
		{
			name: "Contains access_token keyword",
			body: `{"access_token": "12345"}`,
			want: true,
		},
		{
			name: "Contains quoted access_token keyword",
			body: `whatever "access_token" blah`,
			want: true,
		},
		{
			name: "Contains unquoted access_token keyword",
			body: `whatever access_token blah`,
			want: false,
		},
		{
			name: "Does not contain credentials",
			body: `{"key": "value"}`,
			want: false,
		},
		{
			name: "Empty body",
			body: ``,
			want: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := containsCredentials(tt.body); got != tt.want {
				t.Errorf("containsCredentials() = %v, want %v", got, tt.want)
			}
		})
	}
}
