package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"
)

// ---------------------------------------------------------------------------
// Library index
// ---------------------------------------------------------------------------

const archiveDirName = "_archive"

// clipEntry is one recording in the library index.
type clipEntry struct {
	Path      string    `json:"path"` // relative to the library root
	AbsPath   string    `json:"abs_path"`
	SizeBytes int64     `json:"size_bytes"`
	SizeHuman string    `json:"size_human"`
	SHA256    string    `json:"sha256"`
	ModTime   time.Time `json:"mod_time"`
	Title     string    `json:"title"` // redacted
	Command   []string  `json:"command"`
	// HeaderRedacted counts header strings that held a credential and were
	// masked before being stored in this index.
	HeaderRedacted int     `json:"header_fields_redacted"`
	StartedAt      string  `json:"started_at"`
	Width          int     `json:"width"`
	Height         int     `json:"height"`
	Events         int     `json:"events"`
	Duration       float64 `json:"duration_seconds"`
	StdoutBytes    int64   `json:"stdout_bytes"`
	StderrBytes    int64   `json:"stderr_bytes"`
	TextBytes      int64   `json:"text_bytes"`
	ExitCode       *int    `json:"exit_code"`
	Complete       bool    `json:"complete"`
	Error          string  `json:"error,omitempty"`
}

// recordedAt returns the clip's timestamp and where it came from. The header's
// started_at is authoritative; file mtime is the fallback and is labelled as
// such, because a copied file has a new mtime and an old recording.
func (c clipEntry) recordedAt() (time.Time, string) {
	if c.StartedAt != "" {
		if t, err := time.Parse(time.RFC3339, c.StartedAt); err == nil {
			return t.UTC(), "header.started_at"
		}
	}
	return c.ModTime.UTC(), "file.mtime"
}

type libraryIndex struct {
	Tool         string      `json:"tool"`
	Version      string      `json:"version"`
	IndexVersion int         `json:"index_version"`
	Library      string      `json:"library"`
	GeneratedAt  string      `json:"generated_at"`
	Clips        []clipEntry `json:"clips"`
	TotalBytes   int64       `json:"total_bytes"`
	TotalText    int64       `json:"total_text_bytes"`
	Unreadable   int         `json:"unreadable"`
}

// collectCasts walks a library directory and returns every cast file path,
// sorted. The archive directory is skipped: archived clips are out of scope
// for scanning and would otherwise be re-reported forever.
func collectCasts(root string) ([]string, error) {
	info, err := os.Stat(root)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("library %s does not exist", root)
		}
		return nil, fmt.Errorf("cannot read library %s: %w", root, err)
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("library %s is not a directory", root)
	}
	var out []string
	err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil // unreadable entries are skipped, never fatal
		}
		if d.IsDir() {
			if p != root && (d.Name() == archiveDirName || strings.HasPrefix(d.Name(), ".")) {
				return filepath.SkipDir
			}
			return nil
		}
		if !d.Type().IsRegular() {
			return nil
		}
		switch strings.ToLower(filepath.Ext(d.Name())) {
		case ".jsonl", ".cast":
			out = append(out, p)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Strings(out)
	return out, nil
}

func hashFile(path string) (string, int64, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0, err
	}
	defer f.Close()
	h := sha256.New()
	n, err := io.Copy(h, f)
	if err != nil {
		return "", 0, err
	}
	return hex.EncodeToString(h.Sum(nil)), n, nil
}

// buildIndex parses every cast in the library. A cast that fails to parse is
// still indexed, with its error recorded, so `list` shows the whole library
// rather than silently hiding the broken half of it.
func buildIndex(root string) (*libraryIndex, error) {
	abs, err := filepath.Abs(root)
	if err != nil {
		return nil, err
	}
	files, err := collectCasts(abs)
	if err != nil {
		return nil, err
	}
	idx := &libraryIndex{
		Tool:         appName,
		Version:      appVersion,
		IndexVersion: indexVersion,
		Library:      abs,
		GeneratedAt:  nowUTC().Format(rfc3339),
		Clips:        []clipEntry{},
	}
	for _, p := range files {
		rel, err := filepath.Rel(abs, p)
		if err != nil {
			rel = filepath.Base(p)
		}
		e := clipEntry{Path: filepath.ToSlash(rel), AbsPath: p, Command: []string{}}
		if st, err := os.Stat(p); err == nil {
			e.SizeBytes = st.Size()
			e.SizeHuman = humanBytes(st.Size())
			e.ModTime = st.ModTime().UTC()
		}
		if sum, _, err := hashFile(p); err == nil {
			e.SHA256 = sum
		}
		c, err := loadCast(p)
		if err != nil {
			e.Error = err.Error()
			idx.Unreadable++
			idx.Clips = append(idx.Clips, e)
			idx.TotalBytes += e.SizeBytes
			continue
		}
		outB, errB := c.byteCounts()
		// The index is a shareable artefact, so the header strings it stores
		// are redacted here, not on the way out.
		e.Title = maskHeaderString(c.Header.Title)
		if e.Title != c.Header.Title {
			e.HeaderRedacted++
		}
		if c.Header.Command != nil {
			cmdMasked, n := maskHeaderStrings(c.Header.Command)
			e.Command = cmdMasked
			e.HeaderRedacted += n
		}
		e.StartedAt = c.Header.StartedAt
		e.Width = c.Header.Width
		e.Height = c.Header.Height
		e.Events = len(c.Events)
		e.Duration = c.duration()
		e.StdoutBytes = outB
		e.StderrBytes = errB
		e.TextBytes = outB + errB
		e.Complete = c.Footer != nil
		if c.Footer != nil {
			code := c.Footer.ExitCode
			e.ExitCode = &code
		}
		idx.Clips = append(idx.Clips, e)
		idx.TotalBytes += e.SizeBytes
		idx.TotalText += e.TextBytes
	}
	sort.SliceStable(idx.Clips, func(i, j int) bool { return idx.Clips[i].Path < idx.Clips[j].Path })
	return idx, nil
}

func loadIndex(path string) (*libraryIndex, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("no index at %s - run: %s index --lib <dir> --index %s", path, appName, path)
		}
		return nil, fmt.Errorf("cannot read index %s: %w", path, err)
	}
	var idx libraryIndex
	if err := json.Unmarshal(data, &idx); err != nil {
		return nil, fmt.Errorf("index %s is not valid JSON: %w", path, err)
	}
	if idx.IndexVersion != indexVersion {
		return nil, fmt.Errorf("index %s has version %d (this build writes version %d) - rebuild it",
			path, idx.IndexVersion, indexVersion)
	}
	return &idx, nil
}

// resolveIndex is the shared "--index or --lib" resolution for list and search.
func resolveIndex(indexPath, lib string) *libraryIndex {
	switch {
	case indexPath != "" && lib != "":
		usageErr("--index and --lib are mutually exclusive")
	case indexPath != "":
		idx, err := loadIndex(indexPath)
		if err != nil {
			fail("%v", err)
		}
		return idx
	case lib != "":
		idx, err := buildIndex(lib)
		if err != nil {
			fail("%v", err)
		}
		return idx
	}
	usageErr("needs --index <file.json> or --lib <dir>")
	return nil
}

// ---------------------------------------------------------------------------
// index
// ---------------------------------------------------------------------------

func cmdIndex(argv []string) {
	fs := newFlagSet("index")
	lib := fs.String("lib", "", "clip library directory")
	fs.StringVar(lib, "l", "", "shorthand for --lib")
	indexPath := fs.String("index", "", "write the index to this file")
	fs.StringVar(indexPath, "i", "", "shorthand for --index")
	asJSON := fs.Bool("json", false, "machine-readable JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *lib == "" && fs.NArg() > 0 {
		*lib = fs.Arg(0)
	}
	if *lib == "" {
		usageErr("index needs --lib <dir>")
	}
	idx, err := buildIndex(*lib)
	if err != nil {
		fail("%v", err)
	}
	if *indexPath != "" {
		if err := writeIndex(*indexPath, idx); err != nil {
			fail("%v", err)
		}
	}
	if *asJSON {
		emitJSON(idx)
		return
	}
	fmt.Printf("ClipStudio library index\n")
	fmt.Printf("library    : %s\n", idx.Library)
	fmt.Printf("clips      : %d\n", len(idx.Clips))
	fmt.Printf("on disk    : %s\n", humanBytes(idx.TotalBytes))
	fmt.Printf("transcript : %s of reconstructed text\n", humanBytes(idx.TotalText))
	if idx.Unreadable > 0 {
		fmt.Printf("unreadable : %d (listed below with an error)\n", idx.Unreadable)
	}
	if *indexPath != "" {
		fmt.Printf("written to : %s\n", *indexPath)
	}
	fmt.Println()
	printClipTable(idx)
}

func writeIndex(path string, idx *libraryIndex) error {
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	data, err := json.MarshalIndent(idx, "", "  ")
	if err != nil {
		return fmt.Errorf("cannot encode index: %w", err)
	}
	tmp, err := os.CreateTemp(filepath.Dir(path), ".clipstudio-idx-*.tmp")
	if err != nil {
		return fmt.Errorf("cannot create temporary file: %w", err)
	}
	name := tmp.Name()
	if _, err := tmp.Write(append(data, '\n')); err != nil {
		tmp.Close()
		os.Remove(name)
		return fmt.Errorf("cannot write index: %w", err)
	}
	if err := tmp.Close(); err != nil {
		os.Remove(name)
		return err
	}
	if err := os.Rename(name, path); err != nil {
		os.Remove(name)
		return fmt.Errorf("cannot write %s: %w", path, err)
	}
	return nil
}

func printClipTable(idx *libraryIndex) {
	if len(idx.Clips) == 0 {
		fmt.Println("(no cast files found - ClipStudio indexes *.jsonl and *.cast)")
		return
	}
	fmt.Printf("%-34s %10s %9s %7s  %s\n", "CLIP", "SIZE", "DURATION", "EVENTS", "TITLE / RECORDED")
	for _, c := range idx.Clips {
		if c.Error != "" {
			fmt.Printf("%-34s %10s %9s %7s  ERROR: %s\n", trunc(c.Path, 34), humanBytes(c.SizeBytes), "-", "-", c.Error)
			continue
		}
		when, _ := c.recordedAt()
		title := c.Title
		if title == "" {
			title = "(untitled)"
		}
		fmt.Printf("%-34s %10s %8.2fs %7d  %s  %s\n",
			trunc(c.Path, 34), humanBytes(c.SizeBytes), c.Duration, c.Events,
			trunc(title, 28), when.Format(rfc3339))
	}
}

func trunc(s string, n int) string {
	if len(s) <= n {
		return s
	}
	if n <= 3 {
		return s[:n]
	}
	return s[:n-3] + "..."
}

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

func cmdList(argv []string) {
	fs := newFlagSet("list")
	lib := fs.String("lib", "", "clip library directory")
	fs.StringVar(lib, "l", "", "shorthand for --lib")
	indexPath := fs.String("index", "", "read this index file")
	fs.StringVar(indexPath, "i", "", "shorthand for --index")
	asJSON := fs.Bool("json", false, "machine-readable JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *indexPath == "" && *lib == "" && fs.NArg() > 0 {
		*indexPath = fs.Arg(0)
	}
	idx := resolveIndex(*indexPath, *lib)
	if *asJSON {
		emitJSON(idx)
		return
	}
	fmt.Printf("library : %s\n", idx.Library)
	fmt.Printf("indexed : %s\n", idx.GeneratedAt)
	fmt.Printf("clips   : %d (%s on disk)\n\n", len(idx.Clips), humanBytes(idx.TotalBytes))
	printClipTable(idx)
}

// ---------------------------------------------------------------------------
// search
// ---------------------------------------------------------------------------

type searchHit struct {
	Line    int     `json:"line"`
	Time    float64 `json:"time"`
	Stream  string  `json:"stream"`
	Event   int     `json:"event_index"`
	Excerpt string  `json:"excerpt"`
}

type searchClipResult struct {
	Path         string      `json:"path"`
	Title        string      `json:"title,omitempty"`
	SHA256       string      `json:"sha256"`
	TitleMatch   bool        `json:"title_match"`
	CommandMatch bool        `json:"command_match"`
	NameMatch    bool        `json:"name_match"`
	TextMatches  int         `json:"text_matches"`
	Hits         []searchHit `json:"hits"`
	Redacted     int         `json:"redacted_regions"`
	Error        string      `json:"error,omitempty"`
}

type searchReport struct {
	Tool          string             `json:"tool"`
	Version       string             `json:"version"`
	GeneratedAt   string             `json:"generated_at"`
	Library       string             `json:"library"`
	Term          string             `json:"term"`
	Regexp        bool               `json:"regexp"`
	ClipsSearched int                `json:"clips_searched"`
	ClipsMatched  int                `json:"clips_matched"`
	TotalMatches  int                `json:"total_matches"`
	Results       []searchClipResult `json:"results"`
	Note          string             `json:"note"`
}

const maxHitsPerClip = 8

const searchNote = "Search runs over the REDACTED transcript: detected credentials are " +
	"masked before matching, so a secret can never be recovered through search."

func cmdSearch(argv []string) {
	fs := newFlagSet("search")
	lib := fs.String("lib", "", "clip library directory")
	fs.StringVar(lib, "l", "", "shorthand for --lib")
	indexPath := fs.String("index", "", "read this index file")
	fs.StringVar(indexPath, "i", "", "shorthand for --index")
	useRegexp := fs.Bool("regexp", false, "treat the term as a regular expression")
	asJSON := fs.Bool("json", false, "machine-readable JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) == 0 {
		usageErr("search needs a <term>")
	}
	if len(rest) > 1 {
		usageErr("search takes exactly one <term> (quote it if it contains spaces)")
	}
	term := rest[0]
	if term == "" {
		usageErr("search term must not be empty")
	}
	idx := resolveIndex(*indexPath, *lib)

	var re *regexp.Regexp
	if *useRegexp {
		var err error
		re, err = regexp.Compile(term)
		if err != nil {
			usageErr("invalid regular expression %q: %v", term, err)
		}
	}
	lowTerm := strings.ToLower(term)

	// The term is echoed back in the report, so run it through the detectors
	// too: if you paste a credential into the search box, ClipStudio must not
	// print it back at you. Matching still uses the raw term.
	displayTerm := applyMask(term, maskPlan(len(term), scanText(term)))

	rep := searchReport{
		Tool: appName, Version: appVersion, GeneratedAt: nowUTC().Format(rfc3339),
		Library: idx.Library, Term: displayTerm, Regexp: *useRegexp,
		Results: []searchClipResult{}, Note: searchNote,
	}
	for _, ce := range idx.Clips {
		r := searchClipResult{Path: ce.Path, Title: ce.Title, SHA256: ce.SHA256, Hits: []searchHit{}}
		rep.ClipsSearched++
		r.NameMatch = matchString(ce.Path, lowTerm, re)
		r.TitleMatch = matchString(ce.Title, lowTerm, re)
		r.CommandMatch = matchString(strings.Join(ce.Command, " "), lowTerm, re)

		if ce.Error == "" {
			c, err := loadCast(ce.AbsPath)
			if err != nil {
				r.Error = err.Error()
			} else {
				a := analyze(c)
				r.Redacted = len(a.findings)
				// Match against the MASKED transcript, never the raw one.
				for _, loc := range findMatches(a.maskedText, lowTerm, re) {
					r.TextMatches++
					if len(r.Hits) >= maxHitsPerClip {
						continue
					}
					h := searchHit{
						Line:    a.ts.lineOf(loc[0]),
						Event:   -1,
						Excerpt: excerptAround(a.maskedText, loc[0], loc[1]),
					}
					if si := a.ts.segmentAt(loc[0]); si >= 0 {
						h.Time = a.ts.segs[si].time
						h.Stream = a.ts.segs[si].stream
						h.Event = a.ts.segs[si].eventIdx
					}
					r.Hits = append(r.Hits, h)
				}
			}
		} else {
			r.Error = ce.Error
		}
		if r.TextMatches == 0 && !r.NameMatch && !r.TitleMatch && !r.CommandMatch {
			continue
		}
		rep.ClipsMatched++
		rep.TotalMatches += r.TextMatches
		rep.Results = append(rep.Results, r)
	}
	sort.SliceStable(rep.Results, func(i, j int) bool { return rep.Results[i].Path < rep.Results[j].Path })

	if *asJSON {
		emitJSON(rep)
		return
	}
	fmt.Printf("ClipStudio search\n")
	fmt.Printf("library : %s\n", rep.Library)
	fmt.Printf("term    : %q%s\n", rep.Term, map[bool]string{true: " (regexp)", false: ""}[rep.Regexp])
	fmt.Printf("matched : %d of %d clips, %d matches in transcript text\n\n",
		rep.ClipsMatched, rep.ClipsSearched, rep.TotalMatches)
	if len(rep.Results) == 0 {
		fmt.Println("(no matches)")
		return
	}
	for _, r := range rep.Results {
		fmt.Printf("%s\n", r.Path)
		if r.Title != "" {
			fmt.Printf("  title  : %s\n", r.Title)
		}
		var where []string
		if r.NameMatch {
			where = append(where, "filename")
		}
		if r.TitleMatch {
			where = append(where, "title")
		}
		if r.CommandMatch {
			where = append(where, "command")
		}
		if len(where) > 0 {
			fmt.Printf("  matched: %s\n", strings.Join(where, ", "))
		}
		if r.Error != "" {
			fmt.Printf("  ERROR  : %s\n", r.Error)
		}
		if r.TextMatches > 0 {
			fmt.Printf("  text   : %d matches", r.TextMatches)
			if r.TextMatches > len(r.Hits) {
				fmt.Printf(" (first %d shown)", len(r.Hits))
			}
			fmt.Println()
			for _, h := range r.Hits {
				fmt.Printf("    %8.3fs  %s  line %d: %s\n", h.Time, h.Stream, h.Line, h.Excerpt)
			}
		}
		fmt.Println()
	}
	fmt.Println(searchNote)
}

func matchString(hay, lowTerm string, re *regexp.Regexp) bool {
	if hay == "" {
		return false
	}
	if re != nil {
		return re.MatchString(hay)
	}
	return strings.Contains(strings.ToLower(hay), lowTerm)
}

// findMatches returns non-overlapping [start,end) match ranges.
func findMatches(hay, lowTerm string, re *regexp.Regexp) [][]int {
	if re != nil {
		return re.FindAllStringIndex(hay, -1)
	}
	if lowTerm == "" {
		return nil
	}
	low := strings.ToLower(hay)
	var out [][]int
	from := 0
	for {
		i := strings.Index(low[from:], lowTerm)
		if i < 0 {
			return out
		}
		s := from + i
		out = append(out, []int{s, s + len(lowTerm)})
		from = s + len(lowTerm)
	}
}
