package sub

import (
	"fmt"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
	"github.com/mhsanaei/3x-ui/v3/internal/xray"
)

// remarkContext carries the per-client data a remark template can interpolate.
// stats holds the live traffic record when one exists; when it doesn't, the
// caller synthesizes a minimal one from the client so expiry/total/status tokens
// still resolve. hostRemark is the host endpoint's own remark; it backs the
// {{HOST}} token only — it never substitutes the inbound's remark as the config
// name (use {{INBOUND}} and {{HOST}} side by side to show both).
type remarkContext struct {
	client     model.Client
	stats      xray.ClientTraffic
	inbound    *model.Inbound
	hostRemark string
	transport  string
	security   string
}

// configName is the display name for a link: always the inbound's own remark.
// The host endpoint's remark is surfaced only through the {{HOST}} token.
func (ctx remarkContext) configName() string {
	if ctx.inbound != nil {
		return ctx.inbound.Remark
	}
	return ""
}

// remarkVarRe matches a {{TOKEN}} placeholder. Tokens are uppercase letters and
// underscores only, so ordinary braces in a remark are left untouched.
var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)

// remarkToken is one {{TOKEN}} occurrence: its name and the byte range it spans
// in the segment it was found in.
type remarkToken struct {
	name  string
	start int
	end   int
}

// remarkTokens locates every {{TOKEN}} in seg. Both the template-level filter and
// the value-level expansion walk a segment through this, so they share one notion
// of where a token begins and ends and what the literal text between two of them is.
func remarkTokens(seg string) []remarkToken {
	locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
	tokens := make([]remarkToken, len(locs))
	for i, loc := range locs {
		tokens[i] = remarkToken{name: seg[loc[2]:loc[3]], start: loc[0], end: loc[1]}
	}
	return tokens
}

// unlimitedMark is the value the human-readable quota/expiry tokens render when
// the client has no limit. A segment built only around such a token carries no
// information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
const unlimitedMark = "∞"

// unlimitedDropTokens are the tokens that render unlimitedMark for an unlimited
// client. A "|"-separated segment whose only value comes from one of these is
// dropped whole when unlimited, so the operator never sees "📊∞|⏳∞D".
var unlimitedDropTokens = map[string]bool{
	"TRAFFIC_LEFT":  true,
	"TRAFFIC_TOTAL": true,
	"DAYS_LEFT":     true,
	"TIME_LEFT":     true,
}

// uiTokenMap translates user-friendly single-brace tokens (used in the frontend
// Remark/Host Name fields) to their internal double-brace equivalents. Tokens
// not present in this map are left untouched.
var uiTokenMap = map[string]string{
	"EMAIL":              "EMAIL",
	"DATA_USAGE":         "TRAFFIC_USED",
	"DATA_LEFT":          "TRAFFIC_LEFT",
	"DATA_LIMIT":         "TRAFFIC_TOTAL",
	"DAYS_LEFT":          "DAYS_LEFT",
	"EXPIRE_DATE":        "EXPIRE_DATE",
	"JALALI_EXPIRE_DATE": "JALALI_EXPIRE_DATE",
	"TIME_LEFT":          "TIME_LEFT",
	"STATUS_EMOJI":       "STATUS_EMOJI",
	"USAGE_PERCENTAGE":   "USAGE_PERCENTAGE",
	"PROTOCOL":           "PROTOCOL",
	"TRANSPORT":          "TRANSPORT",
	"SECURITY":           "SECURITY",
}

// translateUISingleBrackets converts user-friendly single-brace tokens to the
// internal double-brace format before regex expansion. Only {TOKEN} patterns
// that are NOT part of {{TOKEN}} are translated. Unknown tokens stay as-is.
func translateUISingleBrackets(template string) string {
	var result strings.Builder
	i := 0
	for i < len(template) {
		if template[i] == '{' && (i == 0 || template[i-1] != '{') {
			j := i + 1
			for j < len(template) && template[j] != '}' {
				j++
			}
			if j < len(template) && template[j] == '}' {
				token := template[i+1 : j]
				if internal, ok := uiTokenMap[token]; ok {
					result.WriteString("{{")
					result.WriteString(internal)
					result.WriteString("}}")
					i = j + 1
					continue
				}
			}
		}
		result.WriteByte(template[i])
		i++
	}
	return result.String()
}

// expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
// value. Unknown tokens resolve to "" (never the literal text). The template is
// split on "|" into segments: a segment whose only value is an unlimited quota
// or expiry (∞) drops out whole — decoration and separator included — so an
// unlimited client gets "host" instead of "host|📊∞|⏳∞D". Inside a surviving
// segment expandSegment also elides a hyphen separator an empty token would
// leave dangling.
func expandRemarkVars(template string, ctx remarkContext) string {
	template = translateUISingleBrackets(template)
	if !strings.Contains(template, "{{") {
		return template
	}
	segments := strings.Split(template, "|")
	kept := make([]string, 0, len(segments))
	for _, seg := range segments {
		if out, drop := expandSegment(seg, ctx); !drop {
			kept = append(kept, out)
		}
	}
	return strings.Join(kept, "|")
}

// expandSegment expands one "|" segment and reports whether it should be dropped.
// A segment that contains tokens is dropped when none of them resolve to a real
// value — whether because they render the unlimited (∞) mark or the empty string
// — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
// say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
// tokens) is always kept.
//
// A hyphen standing alone between two adjacent tokens is treated as their
// separator and elided when no token before it has produced a value yet or when
// the token after it resolves to nothing. "{{INBOUND}}-{{EMAIL}}" gives "john"
// for an inbound with no remark, "🌐{{INBOUND}}-{{EMAIL}}" gives "🌐john" so
// leading decoration does not keep the separator alive, and
// "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}" keeps a single separator when the middle
// token is empty. A hyphen anywhere else in the segment is literal text and is
// kept as written.
func expandSegment(seg string, ctx remarkContext) (string, bool) {
	tokens := remarkTokens(seg)
	hasToken, hasOtherValue := len(tokens) > 0, false
	values := make([]string, len(tokens))
	for i, tok := range tokens {
		val := remarkVarValue(tok.name, ctx)
		values[i] = val
		if val != "" && (!unlimitedDropTokens[tok.name] || val != unlimitedMark) {
			hasOtherValue = true
		}
	}

	var result strings.Builder
	start, wroteValue := 0, false
	for i, tok := range tokens {
		result.WriteString(seg[start:tok.start])
		result.WriteString(values[i])
		wroteValue = wroteValue || values[i] != ""
		start = tok.end
		if i+1 < len(tokens) {
			between := seg[start:tokens[i+1].start]
			if strings.TrimSpace(between) == "-" && (!wroteValue || values[i+1] == "") {
				start = tokens[i+1].start
			}
		}
	}
	result.WriteString(seg[start:])
	return result.String(), hasToken && !hasOtherValue
}

func remarkVarValue(token string, ctx remarkContext) string {
	c := ctx.client
	st := ctx.stats
	used := st.Up + st.Down
	switch token {
	case "EMAIL", "USERNAME":
		return c.Email
	case "INBOUND":
		return ctx.configName()
	case "HOST":
		return ctx.hostRemark
	case "ID":
		return c.ID
	case "SHORT_ID":
		if len(c.ID) >= 8 {
			return c.ID[:8]
		}
		return c.ID
	case "TELEGRAM_ID":
		if c.TgID != 0 {
			return strconv.FormatInt(c.TgID, 10)
		}
		return ""
	case "SUB_ID":
		return c.SubID
	case "COMMENT":
		return c.Comment
	case "STATUS":
		return clientStatus(st)
	case "DAYS_LEFT":
		return daysLeftLabel(st.ExpiryTime)
	case "EXPIRE_DATE":
		return expireDateLabel(st.ExpiryTime)
	case "EXPIRE_UNIX":
		if st.ExpiryTime <= 0 {
			return "0"
		}
		return strconv.FormatInt(st.ExpiryTime/1000, 10)
	case "CREATED_UNIX":
		if c.CreatedAt == 0 {
			return ""
		}
		return strconv.FormatInt(c.CreatedAt/1000, 10)
	case "TRAFFIC_USED":
		return common.FormatTraffic(used)
	case "TRAFFIC_LEFT":
		if st.Total <= 0 {
			return unlimitedMark
		}
		return common.FormatTraffic(max64(st.Total-used, 0))
	case "TRAFFIC_TOTAL":
		if st.Total <= 0 {
			return unlimitedMark
		}
		return common.FormatTraffic(st.Total)
	case "TRAFFIC_USED_BYTES":
		return strconv.FormatInt(used, 10)
	case "TRAFFIC_LEFT_BYTES":
		if st.Total <= 0 {
			return "0"
		}
		return strconv.FormatInt(max64(st.Total-used, 0), 10)
	case "TRAFFIC_TOTAL_BYTES":
		return strconv.FormatInt(st.Total, 10)
	case "UP":
		return common.FormatTraffic(st.Up)
	case "DOWN":
		return common.FormatTraffic(st.Down)
	case "RESET_DAYS":
		if c.Reset > 0 {
			return strconv.Itoa(c.Reset)
		}
		return ""
	case "RESET_DAY":
		if c.ResetDay > 0 {
			return strconv.Itoa(c.ResetDay)
		}
		return ""
	case "STATUS_EMOJI":
		return statusEmoji(st)
	case "USAGE_PERCENTAGE":
		return usagePercentage(st)
	case "PROTOCOL":
		if ctx.inbound != nil {
			return strings.ToUpper(string(ctx.inbound.Protocol))
		}
		return ""
	case "TRANSPORT":
		return ctx.transport
	case "SECURITY":
		return strings.ToUpper(ctx.security)
	case "TIME_LEFT":
		return timeLeftLabel(st.ExpiryTime)
	case "JALALI_EXPIRE_DATE":
		return jalaliExpireDateLabel(st.ExpiryTime)
	}
	return ""
}

// clientStatus collapses enable/expiry/quota into a single word.
func clientStatus(st xray.ClientTraffic) string {
	if !st.Enable {
		return "disabled"
	}
	if st.ExpiryTime > 0 && st.ExpiryTime/1000 < time.Now().Unix() {
		return "expired"
	}
	if st.Total > 0 && st.Up+st.Down >= st.Total {
		return "depleted"
	}
	return "active"
}

// daysLeftLabel is the whole-days form of remainingTimeLabel: "∞" for unlimited,
// "0" once past expiry.
func daysLeftLabel(expiryMs int64) string {
	if expiryMs == 0 {
		return unlimitedMark
	}
	exp := expiryMs / 1000
	var secs int64
	if exp > 0 {
		secs = exp - time.Now().Unix()
	} else {
		secs = -exp // delayed-start: value is the duration itself
	}
	days := secs / 86400
	if days < 0 {
		return "0"
	}
	return strconv.FormatInt(days, 10)
}

// expireDateLabel renders a fixed expiry as YYYY-MM-DD (local time). Unlimited
// and delayed-start (no fixed calendar date yet) expiries yield "".
func expireDateLabel(expiryMs int64) string {
	if expiryMs <= 0 {
		return ""
	}
	return time.Unix(expiryMs/1000, 0).In(time.Local).Format("2006-01-02")
}

func max64(a, b int64) int64 {
	if a > b {
		return a
	}
	return b
}

// statusEmoji maps clientStatus to a single emoji character.
func statusEmoji(st xray.ClientTraffic) string {
	switch clientStatus(st) {
	case "active":
		return "✅"
	case "expired":
		return "⏳"
	case "depleted":
		return "🚫"
	case "disabled":
		return "🚫"
	default:
		return ""
	}
}

// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3％").
// Uses U+FF05: an ASCII percent encodes to %25, which Happ rejects, dropping the remark.
func usagePercentage(st xray.ClientTraffic) string {
	if st.Total <= 0 {
		return ""
	}
	used := st.Up + st.Down
	pct := float64(used) / float64(st.Total) * 100
	if pct > 100 {
		pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
	}
	return fmt.Sprintf("%.1f％", pct)
}

// timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
// are zero). Returns "∞" for unlimited and "0" when past expiry.
func timeLeftLabel(expiryMs int64) string {
	if expiryMs == 0 {
		return unlimitedMark
	}
	exp := expiryMs / 1000
	var secs int64
	if exp > 0 {
		secs = exp - time.Now().Unix()
	} else {
		secs = -exp
	}
	if secs <= 0 {
		return "0"
	}
	days := secs / 86400
	hours := (secs % 86400) / 3600
	mins := (secs % 3600) / 60
	if days > 0 {
		return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
	}
	if hours > 0 {
		return fmt.Sprintf("%dh %dm", hours, mins)
	}
	return fmt.Sprintf("%dm", mins)
}

// jalaliExpireDateLabel converts a Gregorian expiry timestamp to Jalali
// (Persian/Solar Hijri) date format "YYYY/MM/DD". Returns "" for unlimited
// or delayed-start expiries.
func jalaliExpireDateLabel(expiryMs int64) string {
	if expiryMs <= 0 {
		return ""
	}
	t := time.Unix(expiryMs/1000, 0).In(time.Local)
	y, m, d := gregorianToJalali(t.Year(), int(t.Month()), t.Day())
	return fmt.Sprintf("%d/%02d/%02d", y, m, d)
}

// gregorianToJalali converts a Gregorian date to Jalali (Solar Hijri) date.
// Uses a reference-date approach: counts days from a known reference point
// (2024-01-01 = 1402-10-11 JAL) and walks the Jalali calendar forward/backward.
func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
	// Compute Julian Day Number for the input Gregorian date
	a := (14 - gm) / 12
	y := gy + 4800 - a
	m := gm + 12*a - 3
	jdn := gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045

	// Reference: 2024-01-01 = JDN 2460311 = 1402-10-11 JAL
	refJDN := 2460311
	days := int64(jdn - refJDN)
	jy, jm, jd = 1402, 10, 11

	// Walk forward
	for days > 0 {
		remaining := int64(jalaliMonthDays(jy, jm) - jd + 1)
		if days < remaining {
			jd += int(days)
			return
		}
		days -= remaining
		jm++
		if jm > 12 {
			jm = 1
			jy++
		}
		jd = 1
	}
	// Walk backward
	for days < 0 {
		jd += int(days)
		for jd < 1 {
			jm--
			if jm < 1 {
				jm = 12
				jy--
			}
			jd += jalaliMonthDays(jy, jm)
		}
		days = 0
	}
	return
}

func jalaliMonthDays(y, m int) int {
	if m <= 6 {
		return 31
	}
	if m <= 11 {
		return 30
	}
	if isJalaliLeap(y) {
		return 30
	}
	return 29
}

// isJalaliLeap reports whether the given Jalali year is a leap year.
// The leap pattern repeats every 33 years with 8 leap years.
func isJalaliLeap(y int) bool {
	switch y % 33 {
	case 1, 5, 9, 13, 17, 22, 26, 30:
		return true
	}
	return false
}

// statsForClient returns the client's live traffic record, or a minimal one
// synthesized from the client (enable/expiry/total) when no live stats exist —
// so expiry/total/status tokens still resolve on links that have no counters yet.
func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client) xray.ClientTraffic {
	if stats, ok := s.findClientStats(inbound, client.Email); ok {
		return stats
	}
	// client_traffics.email is globally unique, so a client shared across several
	// inbounds of one subscription has a single traffic row owned by exactly one
	// inbound. On every other inbound's link findClientStats misses; fall back to
	// the per-request map built from all the subscription's inbounds so
	// {{TRAFFIC_*}} reflect real usage instead of the full quota (#5443).
	if stats, ok := s.statsByEmail[client.Email]; ok {
		return stats
	}
	// Both in-memory paths key off client_traffics.inbound_id, which goes stale
	// when an inbound is deleted and recreated, orphaning the row from every
	// loaded inbound. Fall back to a direct lookup by the globally-unique email
	// so usage still resolves for clients predating that recreation (#5567).
	if stats, ok := s.statsByEmailFromDB(client.Email); ok {
		return stats
	}
	return xray.ClientTraffic{
		Enable:     client.Enable,
		ExpiryTime: client.ExpiryTime,
		Total:      client.TotalGB,
	}
}

// lookupClient resolves the full client (TgID, SubID, comment, …) for an email,
// needed when a global remark template references client-only tokens. Falls back
// to an email-only client if not found.
func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
	if c, ok := s.clientForLink(inbound, email); ok {
		return c
	}
	return model.Client{Email: email}
}

var usageInfoTokens = map[string]bool{
	"TRAFFIC_USED": true, "TRAFFIC_LEFT": true, "TRAFFIC_TOTAL": true,
	"TRAFFIC_USED_BYTES": true, "TRAFFIC_LEFT_BYTES": true, "TRAFFIC_TOTAL_BYTES": true,
	"UP": true, "DOWN": true, "DAYS_LEFT": true, "EXPIRE_DATE": true, "EXPIRE_UNIX": true,
	"STATUS": true, "STATUS_EMOJI": true, "USAGE_PERCENTAGE": true, "TIME_LEFT": true,
	"JALALI_EXPIRE_DATE": true,
}

var connectionTokens = map[string]bool{
	"PROTOCOL":  true,
	"TRANSPORT": true,
	"SECURITY":  true,
}

var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens)

// firstLinkOnlyBodyTokens are stripped from every subscription-body link after a
// client's first one: the usage/info tokens plus the per-client EMAIL/USERNAME
// identity. A client app needs the email once, so repeating it on every link of
// the same subscription is noise — show it on the first link only, like traffic.
var firstLinkOnlyBodyTokens = mergeTokenSets(usageInfoTokens, map[string]bool{
	"EMAIL":    true,
	"USERNAME": true,
})

func mergeTokenSets(sets ...map[string]bool) map[string]bool {
	out := make(map[string]bool)
	for _, set := range sets {
		for tok := range set {
			out[tok] = true
		}
	}
	return out
}

func filterRemarkTemplate(template string, remove map[string]bool) string {
	segments := strings.Split(template, "|")
	kept := make([]string, 0, len(segments))
	for _, seg := range segments {
		if out := filterRemarkSegment(seg, remove); out != "" {
			kept = append(kept, out)
		}
	}
	return strings.Join(kept, "|")
}

// filterRemarkSegment drops whole token categories from one segment while it is
// still a template, before any value is known. Literal text touching a removed
// token goes with it and the surviving runs rejoin with a space, so filtering the
// usage tokens out of "{{EMAIL}} 📊{{TRAFFIC_LEFT}}" leaves "{{EMAIL}}". This is
// the template-level counterpart to expandSegment, which works one layer later on
// tokens that survive here but resolve to an empty value.
func filterRemarkSegment(seg string, remove map[string]bool) string {
	tokens := remarkTokens(seg)
	hasRemove := false
	for _, tok := range tokens {
		if remove[tok.name] {
			hasRemove = true
			break
		}
	}
	if !hasRemove {
		return strings.TrimSpace(seg)
	}
	runs := make([]string, 0, 2)
	runStart, leftRemoved := 0, false
	for _, tok := range tokens {
		if !remove[tok.name] {
			continue
		}
		runs = appendKeptRun(runs, seg[runStart:tok.start], leftRemoved, true)
		runStart, leftRemoved = tok.end, true
	}
	runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
	return strings.Join(runs, " ")
}

func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
	tokens := remarkTokens(run)
	if len(tokens) == 0 {
		return runs
	}
	start, end := 0, len(run)
	if leftRemoved {
		start = tokens[0].start
	}
	if rightRemoved {
		end = tokens[len(tokens)-1].end
	}
	if frag := strings.TrimSpace(run[start:end]); frag != "" {
		runs = append(runs, frag)
	}
	return runs
}

func templateInfoKey(client model.Client) string {
	if client.SubID != "" {
		return "sub:" + client.SubID
	}
	return "email:" + client.Email
}

func (s *SubService) effectiveTemplate(client model.Client) string {
	translated := translateUISingleBrackets(s.remarkTemplate)
	if s.usageShown == nil {
		s.usageShown = map[string]bool{}
	}
	key := templateInfoKey(client)
	if s.usageShown[key] {
		remove := firstLinkOnlyBodyTokens
		if s.showIdentityOnAllLinks {
			remove = usageInfoTokens
		}
		return filterRemarkTemplate(translated, remove)
	}
	s.usageShown[key] = true
	return translated
}

func inboundSecurity(inbound *model.Inbound) string {
	if inbound == nil {
		return ""
	}
	stream := unmarshalStreamSettings(inbound.StreamSettings)
	security, _ := stream["security"].(string)
	return security
}

// genTemplatedRemark expands the remark template for one client. hostRemark is
// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
// token only and never substitutes the inbound remark as the config name.
func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
	ctx := remarkContext{
		client:     client,
		stats:      s.statsForClient(inbound, client),
		inbound:    inbound,
		hostRemark: hostRemark,
		transport:  transport,
		security:   inboundSecurity(inbound),
	}
	var tmpl string
	if s.subscriptionBody {
		tmpl = s.effectiveTemplate(client)
	} else {
		tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
	}
	if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
		return out
	}
	return ctx.configName()
}

// genHostRemark builds one host endpoint's remark for a specific client. With a
// remark template set it is template-driven (body shows the full template on the
// first link and the name-only part thereafter; displays render the name-only
// part). With no template it falls back to inbound, host and email joined by "-".
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
	if s.remarkTemplate != "" {
		return s.genTemplatedRemark(inbound, client, hostRemark, transport)
	}
	return fallbackRemark(inbound.Remark, hostRemark, client.Email)
}
