// Command replay reads captured requests from _System_RequestCapture,
// correlates the _System_AuditLog rows they produced, and optionally
// re-issues each request against a target backend to diff "expected vs.
// replayed" audit trails.
//
// Scope: this is an OPS/DEV tool (design §14.8). It is not started by the
// main backend binary. It must be run with explicit DSNs.
//
// Default behavior is DRY-RUN (no writes to any target):
//
//	replay \
//	  --db-dsn='user:pw@tcp(host:4000)/nexuscrm?parseTime=true' \
//	  --since=2026-04-24T00:00:00Z \
//	  --until=2026-04-24T01:00:00Z \
//	  --diff-output=/tmp/replay-diff.json
//
// With --target-dsn, the captured HTTP request is POSTed to --target-url;
// --i-understand-this-writes is required (second-gate) to guard against
// accidental prod writes (design §14.5 caveat, §14.8).
//
// Limitations (documented in docs/audit-log/runbook.md "Using the replay
// script"):
//   - Captures are PII-scrubbed at ingest (§14.3). Replay POSTs the
//     scrubbed body; any field the scrubber redacted cannot be re-sent.
//     A future secrets-vault could store an un-scrubbed mirror for
//     privileged replay — not implemented. TODO: once §14-vault RFC lands.
//   - Replay is non-idempotent against live DBs; use a dedicated
//     branch/sandbox as the target.
//   - RecordId/auth rewrites (§14.8 three mechanisms) are NOT implemented
//     here; multi-step tape support is Phase 2B. This command targets the
//     single-request diff case.
package main

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"

	"github.com/nexuscrm/backend/internal/features/audit/replay"
)

// cliConfig holds parsed flags. Split out so main_test.go can build one
// without going through flag.Parse.
type cliConfig struct {
	dbDSN                 string
	sinceStr              string
	untilStr              string
	requestID             string
	userID                string
	targetDSN             string
	targetURL             string
	diffOutput            string
	limit                 int
	mode                  string
	iUnderstandThisWrites bool
	replayRIDSuffix       string
	replayAuthBearer      string
	diffIgnoreRecordID    bool
}

func parseFlags(args []string) (*cliConfig, error) {
	fs := flag.NewFlagSet("replay", flag.ContinueOnError)
	cfg := &cliConfig{}
	fs.StringVar(&cfg.dbDSN, "db-dsn", "", "MySQL-compatible DSN for the source DB (_System_RequestCapture + _System_AuditLog) [REQUIRED]")
	fs.StringVar(&cfg.sinceStr, "since", "", "Only replay captures with ReceivedAt >= this ISO-8601 timestamp (e.g. 2026-04-24T00:00:00Z)")
	fs.StringVar(&cfg.untilStr, "until", "", "Only replay captures with ReceivedAt < this ISO-8601 timestamp (default: now)")
	fs.StringVar(&cfg.requestID, "request-id", "", "Replay only this RequestId (debug)")
	fs.StringVar(&cfg.userID, "user-id", "", "Restrict to captures by this UserId")
	fs.StringVar(&cfg.targetDSN, "target-dsn", "", "Target DB DSN to read replayed audit rows from (optional; if empty → dry-run mode, diff expected-only)")
	fs.StringVar(&cfg.targetURL, "target-url", "", "Target HTTP base URL (required with --target-dsn)")
	fs.StringVar(&cfg.diffOutput, "diff-output", "", "Path to write JSON diff report (default: stdout)")
	fs.IntVar(&cfg.limit, "limit", 100, "Maximum number of captures to process")
	fs.StringVar(&cfg.mode, "mode", "tolerant", "Diff mode: tolerant | strict (§14.5)")
	fs.BoolVar(&cfg.iUnderstandThisWrites, "i-understand-this-writes", false, "REQUIRED with --target-dsn — acknowledges that replay mutates the target")
	fs.StringVar(&cfg.replayRIDSuffix, "replay-rid-suffix", "",
		"Suffix to append to RequestId when replaying (e.g. '-replay'). "+
			"Enables single-DB active replay: baseline rows stay under original "+
			"RequestId; replayed rows land under <rid><suffix>. "+
			"REQUIRED when --target-dsn equals --db-dsn.")
	fs.StringVar(&cfg.replayAuthBearer, "replay-auth-bearer", "",
		"Bearer token to attach to every replayed request's Authorization header. "+
			"Required when the target backend requires auth (captures have scrubbed "+
			"Authorization per §14.3 — the raw token is not recoverable from the "+
			"capture). Operator responsibility: the token must have sufficient perms "+
			"on the target for the captured operations to succeed. Leave empty for "+
			"targets that accept unauthenticated requests.")
	fs.BoolVar(&cfg.diffIgnoreRecordID, "diff-ignore-record-id", false,
		"Ignore RecordId when comparing audit rows. Use for single-DB "+
			"active replay: CREATE on the target allocates a new RecordId, so "+
			"baseline and replay rows never share a full key. With this flag "+
			"the differ matches on ObjectApiName|FieldName|Action alone. The "+
			"JSON report's Expected / Replay lists still show the real "+
			"RecordIds (for debugging); only the diff Key is degraded.")
	if err := fs.Parse(args); err != nil {
		return nil, err
	}
	return cfg, nil
}

// validate ensures flag combinations are coherent before opening DBs.
func (cfg *cliConfig) validate() error {
	if cfg.dbDSN == "" {
		return fmt.Errorf("--db-dsn is required")
	}
	if cfg.targetDSN != "" {
		if cfg.targetURL == "" {
			return fmt.Errorf("--target-url is required when --target-dsn is set")
		}
		if !cfg.iUnderstandThisWrites {
			return fmt.Errorf("--i-understand-this-writes must be set when --target-dsn is supplied (replay mutates the target)")
		}
		if cfg.targetDSN == cfg.dbDSN && cfg.replayRIDSuffix == "" {
			return fmt.Errorf("--replay-rid-suffix is required when --target-dsn equals --db-dsn (both point at the same database)")
		}
	}
	if cfg.targetDSN == "" && cfg.replayRIDSuffix != "" {
		return fmt.Errorf("--replay-rid-suffix is only meaningful with --target-dsn (active replay mode)")
	}
	if cfg.targetDSN == "" && cfg.replayAuthBearer != "" {
		return fmt.Errorf("--replay-auth-bearer requires --target-dsn (active replay mode)")
	}
	if cfg.mode != "tolerant" && cfg.mode != "strict" {
		return fmt.Errorf("--mode must be tolerant or strict, got %q", cfg.mode)
	}
	return nil
}

// parseWindow turns the string --since/--until flags into time.Time.
// Zero --since means "no lower bound". Zero --until defaults to now.
func (cfg *cliConfig) parseWindow() (since, until time.Time, err error) {
	if cfg.sinceStr != "" {
		since, err = time.Parse(time.RFC3339, cfg.sinceStr)
		if err != nil {
			return since, until, fmt.Errorf("invalid --since: %w", err)
		}
	}
	if cfg.untilStr != "" {
		until, err = time.Parse(time.RFC3339, cfg.untilStr)
		if err != nil {
			return since, until, fmt.Errorf("invalid --until: %w", err)
		}
	} else {
		until = time.Now().UTC()
	}
	return since, until, nil
}

// Report is the top-level JSON structure written to --diff-output.
type Report struct {
	GeneratedAt time.Time        `json:"generated_at"`
	Mode        replay.DiffMode  `json:"mode"`
	DryRun      bool             `json:"dry_run"`
	Entries     []CaptureReport  `json:"entries"`
	Summary     ReportSummary    `json:"summary"`
}

// CaptureReport is one capture's diff + metadata.
type CaptureReport struct {
	RequestID       string            `json:"request_id"`
	ReplayRequestID string            `json:"replay_request_id,omitempty"`
	ReceivedAt      time.Time         `json:"received_at"`
	Method          string            `json:"method"`
	Path            string            `json:"path"`
	Expected        []replay.AuditRow `json:"expected"`
	Replay          []replay.AuditRow `json:"replay,omitempty"`
	Diff            replay.DiffResult `json:"diff"`
	ReplaySkipped   string            `json:"replay_skipped,omitempty"`
	ReplayHTTPCode  int               `json:"replay_http_code,omitempty"`
}

type ReportSummary struct {
	Captures       int `json:"captures"`
	Matched        int `json:"matched"`
	Mismatched     int `json:"mismatched"`
	SkippedNoAudit int `json:"skipped_no_audit"`
	HTTPErrors     int `json:"http_errors"`
}

func main() {
	zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
	if os.Getenv("LOG_FORMAT") != "json" {
		log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
	}

	cfg, err := parseFlags(os.Args[1:])
	if err != nil {
		log.Fatal().Err(err).Msg("flag parse failed")
	}
	if err := cfg.validate(); err != nil {
		log.Fatal().Err(err).Msg("flag validation failed")
	}
	since, until, err := cfg.parseWindow()
	if err != nil {
		log.Fatal().Err(err).Msg("window parse failed")
	}

	ctx := context.Background()
	sourceDB, err := sql.Open("mysql", cfg.dbDSN)
	if err != nil {
		log.Fatal().Err(err).Msg("open source DB")
	}
	defer sourceDB.Close()

	// Pass nil (interface zero) — NOT a typed-nil *sql.DB — so Run's
	// `target == nil` dry-run branch fires when no target was configured.
	var target replay.Queryer
	if cfg.targetDSN != "" {
		targetDB, err := sql.Open("mysql", cfg.targetDSN)
		if err != nil {
			log.Fatal().Err(err).Msg("open target DB")
		}
		defer targetDB.Close()
		target = targetDB
	}

	report, err := Run(ctx, sourceDB, target, cfg, since, until)
	if err != nil {
		log.Fatal().Err(err).Msg("replay failed")
	}

	if err := writeReport(report, cfg.diffOutput); err != nil {
		log.Fatal().Err(err).Msg("write report")
	}
	log.Info().Int("captures", report.Summary.Captures).
		Int("matched", report.Summary.Matched).
		Int("mismatched", report.Summary.Mismatched).
		Msg("replay done")
}

// Run is the testable pipeline: load captures → for each, load expected
// audit → (optionally) re-POST + load replay audit → diff. Side-effect free
// when targetDB == nil (dry-run).
func Run(ctx context.Context, source replay.Queryer, target replay.Queryer,
	cfg *cliConfig, since, until time.Time) (*Report, error) {
	captures, err := replay.LoadCaptures(ctx, source, replay.CaptureFilter{
		Since:     since,
		Until:     until,
		RequestID: cfg.requestID,
		UserID:    cfg.userID,
		Limit:     cfg.limit,
	})
	if err != nil {
		return nil, err
	}

	mode := replay.DiffMode(cfg.mode)
	dryRun := target == nil
	report := &Report{
		GeneratedAt: time.Now().UTC(),
		Mode:        mode,
		DryRun:      dryRun,
	}

	for _, c := range captures {
		expected, err := replay.LoadAuditRowsForRequest(ctx, source, c.RequestID)
		if err != nil {
			log.Warn().Err(err).Str("request_id", c.RequestID).Msg("load expected audit")
			continue
		}
		entry := CaptureReport{
			RequestID:  c.RequestID,
			ReceivedAt: c.ReceivedAt,
			Method:     c.Method,
			Path:       c.Path,
			Expected:   expected,
		}
		if len(expected) == 0 {
			entry.ReplaySkipped = "no audit rows for this capture (orphan)"
			report.Summary.SkippedNoAudit++
			report.Entries = append(report.Entries, entry)
			continue
		}

		if dryRun {
			// Compare expected with itself → trivially match; the useful
			// dry-run output is the Expected list for eyeballing.
			entry.Diff = replay.DiffResult{Match: true, Mode: mode, ExpectedSize: len(expected), ReplaySize: len(expected)}
		} else {
			replayRID := c.RequestID + cfg.replayRIDSuffix
			entry.ReplayRequestID = replayRID
			status, postErr := postCapture(ctx, cfg.targetURL, c, replayRID, cfg.replayAuthBearer)
			entry.ReplayHTTPCode = status
			if postErr != nil {
				entry.ReplaySkipped = fmt.Sprintf("http error: %v", postErr)
				report.Summary.HTTPErrors++
				report.Entries = append(report.Entries, entry)
				continue
			}
			replayRows, err := replay.LoadAuditRowsForRequest(ctx, target, replayRID)
			if err != nil {
				log.Warn().Err(err).Str("request_id", c.RequestID).Msg("load replay audit")
				entry.ReplaySkipped = fmt.Sprintf("target audit load failed: %v", err)
				report.Entries = append(report.Entries, entry)
				continue
			}
			entry.Replay = replayRows
			// When --diff-ignore-record-id is set, blank RecordId on COPIES
			// before diffing so entry.Expected / entry.Replay still carry the
			// real RecordIds in the JSON report (for operator debugging).
			expForDiff, repForDiff := expected, replayRows
			if cfg.diffIgnoreRecordID {
				expForDiff = blankRecordIDs(expected)
				repForDiff = blankRecordIDs(replayRows)
			}
			entry.Diff = replay.DiffAuditTrails(expForDiff, repForDiff, mode)
		}

		if entry.Diff.Match {
			report.Summary.Matched++
		} else {
			report.Summary.Mismatched++
		}
		report.Entries = append(report.Entries, entry)
	}
	report.Summary.Captures = len(captures)
	return report, nil
}

// postCapture re-issues the captured request against targetURL. It does NOT
// attempt to un-scrub, rewrite RecordIds, or re-auth — those are §14.8
// Phase 2B concerns. Non-JSON bodies: skip with explicit error.
func postCapture(ctx context.Context, targetURL string, c replay.Capture, replayRID string, bearer string) (int, error) {
	if !isLikelyJSON(c.Body) {
		return 0, fmt.Errorf("non-JSON body; skipping (§14.8 RecordId-rewrite not implemented)")
	}
	u, err := url.Parse(targetURL)
	if err != nil {
		return 0, fmt.Errorf("parse target URL: %w", err)
	}
	u.Path = strings.TrimRight(u.Path, "/") + c.Path
	if c.Query != "" {
		u.RawQuery = c.Query
	}

	req, err := http.NewRequestWithContext(ctx, c.Method, u.String(),
		bytes.NewReader([]byte(c.Body)))
	if err != nil {
		return 0, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Replay-RequestId", replayRID)
	if bearer != "" {
		req.Header.Set("Authorization", "Bearer "+bearer)
	}
	client := &http.Client{Timeout: 30 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, resp.Body)
	return resp.StatusCode, nil
}

// blankRecordIDs returns a copy of rows with RecordId cleared on every row,
// leaving the input slice untouched so the JSON report can show originals.
func blankRecordIDs(rows []replay.AuditRow) []replay.AuditRow {
	out := make([]replay.AuditRow, len(rows))
	copy(out, rows)
	for i := range out {
		out[i].RecordId = ""
	}
	return out
}

func isLikelyJSON(s string) bool {
	s = strings.TrimSpace(s)
	return strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[")
}

func writeReport(r *Report, path string) error {
	buf, err := json.MarshalIndent(r, "", "  ")
	if err != nil {
		return err
	}
	if path == "" || path == "-" {
		_, err = os.Stdout.Write(append(buf, '\n'))
		return err
	}
	return os.WriteFile(path, append(buf, '\n'), 0o644)
}
