package main

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// ---------------------------------------------------------------------------
// Finding: what the scanner reports. Note what is NOT in here: the secret.
// ---------------------------------------------------------------------------

// Finding is one detected credential in one clip.
type Finding struct {
	Detector    string  `json:"detector"`
	Severity    string  `json:"severity"`
	Description string  `json:"description"`
	Location    string  `json:"location"` // "stream" or "header.title" / "header.command[2]"
	Stream      string  `json:"stream,omitempty"`
	EventIndex  int     `json:"event_index"`
	Time        float64 `json:"time"`
	Line        int     `json:"line"`
	Offset      int     `json:"offset"`
	Length      int     `json:"length"`
	Entropy     float64 `json:"entropy"`
	Masked      string  `json:"masked"`
	Fingerprint string  `json:"fingerprint"`
	Excerpt     string  `json:"excerpt"`
}

// analysis is everything ClipStudio learns about one cast in a single pass.
type analysis struct {
	cast       *cast
	ts         *textStream
	streamPlan []bool            // mask plan over the reconstructed text
	hdrPlan    map[string][]bool // mask plan per header field
	maskedText string
	findings   []Finding
}

// headerFields enumerates the header strings that can carry a credential. A
// command line is a very common place to find one: `-- deploy.sh --token=X`.
func headerFields(h castHeader) []struct {
	name string
	text string
} {
	var out []struct {
		name string
		text string
	}
	for i, a := range h.Command {
		out = append(out, struct {
			name string
			text string
		}{fmt.Sprintf("header.command[%d]", i), a})
	}
	out = append(out, struct {
		name string
		text string
	}{"header.title", h.Title})
	return out
}

// analyze scans the reconstructed text stream and the header, then builds the
// masked view. Every excerpt in the report is cut from the MASKED text, so no
// output path can leak a secret even by accident of context.
func analyze(c *cast) *analysis {
	a := &analysis{cast: c, ts: buildTextStream(c), hdrPlan: map[string][]bool{}}

	streamSpans := scanText(a.ts.text)
	a.streamPlan = maskPlan(len(a.ts.text), streamSpans)
	a.maskedText = applyMask(a.ts.text, a.streamPlan)

	// Header findings come first: they describe the recording itself.
	for _, f := range headerFields(c.Header) {
		if f.text == "" {
			continue
		}
		spans := scanText(f.text)
		if len(spans) == 0 {
			continue
		}
		plan := maskPlan(len(f.text), spans)
		a.hdrPlan[f.name] = plan
		masked := applyMask(f.text, plan)
		for _, s := range spans {
			a.findings = append(a.findings, Finding{
				Detector:    s.detector,
				Severity:    detectorSeverity(s.detector),
				Description: detectorDesc(s.detector),
				Location:    f.name,
				EventIndex:  -1,
				Time:        0,
				Line:        1,
				Offset:      s.start,
				Length:      s.length(),
				Entropy:     round3(s.entropy),
				Masked:      maskedRender(f.text[s.start:s.end], s.keep),
				Fingerprint: fingerprint(f.text[s.start:s.end]),
				Excerpt:     excerptAround(masked, s.start, s.end),
			})
		}
	}

	for _, s := range streamSpans {
		f := Finding{
			Detector:    s.detector,
			Severity:    detectorSeverity(s.detector),
			Description: detectorDesc(s.detector),
			Location:    "stream",
			EventIndex:  -1,
			Line:        a.ts.lineOf(s.start),
			Offset:      s.start,
			Length:      s.length(),
			Entropy:     round3(s.entropy),
			Masked:      maskedRender(a.ts.text[s.start:s.end], s.keep),
			Fingerprint: fingerprint(a.ts.text[s.start:s.end]),
			Excerpt:     excerptAround(a.maskedText, s.start, s.end),
		}
		if si := a.ts.segmentAt(s.start); si >= 0 {
			f.EventIndex = a.ts.segs[si].eventIdx
			f.Time = a.ts.segs[si].time
			f.Stream = a.ts.segs[si].stream
		}
		a.findings = append(a.findings, f)
	}
	return a
}

func round3(f float64) float64 {
	return float64(int64(f*1000+0.5)) / 1000
}

// excerptAround cuts a readable window out of ALREADY-MASKED text: some
// context either side of the finding, with the middle of a long finding
// elided so a 4KB PEM block does not become 4KB of asterisks.
func excerptAround(masked string, start, end int) string {
	lineStart := strings.LastIndexByte(masked[:start], '\n') + 1
	lineEnd := end
	if i := strings.IndexByte(masked[end:], '\n'); i >= 0 {
		lineEnd = end + i
	} else {
		lineEnd = len(masked)
	}
	const pad = 48
	const maxBody = 64
	from, to := lineStart, lineEnd
	prefix, suffix := "", ""
	if start-from > pad {
		from = start - pad
		prefix = "..."
	}
	if to-end > pad {
		to = end + pad
		suffix = "..."
	}
	body := masked[start:end]
	if len(body) > maxBody {
		body = body[:maxBody/2] + fmt.Sprintf("<%d more bytes>", len(body)-maxBody) + body[len(body)-maxBody/2:]
	}
	return prefix + sanitize(masked[from:start]) + sanitize(body) + sanitize(masked[end:to]) + suffix
}

// sanitize makes arbitrary recorded bytes safe to print on a terminal: escape
// sequences a clip recorded must not be re-interpreted by the reader's shell.
func sanitize(s string) string {
	var b strings.Builder
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case c == '\n':
			b.WriteString("\\n")
		case c == '\r':
			b.WriteString("\\r")
		case c == '\t':
			b.WriteString("\\t")
		case c == 0x1b:
			b.WriteString("\\e")
		case c < 0x20 || c == 0x7f:
			fmt.Fprintf(&b, "\\x%02x", c)
		default:
			b.WriteByte(c)
		}
	}
	return b.String()
}

// ---------------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------------

type clipScan struct {
	Path      string    `json:"path"`
	SHA256    string    `json:"sha256"`
	Title     string    `json:"title,omitempty"`
	TextBytes int64     `json:"text_bytes"`
	Findings  []Finding `json:"findings"`
	Error     string    `json:"error,omitempty"`
}

type scanReport struct {
	Tool          string         `json:"tool"`
	Version       string         `json:"version"`
	GeneratedAt   string         `json:"generated_at"`
	Library       string         `json:"library,omitempty"`
	ClipsScanned  int            `json:"clips_scanned"`
	ClipsAffected int            `json:"clips_affected"`
	TextBytes     int64          `json:"text_bytes"`
	Findings      int            `json:"findings"`
	ByDetector    map[string]int `json:"findings_by_detector"`
	BySeverity    map[string]int `json:"findings_by_severity"`
	Detectors     []detectorInfo `json:"detectors"`
	Clips         []clipScan     `json:"clips"`
	Errors        []string       `json:"errors,omitempty"`
}

func cmdScan(argv []string) {
	fs := newFlagSet("scan")
	lib := fs.String("lib", "", "clip library directory to scan")
	fs.StringVar(lib, "l", "", "shorthand for --lib")
	asJSON := fs.Bool("json", false, "machine-readable JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	files := fs.Args()
	if *lib != "" && len(files) > 0 {
		usageErr("scan takes either --lib <dir> or explicit cast files, not both")
	}
	var err error
	if *lib != "" {
		files, err = collectCasts(*lib)
		if err != nil {
			fail("%v", err)
		}
	}
	if len(files) == 0 {
		if *lib != "" {
			fail("no cast files (*.jsonl, *.cast) found under %s", *lib)
		}
		usageErr("scan needs one or more <cast.jsonl> files, or --lib <dir>")
	}
	sort.Strings(files)

	rep := scanReport{
		Tool:        appName,
		Version:     appVersion,
		GeneratedAt: nowUTC().Format(rfc3339),
		Library:     *lib,
		ByDetector:  map[string]int{},
		BySeverity:  map[string]int{},
		Detectors:   detectorCatalog,
		Clips:       []clipScan{},
	}
	for _, p := range files {
		cs := clipScan{Path: p, Findings: []Finding{}}
		sum, _, sErr := hashFile(p)
		if sErr == nil {
			cs.SHA256 = sum
		}
		c, err := loadCast(p)
		if err != nil {
			cs.Error = err.Error()
			rep.Errors = append(rep.Errors, err.Error())
			rep.Clips = append(rep.Clips, cs)
			continue
		}
		a := analyze(c)
		cs.Title = maskHeaderString(c.Header.Title)
		cs.TextBytes = int64(len(a.ts.text))
		cs.Findings = a.findings
		rep.ClipsScanned++
		rep.TextBytes += cs.TextBytes
		if len(a.findings) > 0 {
			rep.ClipsAffected++
		}
		for _, f := range a.findings {
			rep.Findings++
			rep.ByDetector[f.Detector]++
			rep.BySeverity[f.Severity]++
		}
		rep.Clips = append(rep.Clips, cs)
	}

	if *asJSON {
		emitJSON(rep)
		return
	}

	fmt.Printf("ClipStudio secret scan\n")
	if rep.Library != "" {
		fmt.Printf("library  : %s\n", rep.Library)
	}
	fmt.Printf("scanned  : %d clips, %s of reconstructed text\n", rep.ClipsScanned, humanBytes(rep.TextBytes))
	fmt.Printf("findings : %d in %d clips\n", rep.Findings, rep.ClipsAffected)
	fmt.Println()
	for _, cs := range rep.Clips {
		if cs.Error != "" {
			fmt.Printf("%s\n  ERROR: %s\n\n", cs.Path, cs.Error)
			continue
		}
		if len(cs.Findings) == 0 {
			fmt.Printf("%s\n  clean (%s of text)\n\n", cs.Path, humanBytes(cs.TextBytes))
			continue
		}
		fmt.Printf("%s\n", cs.Path)
		fmt.Printf("  sha256   : %s\n", cs.SHA256)
		fmt.Printf("  findings : %d\n", len(cs.Findings))
		for i, f := range cs.Findings {
			where := fmt.Sprintf("at %.3fs (event %d, stream %s, line %d)", f.Time, f.EventIndex, f.Stream, f.Line)
			if f.Location != "stream" {
				where = "in " + f.Location
			}
			fmt.Printf("  [%d] %-26s %s\n", i+1, f.Detector, where)
			fmt.Printf("      severity : %s\n", f.Severity)
			fmt.Printf("      entropy  : %.3f bits/char over %d bytes\n", f.Entropy, f.Length)
			fmt.Printf("      masked   : %s\n", f.Masked)
			fmt.Printf("      finger   : %s\n", f.Fingerprint)
			fmt.Printf("      excerpt  : %s\n", f.Excerpt)
		}
		fmt.Println()
	}
	if rep.Findings > 0 {
		fmt.Printf("Detector totals:\n")
		for _, d := range detectorCatalog {
			if n := rep.ByDetector[d.Name]; n > 0 {
				fmt.Printf("  %-26s %d\n", d.Name, n)
			}
		}
		fmt.Println()
		fmt.Printf("Nothing above is the credential itself: every value is masked and\n")
		fmt.Printf("fingerprinted. Produce a shareable copy with:  %s redact <clip> --out <clip>\n", appName)
	}
}

// ---------------------------------------------------------------------------
// redact
// ---------------------------------------------------------------------------

type redactResult struct {
	Input           string         `json:"input"`
	Output          string         `json:"output"`
	InputSHA256     string         `json:"input_sha256"`
	OutputSHA256    string         `json:"output_sha256"`
	Findings        int            `json:"findings_redacted"`
	BytesMasked     int            `json:"bytes_masked"`
	Events          int            `json:"events"`
	TimingPreserved bool           `json:"timing_preserved"`
	RescanFindings  int            `json:"rescan_findings"`
	Clean           bool           `json:"clean"`
	ByDetector      map[string]int `json:"redacted_by_detector"`
	Error           string         `json:"error,omitempty"`
}

type redactReport struct {
	Tool        string         `json:"tool"`
	Version     string         `json:"version"`
	GeneratedAt string         `json:"generated_at"`
	Files       []redactResult `json:"files"`
	Failed      int            `json:"failed"`
}

func cmdRedact(argv []string) {
	fs := newFlagSet("redact")
	lib := fs.String("lib", "", "clip library directory to redact wholesale")
	fs.StringVar(lib, "l", "", "shorthand for --lib")
	out := fs.String("out", "", "output cast file (single input only)")
	fs.StringVar(out, "o", "", "shorthand for --out")
	outDir := fs.String("out-dir", "", "output directory (with --lib or several inputs)")
	force := fs.Bool("force", false, "overwrite an existing output file")
	asJSON := fs.Bool("json", false, "machine-readable JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	files := fs.Args()
	if *lib != "" && len(files) > 0 {
		usageErr("redact takes either --lib <dir> or explicit cast files, not both")
	}
	var err error
	root := *lib
	if *lib != "" {
		files, err = collectCasts(*lib)
		if err != nil {
			fail("%v", err)
		}
		if len(files) == 0 {
			fail("no cast files (*.jsonl, *.cast) found under %s", *lib)
		}
	}
	if len(files) == 0 {
		usageErr("redact needs <cast.jsonl> --out <file>, or --lib <dir> --out-dir <dir>")
	}
	sort.Strings(files)
	if len(files) > 1 && *out != "" {
		usageErr("--out names a single file; use --out-dir for %d inputs", len(files))
	}
	if *out == "" && *outDir == "" {
		usageErr("redact needs --out <file.jsonl> or --out-dir <dir>")
	}
	if *out != "" && *outDir != "" {
		usageErr("--out and --out-dir are mutually exclusive")
	}

	rep := redactReport{Tool: appName, Version: appVersion, GeneratedAt: nowUTC().Format(rfc3339)}
	for _, in := range files {
		dst := *out
		if dst == "" {
			rel := filepath.Base(in)
			if root != "" {
				if r, err := filepath.Rel(root, in); err == nil {
					rel = r
				}
			}
			dst = filepath.Join(*outDir, rel)
		}
		res := redactOne(in, dst, *force)
		if res.Error != "" {
			rep.Failed++
		}
		rep.Files = append(rep.Files, res)
	}

	if *asJSON {
		emitJSON(rep)
		if rep.Failed > 0 {
			os.Exit(1)
		}
		return
	}

	fmt.Printf("ClipStudio redaction\n\n")
	for _, r := range rep.Files {
		fmt.Printf("%s\n", r.Input)
		if r.Error != "" {
			fmt.Printf("  ERROR: %s\n\n", r.Error)
			continue
		}
		fmt.Printf("  output          : %s\n", r.Output)
		fmt.Printf("  input sha256    : %s\n", r.InputSHA256)
		fmt.Printf("  output sha256   : %s\n", r.OutputSHA256)
		fmt.Printf("  findings masked : %d (%d bytes replaced)\n", r.Findings, r.BytesMasked)
		for _, d := range detectorCatalog {
			if n := r.ByDetector[d.Name]; n > 0 {
				fmt.Printf("                    %-26s %d\n", d.Name, n)
			}
		}
		if r.TimingPreserved {
			fmt.Printf("  timing          : preserved exactly (%d events, identical timestamps)\n", r.Events)
		} else {
			fmt.Printf("  timing          : CHANGED - this is a bug, do not ship this file\n")
		}
		if r.Clean {
			fmt.Printf("  re-scan         : clean (0 findings remain)\n")
		} else {
			fmt.Printf("  re-scan         : %d findings REMAIN - do not ship this file\n", r.RescanFindings)
		}
		fmt.Println()
	}
	fmt.Printf("The original files were opened read-only and are unchanged.\n")
	if rep.Failed > 0 {
		os.Exit(1)
	}
}

func redactOne(in, dst string, force bool) redactResult {
	res := redactResult{Input: in, Output: dst, ByDetector: map[string]int{}}

	inAbs, err := filepath.Abs(in)
	if err != nil {
		res.Error = err.Error()
		return res
	}
	dstAbs, err := filepath.Abs(dst)
	if err != nil {
		res.Error = err.Error()
		return res
	}
	if inAbs == dstAbs {
		res.Error = "refusing to write the redacted copy over the original"
		return res
	}
	if !force {
		if _, err := os.Stat(dstAbs); err == nil {
			res.Error = fmt.Sprintf("%s already exists (pass --force to overwrite)", dst)
			return res
		}
	}

	sum, _, err := hashFile(in)
	if err != nil {
		res.Error = err.Error()
		return res
	}
	res.InputSHA256 = sum

	c, err := loadCast(in)
	if err != nil {
		res.Error = err.Error()
		return res
	}
	a := analyze(c)
	res.Findings = len(a.findings)
	for _, f := range a.findings {
		res.ByDetector[f.Detector]++
	}
	for _, m := range a.streamPlan {
		if m {
			res.BytesMasked++
		}
	}
	for _, plan := range a.hdrPlan {
		for _, m := range plan {
			if m {
				res.BytesMasked++
			}
		}
	}

	if err := writeRedacted(dstAbs, c, a); err != nil {
		res.Error = err.Error()
		return res
	}

	// Verify what we just wrote rather than assuming it.
	outCast, err := loadCast(dstAbs)
	if err != nil {
		res.Error = "redacted file failed to reload: " + err.Error()
		return res
	}
	res.Events = len(outCast.Events)
	res.TimingPreserved = timingIdentical(c, outCast)
	rescan := analyze(outCast)
	res.RescanFindings = len(rescan.findings)
	res.Clean = res.RescanFindings == 0
	if sum, _, err := hashFile(dstAbs); err == nil {
		res.OutputSHA256 = sum
	}
	if !res.TimingPreserved {
		res.Error = "event timing was not preserved"
	} else if !res.Clean {
		res.Error = fmt.Sprintf("%d findings remain after redaction", res.RescanFindings)
	}
	return res
}

// timingIdentical compares the timestamp TOKENS, not parsed floats, so a
// difference of one ulp cannot hide behind float equality.
func timingIdentical(a, b *cast) bool {
	if len(a.Events) != len(b.Events) {
		return false
	}
	for i := range a.Events {
		if a.Events[i].RawTime != b.Events[i].RawTime {
			return false
		}
		if a.Events[i].Stream != b.Events[i].Stream {
			return false
		}
		if len(a.Events[i].Data) != len(b.Events[i].Data) {
			return false
		}
	}
	if (a.Footer == nil) != (b.Footer == nil) {
		return false
	}
	if a.Footer != nil && *a.Footer != *b.Footer {
		return false
	}
	return true
}

// writeRedacted emits the redacted cast atomically: temp file, then rename.
// The header and footer lines are copied verbatim unless the header itself
// held a finding; event timestamps are always copied verbatim.
func writeRedacted(dst string, c *cast, a *analysis) error {
	if dir := filepath.Dir(dst); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	tmp, err := os.CreateTemp(filepath.Dir(dst), ".clipstudio-*.tmp")
	if err != nil {
		return fmt.Errorf("cannot create temporary file: %w", err)
	}
	tmpName := tmp.Name()
	defer func() {
		tmp.Close()
		os.Remove(tmpName)
	}()

	hdrLine := c.HeaderRaw
	if len(a.hdrPlan) > 0 {
		h := c.Header
		for i := range h.Command {
			if plan, ok := a.hdrPlan[fmt.Sprintf("header.command[%d]", i)]; ok {
				h.Command[i] = applyMask(h.Command[i], plan)
			}
		}
		if plan, ok := a.hdrPlan["header.title"]; ok {
			h.Title = applyMask(h.Title, plan)
		}
		enc, err := json.Marshal(h)
		if err != nil {
			return fmt.Errorf("cannot encode redacted header: %w", err)
		}
		hdrLine = enc
	}
	if _, err := tmp.Write(append(hdrLine, '\n')); err != nil {
		return err
	}

	// Map each event's payload back onto the mask plan through its segment.
	base := make(map[int]int, len(a.ts.segs))
	for _, s := range a.ts.segs {
		base[s.eventIdx] = s.start
	}
	for i, ev := range c.Events {
		data := append([]byte(nil), ev.Data...)
		if off, ok := base[i]; ok {
			for j := range data {
				if p := off + j; p < len(a.streamPlan) && a.streamPlan[p] {
					data[j] = maskChar
				}
			}
		}
		line := encodeEventLine(ev.RawTime, ev.Stream, data, ev.B64)
		if _, err := tmp.Write(append(line, '\n')); err != nil {
			return err
		}
	}
	if c.FooterRaw != nil {
		if _, err := tmp.Write(append(c.FooterRaw, '\n')); err != nil {
			return err
		}
	}
	if err := tmp.Sync(); err != nil {
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Rename(tmpName, dst); err != nil {
		return fmt.Errorf("cannot write %s: %w", dst, err)
	}
	return nil
}
