package tools

import (
	"bufio"
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os/exec"
	"strings"
	"sync"
	"time"
)

// InteractshSession is a long-lived interactsh-client that an oracle drives:
// reserve a unique OOB URL, deliver a payload carrying it, then observe whether
// a callback arrives. It differs from InteractshTool.Run (a one-shot listen
// window that returns the URL only after it ends) because out-of-band
// verification requires the listener armed and the URL in hand BEFORE the
// payload goes out.
//
// One session serves a whole campaign: it holds one base OOB host and mints a
// unique subdomain per Payload call, so many findings share the one listener
// and correlate independently by token. Its methods satisfy the OOBSession
// contract in internal/swarm/verify/oracle structurally (stdlib-only
// signatures), so tools need not import the oracle package.
type InteractshSession struct {
	server string
	token  string // auth token for a private server (optional)

	cmd    *exec.Cmd
	cancel context.CancelFunc
	base   string // assigned OOB host, e.g. c6a1….oast.fun

	mu     sync.Mutex
	raw    []string // raw interaction JSON lines, appended as they stream in
	closed bool
}

// SessionOption configures an InteractshSession.
type SessionOption func(*InteractshSession)

// WithServer points the session at a self-hosted interactsh server (OPSEC).
func WithServer(server string) SessionOption {
	return func(s *InteractshSession) { s.server = server }
}

// WithToken sets the auth token for a private interactsh server.
func WithToken(token string) SessionOption {
	return func(s *InteractshSession) { s.token = token }
}

// NewInteractshSession builds an unstarted session. Call Start before use.
func NewInteractshSession(opts ...SessionOption) *InteractshSession {
	s := &InteractshSession{}
	for _, o := range opts {
		o(s)
	}
	return s
}

// Available reports whether interactsh-client is on PATH.
func (s *InteractshSession) Available() bool { return IsCommandAvailable("interactsh-client") }

// Start launches interactsh-client, waits (bounded) for the assigned OOB host
// to appear on stderr, and begins streaming interactions off stdout into an
// internal buffer. The session runs until Close or the passed ctx is done.
func (s *InteractshSession) Start(ctx context.Context) error {
	if !s.Available() {
		return fmt.Errorf("interactsh-client not found on PATH")
	}

	runCtx, cancel := context.WithCancel(ctx)
	s.cancel = cancel

	args := []string{"-json", "-poll-interval", "1", "-no-color"}
	if s.server != "" {
		args = append(args, "-server", s.server)
	}
	if s.token != "" {
		args = append(args, "-token", s.token)
	}
	cmd := exec.CommandContext(runCtx, "interactsh-client", args...)

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		cancel()
		return fmt.Errorf("interactsh stdout pipe: %w", err)
	}
	stderr, err := cmd.StderrPipe()
	if err != nil {
		cancel()
		return fmt.Errorf("interactsh stderr pipe: %w", err)
	}
	if err := cmd.Start(); err != nil {
		cancel()
		return fmt.Errorf("start interactsh-client: %w", err)
	}
	s.cmd = cmd

	// Capture the base OOB host from the stderr banner, with a startup
	// deadline so a hung client doesn't block the campaign forever.
	baseCh := make(chan string, 1)
	go func() {
		sc := bufio.NewScanner(stderr)
		for sc.Scan() {
			if host := parseInteractshURL(sc.Text()); host != "" {
				select {
				case baseCh <- host:
				default:
				}
			}
		}
	}()

	// Stream interactions off stdout into the buffer for the session's life.
	go func() {
		sc := bufio.NewScanner(stdout)
		sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
		for sc.Scan() {
			line := strings.TrimSpace(sc.Text())
			if line == "" || !strings.HasPrefix(line, "{") {
				continue
			}
			if !strings.Contains(line, "full-id") && !strings.Contains(line, "unique-id") {
				continue // startup banner emitted as JSON, not an interaction
			}
			s.mu.Lock()
			s.raw = append(s.raw, line)
			s.mu.Unlock()
		}
	}()

	select {
	case s.base = <-baseCh:
		return nil
	case <-time.After(10 * time.Second):
		s.Close()
		return fmt.Errorf("interactsh-client did not report an OOB URL within 10s")
	case <-runCtx.Done():
		s.Close()
		return runCtx.Err()
	}
}

// Payload reserves a unique subdomain under the session's base host and returns
// it as an http URL plus the correlation token (the unique label). A callback
// to any protocol on that host is attributable to this token.
func (s *InteractshSession) Payload(ctx context.Context) (url, token string, err error) {
	if s.base == "" {
		return "", "", fmt.Errorf("interactsh session not started")
	}
	token = randLabel(10)
	return "http://" + token + "." + s.base, token, nil
}

// Observed blocks up to window for any buffered interaction whose raw record
// references token, returning a short proof receipt when one is found. Silence
// returns (false, "", nil) — absence of a callback is not disproof.
func (s *InteractshSession) Observed(ctx context.Context, token string, window time.Duration) (bool, string, error) {
	deadline := time.After(window)
	ticker := time.NewTicker(500 * time.Millisecond)
	defer ticker.Stop()
	for {
		if hit, proof := s.scan(token); hit {
			return true, proof, nil
		}
		select {
		case <-deadline:
			// One last scan to avoid losing a callback that landed in the gap.
			if hit, proof := s.scan(token); hit {
				return true, proof, nil
			}
			return false, "", nil
		case <-ctx.Done():
			return false, "", ctx.Err()
		case <-ticker.C:
		}
	}
}

// scan looks for the first buffered interaction mentioning token and renders a
// proof string from it.
func (s *InteractshSession) scan(token string) (bool, string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	for _, line := range s.raw {
		if !strings.Contains(line, token) {
			continue
		}
		var ix map[string]any
		_ = json.Unmarshal([]byte(line), &ix)
		proto, _ := ix["protocol"].(string)
		remote, _ := ix["remote-address"].(string)
		ts, _ := ix["timestamp"].(string)
		host, _ := ix["full-id"].(string)
		if host == "" {
			host = token
		}
		return true, fmt.Sprintf("%s interaction on %s from %s at %s", strings.ToUpper(orDash(proto)), host, orDash(remote), orDash(ts))
	}
	return false, ""
}

// Close terminates the interactsh-client process and releases the session.
func (s *InteractshSession) Close() {
	s.mu.Lock()
	already := s.closed
	s.closed = true
	s.mu.Unlock()
	if already {
		return
	}
	if s.cancel != nil {
		s.cancel()
	}
	if s.cmd != nil && s.cmd.Process != nil {
		_ = s.cmd.Wait()
	}
}

// randLabel returns a lowercase hex DNS label of n chars for a unique OOB
// subdomain. hex keeps it to [0-9a-f], always a valid label.
func randLabel(n int) string {
	b := make([]byte, (n+1)/2)
	if _, err := rand.Read(b); err != nil {
		// Fall back to a time-seeded label; uniqueness within a campaign still
		// holds well enough to correlate, and this path is near-unreachable.
		return fmt.Sprintf("t%09d", time.Now().UnixNano()%1e9)[:n]
	}
	return hex.EncodeToString(b)[:n]
}

func orDash(s string) string {
	if s == "" {
		return "-"
	}
	return s
}
