package harness

import (
	"regexp"
	"strings"
	"unicode/utf8"
)

// secretPatterns 是输出里疑似密钥/令牌的特征（命中即脱敏，防模型把密钥回吐给用户）。
var secretPatterns = []*regexp.Regexp{
	regexp.MustCompile(`sk-[A-Za-z0-9_-]{16,}`),                                      // OpenAI/DeepSeek 风格 key
	regexp.MustCompile(`AKIA[0-9A-Z]{16}`),                                           // AWS Access Key ID
	regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}`), // JWT
	regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._-]{16,}`),                           // Bearer 令牌
}

// piiPatterns 是个人隐私信息（PII）特征：手机号 / 邮箱 / 身份证。用 \b 边界避免吞进更长数字串。
var piiPatterns = []*regexp.Regexp{
	regexp.MustCompile(`\b1[3-9]\d{9}\b`),                                // 中国大陆手机号（11 位）
	regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`), // 邮箱
	regexp.MustCompile(`\b\d{17}[\dXx]\b`),                               // 身份证（18 位，末位可 X）
}

// openerPatterns 匹配「位于字符串末尾、可能仍在增长」的敏感串前缀（锚定 $）。
// 流式逐片脱敏会漏掉被分片切断的密钥（如 "sk-912cf85b"|"16d0..."），逐片都不单独命中。
// StreamRedactor 据此把"半截疑似密钥"的起点之后整段暂留，与下一分片拼接后再判，消除跨片漏检。
var openerPatterns = []*regexp.Regexp{
	regexp.MustCompile(`sk-[A-Za-z0-9_-]*$`),
	regexp.MustCompile(`AKIA[0-9A-Z]*$`),
	regexp.MustCompile(`eyJ[A-Za-z0-9_-]*(\.[A-Za-z0-9_-]*){0,2}$`),
	regexp.MustCompile(`(?i)bearer\s*[A-Za-z0-9._-]*$`),
	regexp.MustCompile(`1[3-9]\d*$`),                        // 手机号（增长中）
	regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]*$`), // 邮箱 @ 之后增长中
	regexp.MustCompile(`\d{6,}$`),                           // 长数字串（身份证等）增长中
}

// allPatterns 是全部完整匹配特征（密钥 + PII），供流式切点判定"勿切断完整匹配"。
var allPatterns = append(append([]*regexp.Regexp{}, secretPatterns...), piiPatterns...)

const redactMark = "[已脱敏]"

// RedactSecrets 把文本里疑似密钥/令牌/PII 替换为脱敏标记，返回脱敏后文本与命中次数。
// 整段脱敏（非流式场景、StreamRedactor 内部、最终兜底都用它）。
func RedactSecrets(s string) (string, int) {
	n := 0
	repl := func(string) string { n++; return redactMark }
	for _, re := range secretPatterns {
		s = re.ReplaceAllStringFunc(s, repl)
	}
	for _, re := range piiPatterns {
		s = re.ReplaceAllStringFunc(s, repl)
	}
	return s, n
}

const (
	// redactMinTail 始终暂留的尾窗：兜住 opener 未识别、却被跨片切断的短模式。
	redactMinTail = 16
	// redactMaxHold 暂留上限：超过即强制发射（脱敏后），防对抗性长串导致无限暂留 / O(n²)。
	redactMaxHold = 256
)

// StreamRedactor 是有状态的流式脱敏器：跨分片缓冲，解决密钥被切断而漏检。
// 用法：每分片 Push() 得可安全发射的文本，流结束 Flush() 吐出暂留尾部。非并发安全（单流单实例）。
type StreamRedactor struct {
	carry strings.Builder
	hits  int
}

// NewStreamRedactor 建一个流式脱敏器（每个 token 流一个实例）。
func NewStreamRedactor() *StreamRedactor { return &StreamRedactor{} }

// Push 吃一个分片，返回当前可安全发射的脱敏文本（可能为空——尾部疑似半截敏感串被暂留待下片）。
// 关键：切点在「原文」缓冲上定，只对已结算的头部脱敏、尾部按原文暂留 —— 否则贪婪正则会在
// 缓冲末尾凑够最短长度就把半截密钥提前脱敏发走，剩余字符随后明文泄漏。
func (r *StreamRedactor) Push(chunk string) string {
	if chunk == "" {
		return ""
	}
	r.carry.WriteString(chunk)
	buf := r.carry.String()
	cut := safeCut(buf)
	head, n := RedactSecrets(buf[:cut])
	r.hits += n
	r.carry.Reset()
	r.carry.WriteString(buf[cut:]) // 原文暂留（未脱敏，供下片继续判定）
	return head
}

// Flush 流结束时吐出暂留尾部（做最终脱敏）。返回脱敏后的剩余文本。
func (r *StreamRedactor) Flush() string {
	s, n := RedactSecrets(r.carry.String())
	r.hits += n
	r.carry.Reset()
	return s
}

// Hits 返回累计脱敏命中次数。
func (r *StreamRedactor) Hits() int { return r.hits }

// safeCut 在「原文」缓冲上计算可安全发射的截断点：暂留 [cut:]，发射并脱敏 [:cut]。
// 三层保障：① opener 暂留末尾仍在增长的疑似密钥；② 始终留一段尾窗兜底未被 opener 覆盖的短模式；
// ③ 切点绝不切断任何「完整匹配」（否则半截匹配的头部会明文流出）。最后以暂留上限封顶防 DoS。
func safeCut(s string) int {
	cut := len(s)
	for _, re := range openerPatterns {
		if loc := re.FindStringIndex(s); loc != nil && loc[0] < cut {
			cut = loc[0]
		}
	}
	if minCut := len(s) - redactMinTail; minCut < cut {
		cut = minCut // 始终暂留尾窗
	}
	// 勿切断完整匹配：把任何跨越 cut 的完整匹配整体并入暂留尾（循环至稳定，匹配数有限）。
	for {
		moved := false
		for _, re := range allPatterns {
			for _, loc := range re.FindAllStringIndex(s, -1) {
				if loc[0] < cut && loc[1] > cut {
					cut = loc[0]
					moved = true
				}
			}
		}
		if !moved {
			break
		}
	}
	if maxCut := len(s) - redactMaxHold; maxCut > cut {
		cut = maxCut // 暂留封顶（最终硬限，防对抗性长串无限暂留 / O(n²)）
	}
	if cut < 0 {
		cut = 0
	}
	// 退到最近的 rune 边界：cut 来自字节索引，直接切多字节字符（中文）会发出半个 rune 乱码。
	for cut > 0 && cut < len(s) && !utf8.RuneStart(s[cut]) {
		cut--
	}
	return cut
}
