// Copyright 2026 Cisco Systems, Inc. and its affiliates
//
// 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
//
// SPDX-License-Identifier: Apache-2.0

package gateway

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/defenseclaw/defenseclaw/internal/config"
)

// TestHookAIDInspect_GateBehavior covers the four ways the AID lane
// silently no-ops: nil server, no inspector, ScanHookSurface=false,
// empty content. Each path returns nil and triggers no upstream call.
func TestHookAIDInspect_GateBehavior(t *testing.T) {
	t.Run("nil_inspector_returns_nil", func(t *testing.T) {
		a := &APIServer{scannerCfg: &config.Config{}}
		if v := a.hookAIDInspect("mcp__jira__createJiraIssue", "summary=test"); v != nil {
			t.Fatalf("expected nil when inspector unset, got %+v", v)
		}
	})

	t.Run("scan_hook_surface_false_returns_nil", func(t *testing.T) {
		falseFlag := false
		cfg := &config.Config{
			CiscoAIDefense: config.CiscoAIDefenseConfig{
				ScanHookSurface: &falseFlag,
			},
		}
		a := &APIServer{
			scannerCfg:     cfg,
			ciscoInspector: &CiscoInspectClient{apiKey: "any"}, // wired but disabled
		}
		if v := a.hookAIDInspect("mcp__jira__createJiraIssue", "x"); v != nil {
			t.Fatalf("expected nil when ScanHookSurface=false, got %+v", v)
		}
	})

	t.Run("empty_content_returns_nil", func(t *testing.T) {
		a := &APIServer{
			scannerCfg:     &config.Config{},
			ciscoInspector: &CiscoInspectClient{apiKey: "any"},
		}
		if v := a.hookAIDInspect("mcp__jira__createJiraIssue", ""); v != nil {
			t.Fatalf("expected nil for empty content, got %+v", v)
		}
	})
}

// TestHookAIDInspect_DefaultsToEnabledWhenKeyPresent ensures an
// operator who only sets api_key_env (no ScanHookSurface override)
// gets the lane enabled — the documented default.
func TestHookAIDInspect_DefaultsToEnabledWhenKeyPresent(t *testing.T) {
	cfg := &config.Config{
		CiscoAIDefense: config.CiscoAIDefenseConfig{
			APIKeyEnv: "DC_TEST_AID_KEY",
		},
	}
	if !cfg.CiscoAIDefense.HookSurfaceEnabled() {
		t.Fatal("HookSurfaceEnabled() should default to true when ScanHookSurface is unset")
	}
}

// TestHookAIDInspect_PrependsToolName verifies the tool name is woven
// into the AID payload so AID classifiers that match on tool-name
// strings (e.g. "Limit JIRA actions" / "createJiraIssue") see it.
func TestHookAIDInspect_PrependsToolName(t *testing.T) {
	var captured map[string]interface{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewDecoder(r.Body).Decode(&captured)
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"is_safe":true,"action":"Allow","rules":[],"processed_rules":[]}`))
	}))
	defer srv.Close()

	cisco := &CiscoInspectClient{
		apiKey:   "test-key",
		endpoint: srv.URL,
		client:   srv.Client(),
	}
	a := &APIServer{
		scannerCfg:     &config.Config{},
		ciscoInspector: cisco,
	}
	_ = a.hookAIDInspect("mcp__jira__createJiraIssue", `{"summary":"test"}`)

	msgs, _ := captured["messages"].([]interface{})
	if len(msgs) == 0 {
		t.Fatalf("expected at least one message in AID payload, got %+v", captured)
	}
	first, _ := msgs[0].(map[string]interface{})
	content, _ := first["content"].(string)
	if !strings.Contains(content, "mcp__jira__createJiraIssue") {
		t.Errorf("AID payload content should contain tool name; got %q", content)
	}
}

// TestMergeWithAIDVerdict_StrictestWins covers action escalation,
// severity escalation, and findings concatenation in the merge
// helper used by the hook-lane callers.
func TestMergeWithAIDVerdict_StrictestWins(t *testing.T) {
	t.Run("aid_block_escalates_local_alert", func(t *testing.T) {
		local := &ToolInspectVerdict{Action: "alert", Severity: "MEDIUM"}
		aid := &ScanVerdict{Action: "block", Severity: "HIGH", Findings: []string{"jira-policy"}, Reason: "AID rule fired"}
		merged := mergeWithAIDVerdict(local, aid)
		if merged.Action != "block" {
			t.Errorf("expected block, got %q", merged.Action)
		}
		if merged.Severity != "HIGH" {
			t.Errorf("expected HIGH, got %q", merged.Severity)
		}
		if len(merged.Findings) == 0 || !strings.HasPrefix(merged.Findings[0], "ai-defense:") {
			t.Errorf("expected ai-defense-prefixed finding, got %v", merged.Findings)
		}
	})

	t.Run("aid_allow_does_not_downgrade_local_block", func(t *testing.T) {
		local := &ToolInspectVerdict{Action: "block", Severity: "CRITICAL"}
		aid := &ScanVerdict{Action: "allow", Severity: "NONE"}
		merged := mergeWithAIDVerdict(local, aid)
		if merged.Action != "block" {
			t.Errorf("local block must not be downgraded by AID allow; got %q", merged.Action)
		}
		if merged.Severity != "CRITICAL" {
			t.Errorf("local CRITICAL must not be downgraded; got %q", merged.Severity)
		}
	})

	t.Run("nil_aid_returns_local_verbatim", func(t *testing.T) {
		local := &ToolInspectVerdict{Action: "alert", Severity: "MEDIUM"}
		if got := mergeWithAIDVerdict(local, nil); got != local {
			t.Errorf("nil AID should return local verbatim")
		}
	})

	t.Run("nil_local_with_aid_block_creates_block_verdict", func(t *testing.T) {
		aid := &ScanVerdict{Action: "block", Severity: "HIGH", Findings: []string{"r1"}, Reason: "AID"}
		merged := mergeWithAIDVerdict(nil, aid)
		if merged.Action != "block" {
			t.Errorf("expected block, got %q", merged.Action)
		}
	})
}

// TestInspectToolPolicy_AIDLaneFiresWhenLocalAllow drives the full
// inspectToolPolicy path end-to-end with no local-rule match,
// asserting that an AID Block escalates the verdict from allow to
// block. The complementary cases (no-op when AID disabled, AID call
// shape) are covered by the unit tests above.
func TestInspectToolPolicy_AIDLaneFiresWhenLocalAllow(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"is_safe": false,
			"action": "Block",
			"rules": [{"rule_name":"Limit JIRA actions","classification":"VIOLATION"}],
			"processed_rules": [{"rule_name":"Limit JIRA actions"}]
		}`))
	}))
	defer srv.Close()

	a := &APIServer{
		scannerCfg: &config.Config{},
		ciscoInspector: &CiscoInspectClient{
			apiKey:   "test-key",
			endpoint: srv.URL,
			client:   srv.Client(),
		},
	}
	v := a.inspectToolPolicy(&ToolInspectRequest{
		Tool:      "mcp__jira__createJiraIssue",
		Args:      json.RawMessage(`{"project":"ENG","summary":"test"}`),
		Direction: "tool_call",
	})
	if v == nil {
		t.Fatal("expected non-nil verdict")
	}
	if v.Action != "block" {
		t.Errorf("expected AID Block to escalate verdict to block, got %q", v.Action)
	}
	hasAIDFinding := false
	for _, f := range v.Findings {
		if strings.HasPrefix(f, "ai-defense:") {
			hasAIDFinding = true
			break
		}
	}
	if !hasAIDFinding {
		t.Errorf("expected ai-defense-prefixed finding, got %v", v.Findings)
	}
}

// TestInspectToolPolicy_AIDLaneOffByDefaultWhenInspectorMissing
// proves the gate: an APIServer with no CiscoInspector wired returns
// the original verdict unchanged. This is the "no AID configured"
// path operators land on out of the box.
func TestInspectToolPolicy_AIDLaneOffByDefaultWhenInspectorMissing(t *testing.T) {
	a := &APIServer{
		scannerCfg:     &config.Config{},
		ciscoInspector: nil, // no AID configured
	}
	v := a.inspectToolPolicy(&ToolInspectRequest{
		Tool:      "ls",
		Args:      json.RawMessage(`{"path":"/tmp"}`),
		Direction: "tool_call",
	})
	if v == nil {
		t.Fatal("expected non-nil verdict")
	}
	if v.Action != "allow" {
		t.Errorf("expected allow without AID, got %q", v.Action)
	}
}
