#!/usr/bin/env ruby

require "json"
require "open3"
require "optparse"
require "set"
require "yaml"

ALLOWED_REQUEST_TYPES = %w[http cmd_aws cmd_gcp cmd_azure cmd_mysqlsh cmd_aliyun].freeze
COMMAND_NAMES = {
  "cmd_aws" => "aws",
  "cmd_gcp" => "gcloud",
  "cmd_azure" => "az",
  "cmd_mysqlsh" => "mysqlsh",
  "cmd_aliyun" => "aliyun"
}.freeze
SECRET_PATTERNS = {
  "AWS access key" => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
  "GitHub token" => /\bgh[opusr]_[A-Za-z0-9_]{30,}\b/,
  "private key" => /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/
}.freeze

options = { base: "HEAD" }
OptionParser.new do |parser|
  parser.banner = "Usage: check_changed_scenarios.rb [--base REF] [--file PATH]"
  parser.on("--base REF", "Diff base (default: HEAD)") { |value| options[:base] = value }
  parser.on("--file PATH", "Review one file; repeatable") { |value| (options[:files] ||= []) << value }
end.parse!

def git(*args)
  stdout, stderr, status = Open3.capture3("git", *args)
  abort(stderr.strip) unless status.success?
  stdout
end

def add_finding(findings, source, priority, issue, suggestion, reference)
  findings << {
    source: source,
    priority: priority,
    issue: issue,
    suggestion: suggestion,
    reference: reference
  }
end

def parse_json_object(value, field, source, findings)
  unless value.is_a?(String) && !value.empty?
    add_finding(
      findings, source, "P1",
      "`#{field}` must be a non-empty JSON string.",
      "Encode the #{field} object as valid JSON in a YAML block scalar.",
      "README.md#yaml-structure; model/generationsteps.go parse methods"
    )
    return nil
  end
  parsed = JSON.parse(value)
  unless parsed.is_a?(Hash)
    add_finding(
      findings, source, "P1",
      "`#{field}` JSON must decode to an object.",
      "Encode the #{field} value as a JSON object in a YAML block scalar.",
      "README.md#yaml-structure; model/generationsteps.go parse methods"
    )
    return nil
  end
  parsed
rescue JSON::ParserError => e
  add_finding(
    findings, source, "P1",
    "`#{field}` contains invalid JSON: #{e.message}",
    "Correct the embedded JSON before running the scenario.",
    "README.md#yaml-parse-failure; model/generationsteps.go parse methods"
  )
  nil
end

def step_lines(path)
  File.readlines(path).each_with_index.each_with_object([]) do |(line, index), lines|
    lines << index + 1 if line.match?(/^\s*-?\s*operation_id:/)
  end
end

def source_for(path, lines, index, step)
  operation = step.is_a?(Hash) ? step["operation_id"] : nil
  order = step.is_a?(Hash) ? step["order"] : nil
  suffix = [operation, ("order #{order}" unless order.nil?)].compact.join(", ")
  "#{path}:#{lines[index] || 1}#{suffix.empty? ? "" : " (#{suffix})"}"
end

def field_source(path, step_starts, index, step, field)
  all_lines = File.readlines(path)
  first = step_starts[index] || 1
  last = index + 1 < step_starts.length ? step_starts[index + 1] - 1 : all_lines.length
  offset = all_lines[(first - 1)...last].index { |line| line.match?(/^\s+#{Regexp.escape(field)}:/) }
  line = offset.nil? ? first : first + offset
  source_for(path, [line], 0, step)
end

def placeholder_names(value)
  case value
  when Hash
    value.values.flat_map { |nested| placeholder_names(nested) }
  when Array
    value.flat_map { |nested| placeholder_names(nested) }
  when String
    value.scan(/\{([^{}:]+)(?::(?:integer|number))?\}/).flatten
  else
    []
  end
end

def changed_lines(base, path)
  lines = Set.new
  git("diff", "--unified=0", base, "--", path).scan(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) do |start_text, count_text|
    start = start_text.to_i
    count = count_text.nil? ? 1 : count_text.to_i
    count.times { |offset| lines << start + offset }
  end
  lines
end

def finding_touches_change?(finding, changed_by_file)
  match = finding[:source].match(/\A(.+):(\d+)(?:\s|\z)/)
  return true unless match

  path = match[1]
  source_line = match[2].to_i
  changed = changed_by_file[path]
  return true if changed.nil?
  return false if changed.empty?

  if finding[:issue].start_with?("Retry", "Order", "Duplicate order")
    return changed.include?(source_line)
  end

  starts = step_lines(path)
  step_index = starts.rindex { |line| line <= source_line }
  return true if step_index.nil?

  first = starts[step_index]
  last = step_index + 1 < starts.length ? starts[step_index + 1] - 1 : File.foreach(path).count
  changed.any? { |line| line >= first && line <= last }
end

repo_root = git("rev-parse", "--show-toplevel").strip
Dir.chdir(repo_root)

explicit_files = !options[:files].nil?
files = options[:files]
untracked_files = []
unless files
  changed = git("diff", "--name-only", "--diff-filter=ACMR", options[:base], "--", "scenario_testcases/*.yaml").lines
  untracked = git("ls-files", "--others", "--exclude-standard", "--", "scenario_testcases/*.yaml").lines
  untracked_files = untracked.map(&:strip).reject(&:empty?)
  files = (changed.map(&:strip) + untracked_files).reject(&:empty?).uniq.sort
end

if files.empty?
  puts "未发现变更的 scenario YAML（base: #{options[:base]}）。"
  exit 0
end

findings = []
reviewed = []
changed_by_file = {}
unless explicit_files
  files.each do |path|
    changed_by_file[path] = if untracked_files.include?(path)
      Set.new(1..File.foreach(path).count)
    else
      changed_lines(options[:base], path)
    end
  end
end

files.each do |path|
  unless File.file?(path)
    add_finding(findings, path, "P1", "Changed YAML file is not present in the worktree.", "Restore it or exclude an intentional deletion from review.", "git diff --diff-filter")
    next
  end

  reviewed << path
  content = File.read(path)
  SECRET_PATTERNS.each do |label, pattern|
    next unless (match = content.match(pattern))

    line = content[0...match.begin(0)].count("\n") + 1
    add_finding(findings, "#{path}:#{line}", "P0", "Possible committed #{label} detected.", "Remove the value and bind a workflow secret or runner placeholder.", "../docs/refs/TiDB Cloud E2E case review rules.md#Sensitive-information; PR #214")
  end

  begin
    document = YAML.safe_load(content, [], [], false)
  rescue StandardError => e
    add_finding(findings, "#{path}:1", "P1", "YAML cannot be parsed: #{e.message}", "Correct the YAML syntax and parse it again.", "README.md#yaml-parse-failure")
    next
  end

  unless document.is_a?(Hash) && document["scenario"].is_a?(Hash) && document["steps"].is_a?(Array)
    add_finding(findings, "#{path}:1", "P1", "Top-level `scenario` object or `steps` array is missing.", "Use the documented scenario/steps structure.", "README.md#yaml-structure")
    next
  end

  lines = step_lines(path)
  previous_order = nil
  seen_orders = {}

  document["steps"].each_with_index do |step, index|
    source = source_for(path, lines, index, step)
    unless step.is_a?(Hash)
      add_finding(findings, source, "P1", "Step is not a YAML mapping.", "Represent the step as a mapping with operation, request, expectation, and execution fields.", "README.md#steps")
      next
    end

    order = step["order"]
    order_source = field_source(path, lines, index, step, "order")
    if !order.is_a?(Numeric)
      add_finding(findings, order_source, "P1", "`order` is missing or non-numeric.", "Assign a numeric order that is unique and greater than the previous step.", "README.md#steps; supplemental rule R2")
    else
      if seen_orders.key?(order)
        add_finding(findings, order_source, "P1", "Duplicate order `#{order}` also appears at #{seen_orders[order]}.", "Choose a unique order while preserving lifecycle dependencies.", "supplemental rule R2; PR #114")
      end
      if !previous_order.nil? && order <= previous_order
        add_finding(findings, order_source, "P1", "Order `#{order}` is not greater than preceding order `#{previous_order}`; filesystem cases execute in YAML sequence.", "Reorder the steps or assign a strictly increasing value.", "Scenario_suite_test.go Test_001_ExecuteActions; supplemental rule R2")
      end
      seen_orders[order] = source
      previous_order = order
    end

    request_type = step["request_type"]
    unless ALLOWED_REQUEST_TYPES.include?(request_type)
      add_finding(findings, source, "P1", "Unsupported request_type `#{request_type}`.", "Use one of: #{ALLOWED_REQUEST_TYPES.join(", ")}.", "model/generationsteps.go request type constants; actionbuilder.go BuildAction")
    end

    request = parse_json_object(step["request"], "request", source, findings)
    expect = parse_json_object(step["expect"], "expect", source, findings)
    save = step["save"].nil? ? nil : parse_json_object(step["save"], "save", source, findings)

    max_retries = step["max_retries"]
    delay_after = step["delay_after"]
    retry_source = field_source(path, lines, index, step, "max_retries")
    if !max_retries.is_a?(Integer) || max_retries.negative? || !delay_after.is_a?(Integer) || delay_after.negative?
      add_finding(findings, retry_source, "P1", "Retry fields `max_retries` and `delay_after` must be non-negative integers.", "Set explicit non-negative integer retry values.", "README.md#steps; action.go Do")
    elsif max_retries.positive? && delay_after.zero?
      add_finding(findings, retry_source, "P2", "Retry loop has zero delay and can hammer the API or command.", "Set a non-zero delay appropriate for the asynchronous operation.", "supplemental rule R5")
    elsif max_retries * delay_after > 1_800
      add_finding(findings, retry_source, "P2", "Retry delay budget is #{max_retries * delay_after} seconds, exceeding 30 minutes.", "Reduce the retry window or document why the scenario needs a longer wait.", "../docs/refs/TiDB Cloud E2E case review rules.md#Retry; supplemental rule R5")
    end

    if request_type == "http" && request.is_a?(Hash)
      %w[method path auth].each do |key|
        next unless request[key].nil? || request[key] == ""

        add_finding(findings, source, "P1", "HTTP request is missing `#{key}`.", "Add a runner-compatible `#{key}` value.", "README.md#http-request-json; supplemental rule R8")
      end
      auth = request["auth"]
      if auth.is_a?(Hash) && auth["auth_type"] == "digest" && (auth["auth_role"].nil? || auth["auth_role"] == "") && !auth["content"].is_a?(Hash)
        add_finding(findings, source, "P1", "Digest auth has neither `auth_role` nor a credential `content` object.", "Use an auth role or the documented placeholder public/private key content.", "actionbuilder.go buildHTTPClient; supplemental rule R8")
      end

      request_path = request["path"].to_s
      path_params = request["path_params"].is_a?(Hash) ? request["path_params"] : {}
      request_path.scan(/\{([^{}]+)\}/).flatten.each do |name|
        next if path_params.key?(name)

        add_finding(findings, source, "P1", "Path placeholder `{#{name}}` is not bound in `path_params`.", "Map `#{name}` to an existing scenario placeholder.", "README.md#placeholder-rules; supplemental rule R3")
      end

      body_params = request["body_params"].is_a?(Hash) ? request["body_params"] : {}
      placeholder_names(request["body"]).uniq.each do |name|
        next if body_params.key?(name)

        add_finding(findings, source, "P1", "Body placeholder `{#{name}}` is not bound in `body_params`.", "Map `#{name}` to an existing scenario placeholder.", "README.md#placeholder-rules; supplemental rule R3")
      end

      if expect.is_a?(Hash) && expect["status_code"].nil? && expect["cel"].to_s.empty?
        add_finding(findings, source, "P1", "HTTP expectation checks neither status code nor CEL behavior.", "Add the expected HTTP status and a meaningful CEL assertion when response behavior matters.", "README.md#expectations; canonical Expect rule")
      end
    elsif COMMAND_NAMES.key?(request_type) && request.is_a?(Hash)
      expected_name = COMMAND_NAMES[request_type]
      if request["name"] != expected_name
        add_finding(findings, source, "P1", "#{request_type} requires command `#{expected_name}`, not `#{request["name"]}`.", "Match the command name to request_type.", "model/generationsteps.go ParseCommandSpec")
      end
      unless request["args"].is_a?(Array) && request["args"].all? { |arg| arg.is_a?(String) }
        add_finding(findings, source, "P1", "Command `args` must be an array of strings.", "Encode each command token as a separate string argument.", "README.md#command-request-json")
      else
        request["args"].each do |arg|
          next unless arg.start_with?("--") && arg.match?(/\{[^{}]+\}/)

          add_finding(findings, source, "P1", "Placeholder in flag argument `#{arg}` will not be replaced.", "Split the flag and placeholder value into separate arguments.", "actionbuilder.go buildCommandArgs; supplemental rule R3")
        end

        args_params = request["args_params"].is_a?(Hash) ? request["args_params"] : {}
        placeholder_names(request["args"]).uniq.each do |name|
          next if args_params.key?(name)

          add_finding(findings, source, "P1", "Command placeholder `{#{name}}` is not bound in `args_params`.", "Map `#{name}` to an existing scenario placeholder.", "README.md#placeholder-rules; actionbuilder.go buildCommandArgs; supplemental rule R3")
        end
      end
      if expect.is_a?(Hash) && expect.key?("status_code") && !expect.key?("exit_code")
        add_finding(findings, source, "P1", "Command expectation uses `status_code`; the runner reads `exit_code`.", "Replace `status_code` with the expected `exit_code`.", "README.md#expectations; model/generationsteps.go CommandExpect")
      elsif expect.is_a?(Hash) && expect["exit_code"].nil? && expect["cel"].to_s.empty?
        add_finding(findings, source, "P1", "Command expectation checks neither exit code nor CEL output.", "Add `exit_code` and a meaningful CEL assertion when output matters.", "README.md#expectations")
      end
    end

    next if save.nil?
    placeholders = save.is_a?(Hash) ? save["placeholder"] : nil
    unless placeholders.is_a?(Array)
      add_finding(findings, source, "P1", "`save.placeholder` must be an array.", "Use the documented save expression array or set `save: null`.", "README.md#save-placeholders")
      next
    end
    placeholders.each do |entry|
      unless entry.is_a?(Hash) && !entry["key"].to_s.empty? && !entry["eval"].to_s.empty?
        add_finding(findings, source, "P1", "Save entry requires non-empty `key` and `eval`.", "Provide both fields or remove the invalid save entry.", "README.md#save-placeholders")
        next
      end
      key = entry["key"]
      if key.start_with?("cluster") && !key.match?(/^cluster_\d+$/)
        add_finding(findings, source, "P1", "Saved key `#{key}` starts with `cluster` but is not a canonical cluster ID key.", "Use `cluster_1`, `cluster_2`, etc. for cluster IDs; rename non-cluster values without the `cluster` prefix.", "Scenario_suite_test.go clearClusters; supplemental rule R7")
      end
    end
  end
end

findings.select! { |finding| finding_touches_change?(finding, changed_by_file) } unless explicit_files
priority_rank = { "P0" => 0, "P1" => 1, "P2" => 2, "P3" => 3 }
findings.sort_by! { |finding| [priority_rank.fetch(finding[:priority], 9), finding[:source]] }

if findings.empty?
  puts "未发现静态结构问题。"
else
  findings.each_with_index do |finding, index|
    puts "### [#{finding[:priority]}] Static check finding #{index + 1}"
    puts
    puts "- source: `#{finding[:source]}`"
    puts "- 优先级: #{finding[:priority]}"
    puts "- 问题点: #{finding[:issue]}"
    puts "- 修改建议: #{finding[:suggestion]}"
    puts "- 参考: #{finding[:reference]}"
    puts
  end
end

puts "Reviewed files (base: #{options[:base]}):"
reviewed.each { |path| puts "- #{path}" }
puts "Static findings: #{findings.length}. Continue with semantic and lifecycle review."
exit(findings.empty? ? 0 : 1)
