OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
 
master @ 649 LINES
 
[ HISTORY ]  [ UP ]
 

package cmd

import (
	"encoding/csv"
	"encoding/json"
	"fmt"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/spf13/cobra"
	"ookstats/internal/blizzard"
)

var investigateAccountCmd = &cobra.Command{
	Use:   "investigate-account",
	Short: "Probe shared-account fingerprinting via account-wide achievement timestam
ps",
	Long: `Single pair: --region/--realm-a/--name-a/--name-b/--realm-b
Batch:       --pairs-file pairs.csv

Batch mode reads labelled pairs (same/different account), runs the probe on
each, then aggregates to suggest a trusted account-wide allowlist plus a
blocklist of globally-granted achievements.`,
	RunE: func(cmd *cobra.Command, args []string) error {
		pairsFile, _ := cmd.Flags().GetString("pairs-file")
		if pairsFile != "" {
			return runBatch(cmd, pairsFile)
		}
		return runSinglePair(cmd)
	},
}

// pairResult holds the per-pair probe outcome that the batch aggregator needs.
type pairResult struct {
	Region       string
	Label        string // human-readable, e.g. "Saiyoran/Saiyopink"
	Relationship string // "same" | "different"
	// CharA and CharB are the canonical (region, realm, name) keys for each
	// character; the aggregator union-finds across `same` pairs to dedupe
	// accounts when the same character appears in multiple pairs.
	CharA charKey
	CharB charKey
	// matches: id -> ts (only entries where both characters had a non-nil
	// completed_timestamp AND the timestamps were equal)
	Matches map[int]int64
}

// charKey identifies a character. Lowercased on construction so name
// capitalisation in the input file doesn't fragment the union-find.
type charKey struct {
	Region string
	Realm  string
	Name   string
}

func makeCharKey(region, realm, name string) charKey {
	return charKey{
		Region: strings.ToLower(region),
		Realm:  strings.ToLower(realm),
		Name:   strings.ToLower(name),
	}
}

// unionFind is a tiny path-compressed disjoint-set over charKey. Used to
// collapse `same` pairs into account-equivalence classes before aggregating.
type unionFind struct {
	parent map[charKey]charKey
}

func newUnionFind() *unionFind {
	return &unionFind{parent: make(map[charKey]charKey)}
}

func (u *unionFind) find(k charKey) charKey {
	p, ok := u.parent[k]
	if !ok {
		u.parent[k] = k
		return k
	}
	if p == k {
		return k
	}
	root := u.find(p)
	u.parent[k] = root
	return root
}

func (u *unionFind) union(a, b charKey) {
	ra, rb := u.find(a), u.find(b)
	if ra != rb {
		u.parent[ra] = rb
	}
}

// runSinglePair preserves the original interactive probe behaviour.
func runSinglePair(cmd *cobra.Command) error {
	region, _ := cmd.Flags().GetString("region")
	realmA, _ := cmd.Flags().GetString("realm-a")
	nameA, _ := cmd.Flags().GetString("name-a")
	realmB, _ := cmd.Flags().GetString("realm-b")
	nameB, _ := cmd.Flags().GetString("name-b")
	showDefs, _ := cmd.Flags().GetBool("definitions")
	limitDefs, _ := cmd.Flags().GetInt("definitions-limit")

	if region == "" || realmA == "" || nameA == "" || nameB == "" {
		return fmt.Errorf("--region, --realm-a, --name-a, --name-b are required (or use 
--pairs-file)")
	}
	if realmB == "" {
		realmB = realmA
	}

	client, err := blizzard.NewClient()
	if err != nil {
		return fmt.Errorf("blizzard client: %w", err)
	}

	fmt.Printf("=== Account Fingerprint Investigation ===\n")
	fmt.Printf("Region: %s\n", region)
	fmt.Printf("A: %s on %s\n", nameA, realmA)
	fmt.Printf("B: %s on %s\n\n", nameB, realmB)

	res, aCompleted, bCompleted, mismatchCount, err := probePair(client, region, real
mA, nameA, realmB, nameB)
	if err != nil {
		return err
	}

	fmt.Printf("Completed achievements - A: %d, B: %d\n", aCompleted, bCompleted)
	fmt.Printf("Achievements completed on both: %d\n", len(res.Matches)+mismatchCount
)
	fmt.Printf("  -> identical timestamp: %d (likely account-wide)\n", len(res.Matche
s))
	fmt.Printf("  -> different timestamp: %d (character-specific)\n\n", mismatchCount
)

	if len(res.Matches) == 0 {
		fmt.Println("No timestamp overlap. Either different accounts, or no overlapping 
account-wide achievements.")
		return nil
	}

	fmt.Printf("VERDICT: %s and %s share an account (confidence: very high)\n\n", nam
eA, nameB)

	matches := sortedMatchEntries(res.Matches)

	if showDefs {
		n := limitDefs
		if n <= 0 || n > len(matches) {
			n = len(matches)
		}
		fmt.Printf("=== Sample of %d/%d matching achievements ===\n", n, len(matches))
		fmt.Printf("%-8s  %-19s  %-7s  %s\n", "ID", "Completed (UTC)", "AcctWide", "Name
")
		acctWideCount := 0
		fetched := 0
		cache := make(map[int]*blizzard.AchievementDetailResponse)
		for i := 0; i < n; i++ {
			m := matches[i]
			det, err := fetchAchievementCached(client, region, m.ID, cache)
			if err != nil {
				fmt.Printf("%-8d  %-19s  %-7s  <fetch error: %v>\n",
					m.ID, fmtTs(m.Ts), "?", err)
				continue
			}
			fetched++
			if det.IsAccountWide {
				acctWideCount++
			}
			fmt.Printf("%-8d  %-19s  %-7s  %s\n", det.ID, fmtTs(m.Ts), yesNo(det.IsAccountW
ide), det.Name)
		}
		fmt.Printf("\n  %d/%d sampled matches are flagged is_account_wide=true\n", acctW
ideCount, fetched)
	} else {
		fmt.Printf("=== %d matching achievements (id, completed_timestamp) ===\n", len(m
atches))
		fmt.Printf("(Pass --definitions to fetch names + is_account_wide flag)\n\n")
		for _, m := range matches {
			fmt.Printf("  %-8d  %s\n", m.ID, fmtTs(m.Ts))
		}
	}
	return nil
}

// runBatch reads a CSV of labelled pairs and emits an aggregate fingerprint
// analysis suitable for building the trusted set + blocklist.
func runBatch(cmd *cobra.Command, pairsFile string) error {
	outFile, _ := cmd.Flags().GetString("output")

	client, err := blizzard.NewClient()
	if err != nil {
		return fmt.Errorf("blizzard client: %w", err)
	}

	pairs, err := readPairsFile(pairsFile)
	if err != nil {
		return fmt.Errorf("read pairs file: %w", err)
	}

	fmt.Printf("=== Batch Account Fingerprint Investigation ===\n")
	fmt.Printf("Pairs: %d (same: %d, different: %d)\n\n",
		len(pairs), countRel(pairs, "same"), countRel(pairs, "different"))

	defCache := make(map[int]*blizzard.AchievementDetailResponse)
	results := make([]pairResult, 0, len(pairs))

	for i, p := range pairs {
		fmt.Printf("[%d/%d] %s (%s)... ", i+1, len(pairs), p.Label, p.Relationship)
		res, _, _, _, err := probePair(client, p.Region, p.RealmA, p.NameA, p.RealmB, p.
NameB)
		if err != nil {
			fmt.Printf("ERROR: %v\n", err)
			continue
		}
		res.Region = p.Region
		res.Label = p.Label
		res.Relationship = p.Relationship
		res.CharA = makeCharKey(p.Region, p.RealmA, p.NameA)
		res.CharB = makeCharKey(p.Region, p.RealmB, p.NameB)
		results = append(results, res)
		fmt.Printf("%d matches\n", len(res.Matches))
	}

	fmt.Printf("\nFetching achievement definitions for %d unique IDs...\n", uniqueMat
chedIDs(results))
	for _, r := range results {
		for id := range r.Matches {
			if _, err := fetchAchievementCached(client, r.Region, id, defCache); err != nil
 {
				fmt.Printf("  WARN: id %d: %v\n", id, err)
			}
		}
	}

	report := analyse(results, defCache)
	printReport(report, defCache)

	if outFile != "" {
		if err := writeReportJSON(outFile, report, defCache); err != nil {
			return fmt.Errorf("write %s: %w", outFile, err)
		}
		fmt.Printf("\nWrote curated set to %s\n", outFile)
	}
	return nil
}

// pairInput is the parsed CSV row.
type pairInput struct {
	Region       string
	RealmA       string
	NameA        string
	RealmB       string
	NameB        string
	Relationship string
	Label        string
}

func readPairsFile(path string) ([]pairInput, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	r := csv.NewReader(f)
	r.TrimLeadingSpace = true
	r.Comment = '#'
	rows, err := r.ReadAll()
	if err != nil {
		return nil, err
	}
	if len(rows) == 0 {
		return nil, fmt.Errorf("empty file")
	}

	// Header is required so column order is explicit.
	header := rows[0]
	idx := make(map[string]int, len(header))
	for i, col := range header {
		idx[strings.ToLower(strings.TrimSpace(col))] = i
	}
	required := []string{"region", "realm_a", "name_a", "name_b", "relationship"}
	for _, c := range required {
		if _, ok := idx[c]; !ok {
			return nil, fmt.Errorf("missing required column: %s", c)
		}
	}

	out := make([]pairInput, 0, len(rows)-1)
	for n, row := range rows[1:] {
		if len(row) == 0 || (len(row) == 1 && row[0] == "") {
			continue
		}
		get := func(col string) string {
			i, ok := idx[col]
			if !ok || i >= len(row) {
				return ""
			}
			return strings.TrimSpace(row[i])
		}
		p := pairInput{
			Region:       strings.ToLower(get("region")),
			RealmA:       strings.ToLower(get("realm_a")),
			NameA:        get("name_a"),
			RealmB:       strings.ToLower(get("realm_b")),
			NameB:        get("name_b"),
			Relationship: strings.ToLower(get("relationship")),
		}
		if p.RealmB == "" {
			p.RealmB = p.RealmA
		}
		if p.Region == "" || p.RealmA == "" || p.NameA == "" || p.NameB == "" {
			return nil, fmt.Errorf("row %d: missing required field", n+2)
		}
		if p.Relationship != "same" && p.Relationship != "different" {
			return nil, fmt.Errorf("row %d: relationship must be 'same' or 'different', got
 %q", n+2, p.Relationship)
		}
		p.Label = fmt.Sprintf("%s/%s", p.NameA, p.NameB)
		out = append(out, p)
	}
	return out, nil
}

// probePair fetches both characters' achievements and returns the matched set.
func probePair(c *blizzard.Client, region, realmA, nameA, realmB, nameB string) (p
airResult, int, int, int, error) {
	res := pairResult{Matches: make(map[int]int64)}

	achA, err := c.FetchCharacterAchievements(nameA, realmA, region)
	if err != nil {
		return res, 0, 0, 0, fmt.Errorf("fetch %s: %w", nameA, err)
	}
	achB, err := c.FetchCharacterAchievements(nameB, realmB, region)
	if err != nil {
		return res, 0, 0, 0, fmt.Errorf("fetch %s: %w", nameB, err)
	}

	bByID := make(map[int]int64, len(achB.Achievements))
	bCompleted := 0
	for _, a := range achB.Achievements {
		if a.CompletedTimestamp != nil {
			bByID[a.ID] = *a.CompletedTimestamp
			bCompleted++
		}
	}
	aCompleted := 0
	mismatch := 0
	for _, a := range achA.Achievements {
		if a.CompletedTimestamp == nil {
			continue
		}
		aCompleted++
		tsB, ok := bByID[a.ID]
		if !ok {
			continue
		}
		if *a.CompletedTimestamp == tsB {
			res.Matches[a.ID] = tsB
		} else {
			mismatch++
		}
	}
	return res, aCompleted, bCompleted, mismatch, nil
}

// achievementStat aggregates one achievement's behaviour across all accounts.
// Same-pair matches are deduped via union-find: pairs that share a character
// collapse to the same account, so an achievement only earned once on that
// account is counted once regardless of how many pairs sampled it.
type achievementStat struct {
	ID               int
	IsAccountWide    bool
	SameAccounts     int   // distinct accounts that matched on this id
	SameAccountTs    []int64 // one ts per matching account (deduped per account)
	DifferentPairs   int   // count of `different`-pair matches (cross-account collis
ions)
	DistinctTsInSame int   // unique ts across the matched accounts
}

// classification is the suggested bucket for an achievement.
type classification string

const (
	clsTrusted        classification = "trusted"
	clsGloballyGranted classification = "globally-granted"
	clsCharSpecific   classification = "character-specific"
	clsThin           classification = "thin"
)

// reportRow is one line in the final aggregated report.
type reportRow struct {
	Stat           achievementStat
	Classification classification
	Reason         string
}

// analyse walks the per-pair results and classifies each matched achievement.
// Same-pairs that share a character collapse into one account via union-find,
// so the per-achievement counts reflect distinct accounts rather than pairs.
func analyse(results []pairResult, defCache map[int]*blizzard.AchievementDetailRes
ponse) []reportRow {
	uf := newUnionFind()
	for _, r := range results {
		if r.Relationship == "same" {
			uf.union(r.CharA, r.CharB)
		}
	}

	// id -> account-root -> ts (one ts per account, since within an account
	// the matching ts is the same by construction)
	perID := make(map[int]map[charKey]int64)
	differentPairs := make(map[int]int)
	for _, r := range results {
		if r.Relationship != "same" {
			for id := range r.Matches {
				differentPairs[id]++
			}
			continue
		}
		acct := uf.find(r.CharA)
		for id, ts := range r.Matches {
			byAcct, ok := perID[id]
			if !ok {
				byAcct = make(map[charKey]int64)
				perID[id] = byAcct
			}
			byAcct[acct] = ts
		}
	}

	stats := make(map[int]*achievementStat)
	for id, byAcct := range perID {
		s := &achievementStat{ID: id}
		if det, hit := defCache[id]; hit {
			s.IsAccountWide = det.IsAccountWide
		}
		s.SameAccounts = len(byAcct)
		distinct := make(map[int64]struct{})
		for _, ts := range byAcct {
			s.SameAccountTs = append(s.SameAccountTs, ts)
			distinct[ts] = struct{}{}
		}
		s.DistinctTsInSame = len(distinct)
		stats[id] = s
	}
	// Cover ids only seen in different-pairs.
	for id, n := range differentPairs {
		s, ok := stats[id]
		if !ok {
			s = &achievementStat{ID: id}
			if det, hit := defCache[id]; hit {
				s.IsAccountWide = det.IsAccountWide
			}
			stats[id] = s
		}
		s.DifferentPairs = n
	}
	// Apply different-pair counts to ids that also matched in same-pairs.
	for id, n := range differentPairs {
		if s, ok := stats[id]; ok && s.DifferentPairs == 0 {
			s.DifferentPairs = n
		}
	}

	rows := make([]reportRow, 0, len(stats))
	for _, s := range stats {
		row := reportRow{Stat: *s}
		switch {
		case !s.IsAccountWide:
			row.Classification = clsCharSpecific
			row.Reason = "is_account_wide=false"
		case s.DifferentPairs > 0:
			// Same ts on a genuinely-different account - globally-granted.
			row.Classification = clsGloballyGranted
			row.Reason = fmt.Sprintf("matched in %d different-account pair(s)", s.Different
Pairs)
		case s.SameAccounts >= 2 && s.DistinctTsInSame == 1:
			// Multiple distinct accounts share one ts - globally-granted.
			row.Classification = clsGloballyGranted
			row.Reason = fmt.Sprintf("%d accounts share one ts", s.SameAccounts)
		case s.SameAccounts >= 2 && s.DistinctTsInSame == s.SameAccounts:
			row.Classification = clsTrusted
			row.Reason = fmt.Sprintf("%d accounts, all distinct ts", s.SameAccounts)
		case s.SameAccounts == 1:
			row.Classification = clsThin
			row.Reason = "only 1 account sampled"
		default:
			row.Classification = clsTrusted
			row.Reason = fmt.Sprintf("%d accounts, %d distinct ts", s.SameAccounts, s.Disti
nctTsInSame)
		}
		rows = append(rows, row)
	}

	// Trusted first, then thin, globally-granted, character-specific. Within a
	// class, sort by sample count desc so the most-evidenced rows surface first.
	classRank := map[classification]int{
		clsTrusted: 0, clsThin: 1, clsGloballyGranted: 2, clsCharSpecific: 3,
	}
	sort.Slice(rows, func(i, j int) bool {
		if classRank[rows[i].Classification] != classRank[rows[j].Classification] {
			return classRank[rows[i].Classification] < classRank[rows[j].Classification]
		}
		ai := rows[i].Stat.SameAccounts + rows[i].Stat.DifferentPairs
		aj := rows[j].Stat.SameAccounts + rows[j].Stat.DifferentPairs
		if ai != aj {
			return ai > aj
		}
		return rows[i].Stat.ID < rows[j].Stat.ID
	})
	return rows
}

func printReport(rows []reportRow, defCache map[int]*blizzard.AchievementDetailRes
ponse) {
	counts := make(map[classification]int)
	for _, r := range rows {
		counts[r.Classification]++
	}

	fmt.Printf("\n=== Aggregate report (%d unique achievements) ===\n", len(rows))
	fmt.Printf("trusted: %d, thin: %d, globally-granted: %d, character-specific: %d\n
\n",
		counts[clsTrusted], counts[clsThin], counts[clsGloballyGranted], counts[clsCharS
pecific])

	fmt.Printf("%-9s  %-8s  %-5s  %-5s  %-5s  %-32s  %s\n",
		"CLASS", "ID", "ACCTS", "DIFFt", "DISTt", "REASON", "NAME")
	for _, r := range rows {
		name := "?"
		if det, ok := defCache[r.Stat.ID]; ok {
			name = det.Name
		}
		fmt.Printf("%-9s  %-8d  %-5d  %-5d  %-5d  %-32s  %s\n",
			r.Classification, r.Stat.ID,
			r.Stat.SameAccounts, r.Stat.DifferentPairs, r.Stat.DistinctTsInSame,
			r.Reason, name,
		)
	}
}

// jsonReport is the on-disk shape of the curated set.
type jsonReport struct {
	GeneratedAt       int64     `json:"generated_at"`
	Trusted           []jsonAch `json:"trusted"`
	Thin              []jsonAch `json:"thin"`
	GloballyGranted   []jsonAch `json:"globally_granted"`
	CharacterSpecific []jsonAch `json:"character_specific"`
}

type jsonAch struct {
	ID            int    `json:"id"`
	Name          string `json:"name"`
	IsAccountWide bool   `json:"is_account_wide"`
	SameAccounts  int    `json:"same_accounts"`
	DiffPairs     int    `json:"different_pairs"`
	DistinctTs    int    `json:"distinct_ts_in_same"`
	Reason        string `json:"reason"`
}

func writeReportJSON(path string, rows []reportRow, defCache map[int]*blizzard.Ach
ievementDetailResponse) error {
	rep := jsonReport{GeneratedAt: time.Now().UnixMilli()}
	for _, r := range rows {
		entry := jsonAch{
			ID:            r.Stat.ID,
			IsAccountWide: r.Stat.IsAccountWide,
			SameAccounts:  r.Stat.SameAccounts,
			DiffPairs:     r.Stat.DifferentPairs,
			DistinctTs:    r.Stat.DistinctTsInSame,
			Reason:        r.Reason,
		}
		if det, ok := defCache[r.Stat.ID]; ok {
			entry.Name = det.Name
		}
		switch r.Classification {
		case clsTrusted:
			rep.Trusted = append(rep.Trusted, entry)
		case clsThin:
			rep.Thin = append(rep.Thin, entry)
		case clsGloballyGranted:
			rep.GloballyGranted = append(rep.GloballyGranted, entry)
		case clsCharSpecific:
			rep.CharacterSpecific = append(rep.CharacterSpecific, entry)
		}
	}

	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()
	enc := json.NewEncoder(f)
	enc.SetIndent("", "  ")
	return enc.Encode(rep)
}

// --- helpers ---------------------------------------------------------------

type matchEntry struct {
	ID int
	Ts int64
}

func sortedMatchEntries(m map[int]int64) []matchEntry {
	out := make([]matchEntry, 0, len(m))
	for id, ts := range m {
		out = append(out, matchEntry{ID: id, Ts: ts})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Ts < out[j].Ts })
	return out
}

func fetchAchievementCached(c *blizzard.Client, region string, id int, cache map[i
nt]*blizzard.AchievementDetailResponse) (*blizzard.AchievementDetailResponse, erro
r) {
	if det, ok := cache[id]; ok {
		return det, nil
	}
	det, err := c.FetchAchievement(id, region)
	if err != nil {
		return nil, err
	}
	cache[id] = det
	return det, nil
}

func uniqueMatchedIDs(rs []pairResult) int {
	set := make(map[int]struct{})
	for _, r := range rs {
		for id := range r.Matches {
			set[id] = struct{}{}
		}
	}
	return len(set)
}

func countRel(pairs []pairInput, rel string) int {
	n := 0
	for _, p := range pairs {
		if p.Relationship == rel {
			n++
		}
	}
	return n
}

func fmtTs(ms int64) string {
	return time.UnixMilli(ms).UTC().Format("2006-01-02 15:04:05")
}

func yesNo(b bool) string {
	if b {
		return "yes"
	}
	return "no"
}

func init() {
	rootCmd.AddCommand(investigateAccountCmd)
	investigateAccountCmd.Flags().String("region", "", "Region (us|eu|kr|tw)")
	investigateAccountCmd.Flags().String("realm-a", "", "Realm slug for character A")
	investigateAccountCmd.Flags().String("name-a", "", "Name of character A")
	investigateAccountCmd.Flags().String("realm-b", "", "Realm slug for character B (
defaults to realm-a)")
	investigateAccountCmd.Flags().String("name-b", "", "Name of character B")
	investigateAccountCmd.Flags().Bool("definitions", false, "Fetch each matching ach
ievement's definition (single-pair mode)")
	investigateAccountCmd.Flags().Int("definitions-limit", 25, "Cap definition lookup
s in single-pair mode")
	investigateAccountCmd.Flags().String("pairs-file", "", "CSV of labelled pairs for
 batch aggregate analysis")
	investigateAccountCmd.Flags().String("output", "", "Optional path to write the cu
rated JSON report (batch mode)")
}

package cmd

import (
	"encoding/csv"
	"encoding/json"
	"fmt"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/spf13/cobra"
	"ookstats/internal/blizzard"
)

var investigateAccountCmd = &cobra.Command{
	Use:   "investigate-account",
	Short: "Probe shared-account fingerprinting
 via account-wide achievement timestamps",
	Long: `Single pair: --region/--realm-a/--na
me-a/--name-b/--realm-b
Batch:       --pairs-file pairs.csv

Batch mode reads labelled pairs (same/differ
ent account), runs the probe on
each, then aggregates to suggest a trusted a
ccount-wide allowlist plus a
blocklist of globally-granted achievements.`
,
	RunE: func(cmd *cobra.Command, args []strin
g) error {
		pairsFile, _ := cmd.Flags().GetString("pai
rs-file")
		if pairsFile != "" {
			return runBatch(cmd, pairsFile)
		}
		return runSinglePair(cmd)
	},
}

// pairResult holds the per-pair probe outco
me that the batch aggregator needs.
type pairResult struct {
	Region       string
	Label        string // human-readable, e.g.
 "Saiyoran/Saiyopink"
	Relationship string // "same" | "different"
	// CharA and CharB are the canonical (regio
n, realm, name) keys for each
	// character; the aggregator union-finds ac
ross `same` pairs to dedupe
	// accounts when the same character appears
 in multiple pairs.
	CharA charKey
	CharB charKey
	// matches: id -> ts (only entries where bo
th characters had a non-nil
	// completed_timestamp AND the timestamps w
ere equal)
	Matches map[int]int64
}

// charKey identifies a character. Lowercase
d on construction so name
// capitalisation in the input file doesn't 
fragment the union-find.
type charKey struct {
	Region string
	Realm  string
	Name   string
}

func makeCharKey(region, realm, name string)
 charKey {
	return charKey{
		Region: strings.ToLower(region),
		Realm:  strings.ToLower(realm),
		Name:   strings.ToLower(name),
	}
}

// unionFind is a tiny path-compressed disjo
int-set over charKey. Used to
// collapse `same` pairs into account-equiva
lence classes before aggregating.
type unionFind struct {
	parent map[charKey]charKey
}

func newUnionFind() *unionFind {
	return &unionFind{parent: make(map[charKey]
charKey)}
}

func (u *unionFind) find(k charKey) charKey 
{
	p, ok := u.parent[k]
	if !ok {
		u.parent[k] = k
		return k
	}
	if p == k {
		return k
	}
	root := u.find(p)
	u.parent[k] = root
	return root
}

func (u *unionFind) union(a, b charKey) {
	ra, rb := u.find(a), u.find(b)
	if ra != rb {
		u.parent[ra] = rb
	}
}

// runSinglePair preserves the original inte
ractive probe behaviour.
func runSinglePair(cmd *cobra.Command) error
 {
	region, _ := cmd.Flags().GetString("region"
)
	realmA, _ := cmd.Flags().GetString("realm-a
")
	nameA, _ := cmd.Flags().GetString("name-a")
	realmB, _ := cmd.Flags().GetString("realm-b
")
	nameB, _ := cmd.Flags().GetString("name-b")
	showDefs, _ := cmd.Flags().GetBool("definit
ions")
	limitDefs, _ := cmd.Flags().GetInt("definit
ions-limit")

	if region == "" || realmA == "" || nameA ==
 "" || nameB == "" {
		return fmt.Errorf("--region, --realm-a, --
name-a, --name-b are required (or use --pair
s-file)")
	}
	if realmB == "" {
		realmB = realmA
	}

	client, err := blizzard.NewClient()
	if err != nil {
		return fmt.Errorf("blizzard client: %w", e
rr)
	}

	fmt.Printf("=== Account Fingerprint Investi
gation ===\n")
	fmt.Printf("Region: %s\n", region)
	fmt.Printf("A: %s on %s\n", nameA, realmA)
	fmt.Printf("B: %s on %s\n\n", nameB, realmB
)

	res, aCompleted, bCompleted, mismatchCount,
 err := probePair(client, region, realmA, na
meA, realmB, nameB)
	if err != nil {
		return err
	}

	fmt.Printf("Completed achievements - A: %d,
 B: %d\n", aCompleted, bCompleted)
	fmt.Printf("Achievements completed on both:
 %d\n", len(res.Matches)+mismatchCount)
	fmt.Printf("  -> identical timestamp: %d (l
ikely account-wide)\n", len(res.Matches))
	fmt.Printf("  -> different timestamp: %d (c
haracter-specific)\n\n", mismatchCount)

	if len(res.Matches) == 0 {
		fmt.Println("No timestamp overlap. Either 
different accounts, or no overlapping accoun
t-wide achievements.")
		return nil
	}

	fmt.Printf("VERDICT: %s and %s share an acc
ount (confidence: very high)\n\n", nameA, na
meB)

	matches := sortedMatchEntries(res.Matches)

	if showDefs {
		n := limitDefs
		if n <= 0 || n > len(matches) {
			n = len(matches)
		}
		fmt.Printf("=== Sample of %d/%d matching a
chievements ===\n", n, len(matches))
		fmt.Printf("%-8s  %-19s  %-7s  %s\n", "ID"
, "Completed (UTC)", "AcctWide", "Name")
		acctWideCount := 0
		fetched := 0
		cache := make(map[int]*blizzard.Achievemen
tDetailResponse)
		for i := 0; i < n; i++ {
			m := matches[i]
			det, err := fetchAchievementCached(client
, region, m.ID, cache)
			if err != nil {
				fmt.Printf("%-8d  %-19s  %-7s  <fetch er
ror: %v>\n",
					m.ID, fmtTs(m.Ts), "?", err)
				continue
			}
			fetched++
			if det.IsAccountWide {
				acctWideCount++
			}
			fmt.Printf("%-8d  %-19s  %-7s  %s\n", det
.ID, fmtTs(m.Ts), yesNo(det.IsAccountWide), 
det.Name)
		}
		fmt.Printf("\n  %d/%d sampled matches are 
flagged is_account_wide=true\n", acctWideCou
nt, fetched)
	} else {
		fmt.Printf("=== %d matching achievements (
id, completed_timestamp) ===\n", len(matches
))
		fmt.Printf("(Pass --definitions to fetch n
ames + is_account_wide flag)\n\n")
		for _, m := range matches {
			fmt.Printf("  %-8d  %s\n", m.ID, fmtTs(m.
Ts))
		}
	}
	return nil
}

// runBatch reads a CSV of labelled pairs an
d emits an aggregate fingerprint
// analysis suitable for building the truste
d set + blocklist.
func runBatch(cmd *cobra.Command, pairsFile 
string) error {
	outFile, _ := cmd.Flags().GetString("output
")

	client, err := blizzard.NewClient()
	if err != nil {
		return fmt.Errorf("blizzard client: %w", e
rr)
	}

	pairs, err := readPairsFile(pairsFile)
	if err != nil {
		return fmt.Errorf("read pairs file: %w", e
rr)
	}

	fmt.Printf("=== Batch Account Fingerprint I
nvestigation ===\n")
	fmt.Printf("Pairs: %d (same: %d, different:
 %d)\n\n",
		len(pairs), countRel(pairs, "same"), count
Rel(pairs, "different"))

	defCache := make(map[int]*blizzard.Achievem
entDetailResponse)
	results := make([]pairResult, 0, len(pairs)
)

	for i, p := range pairs {
		fmt.Printf("[%d/%d] %s (%s)... ", i+1, len
(pairs), p.Label, p.Relationship)
		res, _, _, _, err := probePair(client, p.R
egion, p.RealmA, p.NameA, p.RealmB, p.NameB)
		if err != nil {
			fmt.Printf("ERROR: %v\n", err)
			continue
		}
		res.Region = p.Region
		res.Label = p.Label
		res.Relationship = p.Relationship
		res.CharA = makeCharKey(p.Region, p.RealmA
, p.NameA)
		res.CharB = makeCharKey(p.Region, p.RealmB
, p.NameB)
		results = append(results, res)
		fmt.Printf("%d matches\n", len(res.Matches
))
	}

	fmt.Printf("\nFetching achievement definiti
ons for %d unique IDs...\n", uniqueMatchedID
s(results))
	for _, r := range results {
		for id := range r.Matches {
			if _, err := fetchAchievementCached(clien
t, r.Region, id, defCache); err != nil {
				fmt.Printf("  WARN: id %d: %v\n", id, er
r)
			}
		}
	}

	report := analyse(results, defCache)
	printReport(report, defCache)

	if outFile != "" {
		if err := writeReportJSON(outFile, report,
 defCache); err != nil {
			return fmt.Errorf("write %s: %w", outFile
, err)
		}
		fmt.Printf("\nWrote curated set to %s\n", 
outFile)
	}
	return nil
}

// pairInput is the parsed CSV row.
type pairInput struct {
	Region       string
	RealmA       string
	NameA        string
	RealmB       string
	NameB        string
	Relationship string
	Label        string
}

func readPairsFile(path string) ([]pairInput
, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	r := csv.NewReader(f)
	r.TrimLeadingSpace = true
	r.Comment = '#'
	rows, err := r.ReadAll()
	if err != nil {
		return nil, err
	}
	if len(rows) == 0 {
		return nil, fmt.Errorf("empty file")
	}

	// Header is required so column order is ex
plicit.
	header := rows[0]
	idx := make(map[string]int, len(header))
	for i, col := range header {
		idx[strings.ToLower(strings.TrimSpace(col)
)] = i
	}
	required := []string{"region", "realm_a", "
name_a", "name_b", "relationship"}
	for _, c := range required {
		if _, ok := idx[c]; !ok {
			return nil, fmt.Errorf("missing required 
column: %s", c)
		}
	}

	out := make([]pairInput, 0, len(rows)-1)
	for n, row := range rows[1:] {
		if len(row) == 0 || (len(row) == 1 && row[
0] == "") {
			continue
		}
		get := func(col string) string {
			i, ok := idx[col]
			if !ok || i >= len(row) {
				return ""
			}
			return strings.TrimSpace(row[i])
		}
		p := pairInput{
			Region:       strings.ToLower(get("region
")),
			RealmA:       strings.ToLower(get("realm_
a")),
			NameA:        get("name_a"),
			RealmB:       strings.ToLower(get("realm_
b")),
			NameB:        get("name_b"),
			Relationship: strings.ToLower(get("relati
onship")),
		}
		if p.RealmB == "" {
			p.RealmB = p.RealmA
		}
		if p.Region == "" || p.RealmA == "" || p.N
ameA == "" || p.NameB == "" {
			return nil, fmt.Errorf("row %d: missing r
equired field", n+2)
		}
		if p.Relationship != "same" && p.Relations
hip != "different" {
			return nil, fmt.Errorf("row %d: relations
hip must be 'same' or 'different', got %q", 
n+2, p.Relationship)
		}
		p.Label = fmt.Sprintf("%s/%s", p.NameA, p.
NameB)
		out = append(out, p)
	}
	return out, nil
}

// probePair fetches both characters' achiev
ements and returns the matched set.
func probePair(c *blizzard.Client, region, r
ealmA, nameA, realmB, nameB string) (pairRes
ult, int, int, int, error) {
	res := pairResult{Matches: make(map[int]int
64)}

	achA, err := c.FetchCharacterAchievements(n
ameA, realmA, region)
	if err != nil {
		return res, 0, 0, 0, fmt.Errorf("fetch %s:
 %w", nameA, err)
	}
	achB, err := c.FetchCharacterAchievements(n
ameB, realmB, region)
	if err != nil {
		return res, 0, 0, 0, fmt.Errorf("fetch %s:
 %w", nameB, err)
	}

	bByID := make(map[int]int64, len(achB.Achie
vements))
	bCompleted := 0
	for _, a := range achB.Achievements {
		if a.CompletedTimestamp != nil {
			bByID[a.ID] = *a.CompletedTimestamp
			bCompleted++
		}
	}
	aCompleted := 0
	mismatch := 0
	for _, a := range achA.Achievements {
		if a.CompletedTimestamp == nil {
			continue
		}
		aCompleted++
		tsB, ok := bByID[a.ID]
		if !ok {
			continue
		}
		if *a.CompletedTimestamp == tsB {
			res.Matches[a.ID] = tsB
		} else {
			mismatch++
		}
	}
	return res, aCompleted, bCompleted, mismatc
h, nil
}

// achievementStat aggregates one achievemen
t's behaviour across all accounts.
// Same-pair matches are deduped via union-f
ind: pairs that share a character
// collapse to the same account, so an achie
vement only earned once on that
// account is counted once regardless of how
 many pairs sampled it.
type achievementStat struct {
	ID               int
	IsAccountWide    bool
	SameAccounts     int   // distinct accounts
 that matched on this id
	SameAccountTs    []int64 // one ts per matc
hing account (deduped per account)
	DifferentPairs   int   // count of `differe
nt`-pair matches (cross-account collisions)
	DistinctTsInSame int   // unique ts across 
the matched accounts
}

// classification is the suggested bucket fo
r an achievement.
type classification string

const (
	clsTrusted        classification = "trusted
"
	clsGloballyGranted classification = "global
ly-granted"
	clsCharSpecific   classification = "charact
er-specific"
	clsThin           classification = "thin"
)

// reportRow is one line in the final aggreg
ated report.
type reportRow struct {
	Stat           achievementStat
	Classification classification
	Reason         string
}

// analyse walks the per-pair results and cl
assifies each matched achievement.
// Same-pairs that share a character collaps
e into one account via union-find,
// so the per-achievement counts reflect dis
tinct accounts rather than pairs.
func analyse(results []pairResult, defCache 
map[int]*blizzard.AchievementDetailResponse)
 []reportRow {
	uf := newUnionFind()
	for _, r := range results {
		if r.Relationship == "same" {
			uf.union(r.CharA, r.CharB)
		}
	}

	// id -> account-root -> ts (one ts per acc
ount, since within an account
	// the matching ts is the same by construct
ion)
	perID := make(map[int]map[charKey]int64)
	differentPairs := make(map[int]int)
	for _, r := range results {
		if r.Relationship != "same" {
			for id := range r.Matches {
				differentPairs[id]++
			}
			continue
		}
		acct := uf.find(r.CharA)
		for id, ts := range r.Matches {
			byAcct, ok := perID[id]
			if !ok {
				byAcct = make(map[charKey]int64)
				perID[id] = byAcct
			}
			byAcct[acct] = ts
		}
	}

	stats := make(map[int]*achievementStat)
	for id, byAcct := range perID {
		s := &achievementStat{ID: id}
		if det, hit := defCache[id]; hit {
			s.IsAccountWide = det.IsAccountWide
		}
		s.SameAccounts = len(byAcct)
		distinct := make(map[int64]struct{})
		for _, ts := range byAcct {
			s.SameAccountTs = append(s.SameAccountTs,
 ts)
			distinct[ts] = struct{}{}
		}
		s.DistinctTsInSame = len(distinct)
		stats[id] = s
	}
	// Cover ids only seen in different-pairs.
	for id, n := range differentPairs {
		s, ok := stats[id]
		if !ok {
			s = &achievementStat{ID: id}
			if det, hit := defCache[id]; hit {
				s.IsAccountWide = det.IsAccountWide
			}
			stats[id] = s
		}
		s.DifferentPairs = n
	}
	// Apply different-pair counts to ids that 
also matched in same-pairs.
	for id, n := range differentPairs {
		if s, ok := stats[id]; ok && s.DifferentPa
irs == 0 {
			s.DifferentPairs = n
		}
	}

	rows := make([]reportRow, 0, len(stats))
	for _, s := range stats {
		row := reportRow{Stat: *s}
		switch {
		case !s.IsAccountWide:
			row.Classification = clsCharSpecific
			row.Reason = "is_account_wide=false"
		case s.DifferentPairs > 0:
			// Same ts on a genuinely-different accou
nt - globally-granted.
			row.Classification = clsGloballyGranted
			row.Reason = fmt.Sprintf("matched in %d d
ifferent-account pair(s)", s.DifferentPairs)
		case s.SameAccounts >= 2 && s.DistinctTsIn
Same == 1:
			// Multiple distinct accounts share one t
s - globally-granted.
			row.Classification = clsGloballyGranted
			row.Reason = fmt.Sprintf("%d accounts sha
re one ts", s.SameAccounts)
		case s.SameAccounts >= 2 && s.DistinctTsIn
Same == s.SameAccounts:
			row.Classification = clsTrusted
			row.Reason = fmt.Sprintf("%d accounts, al
l distinct ts", s.SameAccounts)
		case s.SameAccounts == 1:
			row.Classification = clsThin
			row.Reason = "only 1 account sampled"
		default:
			row.Classification = clsTrusted
			row.Reason = fmt.Sprintf("%d accounts, %d
 distinct ts", s.SameAccounts, s.DistinctTsI
nSame)
		}
		rows = append(rows, row)
	}

	// Trusted first, then thin, globally-grant
ed, character-specific. Within a
	// class, sort by sample count desc so the 
most-evidenced rows surface first.
	classRank := map[classification]int{
		clsTrusted: 0, clsThin: 1, clsGloballyGran
ted: 2, clsCharSpecific: 3,
	}
	sort.Slice(rows, func(i, j int) bool {
		if classRank[rows[i].Classification] != cl
assRank[rows[j].Classification] {
			return classRank[rows[i].Classification] 
< classRank[rows[j].Classification]
		}
		ai := rows[i].Stat.SameAccounts + rows[i].
Stat.DifferentPairs
		aj := rows[j].Stat.SameAccounts + rows[j].
Stat.DifferentPairs
		if ai != aj {
			return ai > aj
		}
		return rows[i].Stat.ID < rows[j].Stat.ID
	})
	return rows
}

func printReport(rows []reportRow, defCache 
map[int]*blizzard.AchievementDetailResponse)
 {
	counts := make(map[classification]int)
	for _, r := range rows {
		counts[r.Classification]++
	}

	fmt.Printf("\n=== Aggregate report (%d uniq
ue achievements) ===\n", len(rows))
	fmt.Printf("trusted: %d, thin: %d, globally
-granted: %d, character-specific: %d\n\n",
		counts[clsTrusted], counts[clsThin], count
s[clsGloballyGranted], counts[clsCharSpecifi
c])

	fmt.Printf("%-9s  %-8s  %-5s  %-5s  %-5s  %
-32s  %s\n",
		"CLASS", "ID", "ACCTS", "DIFFt", "DISTt", 
"REASON", "NAME")
	for _, r := range rows {
		name := "?"
		if det, ok := defCache[r.Stat.ID]; ok {
			name = det.Name
		}
		fmt.Printf("%-9s  %-8d  %-5d  %-5d  %-5d  
%-32s  %s\n",
			r.Classification, r.Stat.ID,
			r.Stat.SameAccounts, r.Stat.DifferentPair
s, r.Stat.DistinctTsInSame,
			r.Reason, name,
		)
	}
}

// jsonReport is the on-disk shape of the cu
rated set.
type jsonReport struct {
	GeneratedAt       int64     `json:"generate
d_at"`
	Trusted           []jsonAch `json:"trusted"
`
	Thin              []jsonAch `json:"thin"`
	GloballyGranted   []jsonAch `json:"globally
_granted"`
	CharacterSpecific []jsonAch `json:"characte
r_specific"`
}

type jsonAch struct {
	ID            int    `json:"id"`
	Name          string `json:"name"`
	IsAccountWide bool   `json:"is_account_wide
"`
	SameAccounts  int    `json:"same_accounts"`
	DiffPairs     int    `json:"different_pairs
"`
	DistinctTs    int    `json:"distinct_ts_in_
same"`
	Reason        string `json:"reason"`
}

func writeReportJSON(path string, rows []rep
ortRow, defCache map[int]*blizzard.Achieveme
ntDetailResponse) error {
	rep := jsonReport{GeneratedAt: time.Now().U
nixMilli()}
	for _, r := range rows {
		entry := jsonAch{
			ID:            r.Stat.ID,
			IsAccountWide: r.Stat.IsAccountWide,
			SameAccounts:  r.Stat.SameAccounts,
			DiffPairs:     r.Stat.DifferentPairs,
			DistinctTs:    r.Stat.DistinctTsInSame,
			Reason:        r.Reason,
		}
		if det, ok := defCache[r.Stat.ID]; ok {
			entry.Name = det.Name
		}
		switch r.Classification {
		case clsTrusted:
			rep.Trusted = append(rep.Trusted, entry)
		case clsThin:
			rep.Thin = append(rep.Thin, entry)
		case clsGloballyGranted:
			rep.GloballyGranted = append(rep.Globally
Granted, entry)
		case clsCharSpecific:
			rep.CharacterSpecific = append(rep.Charac
terSpecific, entry)
		}
	}

	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()
	enc := json.NewEncoder(f)
	enc.SetIndent("", "  ")
	return enc.Encode(rep)
}

// --- helpers -----------------------------
----------------------------------

type matchEntry struct {
	ID int
	Ts int64
}

func sortedMatchEntries(m map[int]int64) []m
atchEntry {
	out := make([]matchEntry, 0, len(m))
	for id, ts := range m {
		out = append(out, matchEntry{ID: id, Ts: t
s})
	}
	sort.Slice(out, func(i, j int) bool { retur
n out[i].Ts < out[j].Ts })
	return out
}

func fetchAchievementCached(c *blizzard.Clie
nt, region string, id int, cache map[int]*bl
izzard.AchievementDetailResponse) (*blizzard
.AchievementDetailResponse, error) {
	if det, ok := cache[id]; ok {
		return det, nil
	}
	det, err := c.FetchAchievement(id, region)
	if err != nil {
		return nil, err
	}
	cache[id] = det
	return det, nil
}

func uniqueMatchedIDs(rs []pairResult) int {
	set := make(map[int]struct{})
	for _, r := range rs {
		for id := range r.Matches {
			set[id] = struct{}{}
		}
	}
	return len(set)
}

func countRel(pairs []pairInput, rel string)
 int {
	n := 0
	for _, p := range pairs {
		if p.Relationship == rel {
			n++
		}
	}
	return n
}

func fmtTs(ms int64) string {
	return time.UnixMilli(ms).UTC().Format("200
6-01-02 15:04:05")
}

func yesNo(b bool) string {
	if b {
		return "yes"
	}
	return "no"
}

func init() {
	rootCmd.AddCommand(investigateAccountCmd)
	investigateAccountCmd.Flags().String("regio
n", "", "Region (us|eu|kr|tw)")
	investigateAccountCmd.Flags().String("realm
-a", "", "Realm slug for character A")
	investigateAccountCmd.Flags().String("name-
a", "", "Name of character A")
	investigateAccountCmd.Flags().String("realm
-b", "", "Realm slug for character B (defaul
ts to realm-a)")
	investigateAccountCmd.Flags().String("name-
b", "", "Name of character B")
	investigateAccountCmd.Flags().Bool("definit
ions", false, "Fetch each matching achieveme
nt's definition (single-pair mode)")
	investigateAccountCmd.Flags().Int("definiti
ons-limit", 25, "Cap definition lookups in s
ingle-pair mode")
	investigateAccountCmd.Flags().String("pairs
-file", "", "CSV of labelled pairs for batch
 aggregate analysis")
	investigateAccountCmd.Flags().String("outpu
t", "", "Optional path to write the curated 
JSON report (batch mode)")
}
 
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET