// 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
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0

package audit

import (
	"context"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/defenseclaw/defenseclaw/internal/audit/sinks"
	"github.com/defenseclaw/defenseclaw/internal/scanner"
)

// captureSink is an in-memory sinks.Sink that records every event
// the Logger forwards, used by the audit-fanout tests to assert that
// events reach the sink fan-out path with the expected fields.
//
// The previous Splunk-specific tests asserted the same invariants
// against the old SplunkForwarder; this generic capture sink replaces
// them and works against any future sink implementation by virtue of
// living one layer above the wire format.
type captureSink struct {
	mu              sync.Mutex
	events          []sinks.Event
	immediateFlushC chan struct{}
}

func newCaptureSink() *captureSink {
	return &captureSink{immediateFlushC: make(chan struct{}, 16)}
}

func (c *captureSink) Name() string { return "capture" }
func (c *captureSink) Kind() string { return "capture" }
func (c *captureSink) Forward(_ context.Context, e sinks.Event) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.events = append(c.events, e)
	return nil
}
func (c *captureSink) Flush(_ context.Context) error {
	select {
	case c.immediateFlushC <- struct{}{}:
	default:
	}
	return nil
}
func (c *captureSink) Close() error { return nil }

func (c *captureSink) snapshot() []sinks.Event {
	c.mu.Lock()
	defer c.mu.Unlock()
	out := make([]sinks.Event, len(c.events))
	copy(out, c.events)
	return out
}

// installCaptureSink wires a captureSink into the Logger via a
// sinks.Manager. Callers receive the underlying sink for assertions.
func installCaptureSink(t *testing.T, l *Logger) *captureSink {
	t.Helper()
	mgr := sinks.NewManager()
	cs := newCaptureSink()
	mgr.Register(cs)
	l.SetSinks(mgr)
	return cs
}

func TestInferTargetType(t *testing.T) {
	tests := []struct {
		scanner string
		want    string
	}{
		{"skill-scanner", "skill"},
		{"skill_scanner", "skill"},
		{"mcp-scanner", "mcp"},
		{"mcp_scanner", "mcp"},
		{"codeguard", "code"},
		{"aibom", "code"},
		{"aibom-claw", "code"},
		{"clawshield-vuln", "code"},
		{"clawshield-secrets", "code"},
		{"clawshield-pii", "code"},
		{"clawshield-malware", "code"},
		{"clawshield-injection", "code"},
		{"future-scanner", "unknown"},
		{"", "unknown"},
	}
	for _, tt := range tests {
		t.Run(tt.scanner, func(t *testing.T) {
			if got := inferTargetType(tt.scanner); got != tt.want {
				t.Errorf("inferTargetType(%q) = %q, want %q", tt.scanner, got, tt.want)
			}
		})
	}
}

func TestInferAssetTypeFromAction(t *testing.T) {
	tests := []struct {
		name    string
		action  string
		details string
		want    string
	}{
		{"mcp action", "mcp-block", "", "mcp"},
		{"mcp in details", "block", "type=mcp reason=test", "mcp"},
		{"skill action", "skill-install", "", "skill"},
		{"skill in details", "install-clean", "type=skill scanner=x", "skill"},
		{"default to skill", "block", "reason=test", "skill"},
		{"watcher-block skill", "watcher-block", "type=skill reason=x", "skill"},
		{"watcher-block mcp", "watcher-block", "type=mcp reason=x", "mcp"},
		{"empty action", "", "", "skill"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := inferAssetTypeFromAction(tt.action, tt.details); got != tt.want {
				t.Errorf("inferAssetTypeFromAction(%q, %q) = %q, want %q",
					tt.action, tt.details, got, tt.want)
			}
		})
	}
}

func TestContains(t *testing.T) {
	tests := []struct {
		s, substr string
		want      bool
	}{
		{"hello world", "world", true},
		{"hello", "hello", true},
		{"hello", "xyz", false},
		{"", "", true},
		{"hello", "", true},
		{"", "x", false},
		{"type=skill scanner=x", "type=skill", true},
		{"type=mcp", "type=skill", false},
	}
	for _, tt := range tests {
		t.Run(tt.s+"_"+tt.substr, func(t *testing.T) {
			if got := contains(tt.s, tt.substr); got != tt.want {
				t.Errorf("contains(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want)
			}
		})
	}
}

func TestLoggerLogActionIncludesRunID(t *testing.T) {
	t.Setenv("DEFENSECLAW_RUN_ID", "logger-run-id")

	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	if err := logger.LogAction("skill-block", "test-skill", "reason=test"); err != nil {
		t.Fatalf("LogAction: %v", err)
	}

	events, err := store.ListEvents(10)
	if err != nil {
		t.Fatalf("ListEvents: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("expected 1 event, got %d", len(events))
	}
	if got := events[0].RunID; got != "logger-run-id" {
		t.Fatalf("RunID = %q, want %q", got, "logger-run-id")
	}
}

func TestLoggerSinkForwardingIncludesDefaultedFields(t *testing.T) {
	t.Setenv("DEFENSECLAW_RUN_ID", "logger-sink-run-id")

	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)
	if err := logger.LogAction("skill-block", "test-skill", "reason=test"); err != nil {
		t.Fatalf("LogAction: %v", err)
	}
	logger.Close()

	got := cs.snapshot()
	if len(got) != 1 {
		t.Fatalf("expected 1 forwarded event, got %d", len(got))
	}

	evt := got[0]
	if evt.ID == "" {
		t.Fatal("forwarded event id was empty")
	}
	if evt.Actor != "defenseclaw" {
		t.Fatalf("forwarded actor = %q, want %q", evt.Actor, "defenseclaw")
	}
	if evt.RunID != "logger-sink-run-id" {
		t.Fatalf("forwarded run_id = %q, want %q", evt.RunID, "logger-sink-run-id")
	}
	if evt.Action != "skill-block" || evt.Target != "test-skill" {
		t.Fatalf("forwarded event mismatch: %+v", evt)
	}
}

// TestLoggerForwardsWhenStoreWriteFails locks in the v7 contract that
// non-critical audit actions (lifecycle signals like sidecar-connected,
// gateway-ready, watch-start) are still fanned out to sinks and the
// structured emitter even when the local SQLite audit write fails.
// Dropping the event entirely on a DB error was the root cause of the
// "sidecar-connected missing from Splunk" incident — #127 — where the
// sidecar successfully connected to the gateway but the transition
// never reached Splunk because one of the concurrent SQLite writers
// briefly held the write lock.
func TestLoggerForwardsWhenStoreWriteFails(t *testing.T) {
	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}
	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)
	// Force every subsequent LogEvent to return an error.
	if err := store.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}

	err = logger.LogAction("sidecar-connected", "", "protocol=3")
	if err == nil {
		t.Fatal("expected LogAction to surface the store error, got nil")
	}

	got := cs.snapshot()
	if len(got) != 1 {
		t.Fatalf("expected sink fan-out on db failure, got %d events", len(got))
	}
	if got[0].Action != "sidecar-connected" {
		t.Fatalf("forwarded action = %q, want sidecar-connected", got[0].Action)
	}
}

// TestLoggerDoesNotForwardCriticalActionsOnStoreFailure asserts the
// other side of the v7 contract: block/allow/quarantine decisions must
// NOT be fanned out when the SQLite row fails to persist, because the
// local DB is the canonical source of truth for admission policy.
// Forwarding those signals to Splunk without the on-disk twin would
// make external auditors diverge from the runtime admission gate.
func TestLoggerDoesNotForwardCriticalActionsOnStoreFailure(t *testing.T) {
	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}
	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)
	if err := store.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}

	if err := logger.LogAction("block", "some-skill", "reason=test"); err == nil {
		t.Fatal("expected LogAction(block) to surface the store error, got nil")
	}

	if got := cs.snapshot(); len(got) != 0 {
		t.Fatalf("block must NOT fan out when the audit row failed to persist; got %d events", len(got))
	}
}

func TestLoggerSinkFlushesWatchStartImmediately(t *testing.T) {
	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)

	if err := logger.LogAction("watch-start", "", "dirs=3 debounce=500ms"); err != nil {
		t.Fatalf("LogAction: %v", err)
	}

	deadline := time.Now().Add(2 * time.Second)
	for len(cs.snapshot()) == 0 && !time.Now().After(deadline) {
		time.Sleep(10 * time.Millisecond)
	}

	if len(cs.snapshot()) == 0 {
		t.Fatal("expected watch-start to be forwarded to the sink promptly")
	}
}

func TestLoggerLogEventPreservesSeverity(t *testing.T) {
	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	evt := Event{
		Action:   "drift",
		Target:   "/path/to/skill",
		Actor:    "defenseclaw-rescan",
		Details:  "hash changed",
		Severity: "HIGH",
	}
	if err := logger.LogEvent(evt); err != nil {
		t.Fatalf("LogEvent: %v", err)
	}

	events, err := store.ListEvents(10)
	if err != nil {
		t.Fatalf("ListEvents: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("expected 1 event, got %d", len(events))
	}
	if got := events[0].Severity; got != "HIGH" {
		t.Fatalf("Severity = %q, want HIGH", got)
	}
	if events[0].ID == "" {
		t.Fatal("expected ID to be auto-filled")
	}
}

func TestLoggerLogEventSinkForwarding(t *testing.T) {
	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)

	evt := Event{
		Action:   "drift",
		Target:   "/path/to/skill",
		Actor:    "defenseclaw-rescan",
		Details:  "new finding",
		Severity: "CRITICAL",
	}
	if err := logger.LogEvent(evt); err != nil {
		t.Fatalf("LogEvent: %v", err)
	}
	logger.Close()

	got := cs.snapshot()
	if len(got) == 0 {
		t.Fatal("expected drift event to be forwarded to the sink")
	}
	if got[0].Action != "drift" {
		t.Fatalf("action = %q, want drift", got[0].Action)
	}
	if got[0].Severity != "CRITICAL" {
		t.Fatalf("severity = %q, want CRITICAL", got[0].Severity)
	}
}

// TestLoggerRedactsPIIBeforeSink asserts the redaction invariant for
// the audit fan-out path: free-form Details strings with phone
// numbers, emails, or SSNs never reach the sink channel or SQLite in
// plaintext, regardless of which Log* entrypoint the caller used.
//
// Any of these leaking to a persistent sink is an incident, so the
// test brackets every Log* method rather than trusting transitive
// coverage.
func TestLoggerRedactsPIIBeforeSink(t *testing.T) {
	t.Setenv("DEFENSECLAW_REVEAL_PII", "")

	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)

	cases := []struct {
		name   string
		record func() error
		pii    []string
	}{
		{
			name: "LogAction phone in details",
			record: func() error {
				return logger.LogAction("tool-call", "sms-tool",
					"args.recipient=4155551234")
			},
			pii: []string{"4155551234"},
		},
		{
			name: "LogActionWithTrace email in details",
			record: func() error {
				return logger.LogActionWithTrace("skill-install", "contacts",
					"user=alice@example.com", "trace-xyz")
			},
			pii: []string{"alice@example.com"},
		},
		{
			name: "LogEvent SSN in details",
			record: func() error {
				return logger.LogEvent(Event{
					Action:   "tool-call",
					Target:   "ssn-lookup",
					Details:  "args.ssn=123-45-6789",
					Severity: "HIGH",
				})
			},
			pii: []string{"123-45-6789"},
		},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			before := len(cs.snapshot())
			if err := tc.record(); err != nil {
				t.Fatalf("record: %v", err)
			}
			snap := cs.snapshot()
			if len(snap) <= before {
				t.Fatalf("expected new sink event; got %d total", len(snap))
			}
			sinkEvt := snap[len(snap)-1]
			for _, needle := range tc.pii {
				if strings.Contains(sinkEvt.Details, needle) {
					t.Fatalf("sink leaked PII %q in Details=%q",
						needle, sinkEvt.Details)
				}
			}

			// SQLite leg: ListEvents pulls the row we just inserted;
			// its Details must also be redacted.
			events, err := store.ListEvents(32)
			if err != nil {
				t.Fatalf("ListEvents: %v", err)
			}
			// ListEvents returns newest first.
			for _, needle := range tc.pii {
				if strings.Contains(events[0].Details, needle) {
					t.Fatalf("SQLite leaked PII %q in Details=%q",
						needle, events[0].Details)
				}
			}
		})
	}
}

// TestLoggerSinkBypassesRevealFlag confirms that persistent sinks
// remain fully redacted even when the operator has set
// DEFENSECLAW_REVEAL_PII=1 on the host for triage. The reveal flag
// is strictly scoped to stderr/TUI; the audit store, audit sinks,
// and OTel exporters must never unmask.
func TestLoggerSinkBypassesRevealFlag(t *testing.T) {
	t.Setenv("DEFENSECLAW_REVEAL_PII", "1")

	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)

	if err := logger.LogAction("tool-call", "sms-tool",
		"args.recipient=4155551234"); err != nil {
		t.Fatalf("LogAction: %v", err)
	}

	snap := cs.snapshot()
	if len(snap) == 0 {
		t.Fatal("no sink event")
	}
	if strings.Contains(snap[0].Details, "4155551234") {
		t.Fatalf("sink unmasked under reveal flag: %q", snap[0].Details)
	}

	events, _ := store.ListEvents(10)
	if len(events) > 0 && strings.Contains(events[0].Details, "4155551234") {
		t.Fatalf("SQLite unmasked under reveal flag: %q", events[0].Details)
	}
}

// TestLoggerRedactsFindingFieldsBeforeSQLite covers the scan-result
// path: a Finding whose Description/Location/Remediation contain PII
// must reach SQLite only as "<redacted ...>" placeholders. The
// finding title is authored from static rule metadata so it stays
// verbatim.
func TestLoggerRedactsFindingFieldsBeforeSQLite(t *testing.T) {
	t.Setenv("DEFENSECLAW_REVEAL_PII", "")

	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	result := &scanner.ScanResult{
		Scanner:   "clawshield-pii",
		Target:    "test-skill",
		Timestamp: time.Now(),
		Duration:  time.Millisecond,
		Findings: []scanner.Finding{
			{
				Severity:    scanner.SeverityHigh,
				Title:       "PII detected",
				Description: "detected SSN 123-45-6789 in payload",
				Location:    "/home/alice@example.com/skill.py:42",
				Remediation: "contact 4155551234 before removing",
				Scanner:     "clawshield-pii",
			},
		},
	}
	if err := logger.LogScan(result); err != nil {
		t.Fatalf("LogScan: %v", err)
	}

	scans, err := store.ListScanResults(10)
	if err != nil {
		t.Fatalf("ListScanResults: %v", err)
	}
	if len(scans) == 0 {
		t.Fatal("expected a scan row")
	}
	findings, err := store.ListScanFindings(scans[0].ID)
	if err != nil {
		t.Fatalf("ListScanFindings: %v", err)
	}
	if len(findings) == 0 {
		t.Fatal("expected a finding row")
	}
	f := findings[0]
	desc := f.Description.String
	loc := f.Location.String
	rem := f.Remediation.String
	title := ""
	if f.Title.Valid {
		title = f.Title.String
	}
	for _, needle := range []string{"123-45-6789", "4155551234", "alice@example.com"} {
		if strings.Contains(desc, needle) ||
			strings.Contains(loc, needle) ||
			strings.Contains(rem, needle) {
			t.Fatalf("SQLite finding leaked %q: desc=%q loc=%q rem=%q",
				needle, desc, loc, rem)
		}
	}
	if title != "PII detected" {
		t.Fatalf("Title should be preserved verbatim; got %q", title)
	}
}

// TestLoggerLogActionWithCorrelation_PersistsRequestID asserts that
// a request_id supplied by the guardrail request path flows all the
// way to SQLite and to the sinks.Manager fan-out — the end-to-end
// contract for Phase 5.
func TestLoggerLogActionWithCorrelation_PersistsRequestID(t *testing.T) {
	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)

	const (
		traceID = "00-1234567890abcdef1234567890abcdef-0102030405060708-01"
		reqID   = "req-phase5-abcdef"
	)
	if err := logger.LogActionWithCorrelation("guardrail-verdict", "gpt-5",
		"direction=prompt action=block severity=HIGH", traceID, reqID); err != nil {
		t.Fatalf("LogActionWithCorrelation: %v", err)
	}
	logger.Close()

	events, err := store.ListEvents(10)
	if err != nil {
		t.Fatalf("ListEvents: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("expected 1 event, got %d", len(events))
	}
	if events[0].TraceID != traceID {
		t.Fatalf("SQLite trace_id = %q, want %q", events[0].TraceID, traceID)
	}
	if events[0].RequestID != reqID {
		t.Fatalf("SQLite request_id = %q, want %q", events[0].RequestID, reqID)
	}

	snap := cs.snapshot()
	if len(snap) != 1 {
		t.Fatalf("expected 1 forwarded event, got %d", len(snap))
	}
	if snap[0].RequestID != reqID {
		t.Fatalf("sink request_id = %q, want %q", snap[0].RequestID, reqID)
	}
	if snap[0].TraceID != traceID {
		t.Fatalf("sink trace_id = %q, want %q", snap[0].TraceID, traceID)
	}
}

// TestLoggerLogActionWithTrace_EmptyRequestIDIsLegal asserts that the
// legacy LogActionWithTrace path still works when the request_id is
// not known (e.g., the file-watcher subsystem has no HTTP
// correlation context). The row must land in SQLite with an empty
// request_id and the sink fan-out must not panic.
func TestLoggerLogActionWithTrace_EmptyRequestIDIsLegal(t *testing.T) {
	store, err := NewStore(filepath.Join(t.TempDir(), "audit.db"))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	defer store.Close()
	if err := store.Init(); err != nil {
		t.Fatalf("Init: %v", err)
	}

	logger := NewLogger(store)
	cs := installCaptureSink(t, logger)

	if err := logger.LogActionWithTrace("watch-start", "", "dirs=3", ""); err != nil {
		t.Fatalf("LogActionWithTrace: %v", err)
	}
	logger.Close()

	events, err := store.ListEvents(10)
	if err != nil {
		t.Fatalf("ListEvents: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("expected 1 event, got %d", len(events))
	}
	if events[0].RequestID != "" {
		t.Fatalf("empty request_id should persist as empty; got %q", events[0].RequestID)
	}
	if snap := cs.snapshot(); len(snap) != 1 || snap[0].RequestID != "" {
		t.Fatalf("sink should have empty request_id; got %+v", snap)
	}
}
