require "json"
require "time"
require "fileutils"

module Backlog
  # Issue-side capture/list helpers (ADR-0042: one tracker holds both state
  # and specs, git issues are the single tracker). Dispatches on the
  # tracker's kind: `tea` for forgejo:, `gh` for github:, in-repo markdown
  # files for repo:<path>.
  #
  # The git-host CLIs are shelled out to via an injected #capture(*cmd)
  # runner — [stdout, success?], mirroring the tail of Open3.capture3 — so
  # tests stub the runner object directly instead of hacking PATH
  # (ShellRunner below is the only piece that actually shells out). The
  # repo: file mode does real filesystem IO directly (same style as
  # Inspector), exercised in tests via tmpdir.
  class Issues
    # Priority labels capture may attach. Deliberately excludes every state
    # label (`next`/`waiting`/`review`) — there is no parameter through
    # which a state label can reach #create, so it can never apply `next`
    # (issue-state-labels spec: next is human-only).
    PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
    STATE_LABELS = %w[next waiting review].freeze

    CommandFailed = Class.new(StandardError)

    # Default runner: actually shells out via Open3. The only non-pure
    # piece of this file; production CLI wiring uses it, tests never do.
    class ShellRunner
      def capture(*cmd)
        require "open3"
        out, _err, status = Open3.capture3(*cmd)
        [out, status.success?]
      end
    end

    # @param runner [#capture] responds to #capture(*cmd) -> [stdout, success?]
    # @param repo_root [String] base directory a repo:<path> tracker's path is relative to
    # @param clock [#call] returns the current Time; injected for repo: issue dating
    def initialize(runner:, repo_root: Dir.pwd, clock: -> { Time.now })
      @runner = runner
      @repo_root = repo_root
      @clock = clock
    end

    # Create an issue with a title, body, and optional priority label
    # (P0-P3). Never applies a state label.
    #
    # @return [Hash] {number:, url:} for forgejo:/github:, {number:, path:} for repo:
    def create(tracker:, title:, body:, priority: nil)
      validate_priority!(priority)
      case Tracker.kind(tracker)
      when :forgejo then create_tea(slug(tracker), title: title, body: body, priority: priority)
      when :github then create_gh(slug(tracker), title: title, body: body, priority: priority)
      when :repo then create_file(rel_path(tracker), title: title, body: body, priority: priority)
      else raise ArgumentError, "cannot create an issue on unroutable tracker #{tracker.inspect}"
      end
    end

    # Open issues, grouped by state label.
    #
    # @return [Hash{Symbol=>Array<Hash>}] :next / :waiting / :review / :other
    #   => arrays of {number:, title:, labels:, assignee:}; :next first is
    #   the caller's job to render, not this method's (it returns a plain
    #   Hash, insertion-ordered next/waiting/review/other).
    def list(tracker:)
      issues =
        case Tracker.kind(tracker)
        when :forgejo then list_tea(slug(tracker))
        when :github then list_gh(slug(tracker))
        when :repo then list_files(rel_path(tracker))
        else raise ArgumentError, "cannot list issues on unroutable tracker #{tracker.inspect}"
        end
      group_by_state(issues)
    end

    private

    def validate_priority!(priority)
      return if priority.nil? || PRIORITY_LABELS.include?(priority)

      raise ArgumentError, "invalid priority #{priority.inspect}; expected one of #{PRIORITY_LABELS.join(', ')}"
    end

    def slug(tracker) = tracker.split(":", 2).last
    def rel_path(tracker) = tracker.split(":", 2).last

    def group_by_state(issues)
      buckets = { next: [], waiting: [], review: [], other: [] }
      issues.each do |issue|
        labels = Array(issue[:labels])
        key = STATE_LABELS.find { |label| labels.include?(label) }&.to_sym || :other
        buckets[key] << issue
      end
      buckets
    end

    # --- forgejo (tea) ---------------------------------------------------

    def create_tea(repo_slug, title:, body:, priority:)
      cmd = ["tea", "issue", "create", "--repo", repo_slug, "--title", title, "--description", body]
      cmd += ["--labels", priority] if priority
      out, ok = @runner.capture(*cmd)
      raise CommandFailed, "tea issue create failed: #{out}" unless ok

      { number: extract_number(out), url: extract_url(out) }
    end

    def list_tea(repo_slug)
      cmd = ["tea", "issues", "list", "--repo", repo_slug, "--state", "open",
             "--fields", "index,title,labels,assignees", "--output", "json"]
      out, ok = @runner.capture(*cmd)
      raise CommandFailed, "tea issues list failed: #{out}" unless ok

      JSON.parse(out).map do |issue|
        assignee = issue["assignees"].to_s.split(",").map(&:strip).reject(&:empty?).first
        { number: issue["index"].to_i, title: issue["title"],
          labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
          assignee: assignee }
      end
    end

    # --- github (gh) ------------------------------------------------------

    def create_gh(repo_slug, title:, body:, priority:)
      cmd = ["gh", "issue", "create", "--repo", repo_slug, "--title", title, "--body", body]
      cmd += ["--label", priority] if priority
      out, ok = @runner.capture(*cmd)
      raise CommandFailed, "gh issue create failed: #{out}" unless ok

      { number: extract_number(out), url: extract_url(out) }
    end

    def list_gh(repo_slug)
      cmd = ["gh", "issue", "list", "--repo", repo_slug, "--state", "open",
             "--json", "number,title,labels,assignees"]
      out, ok = @runner.capture(*cmd)
      raise CommandFailed, "gh issue list failed: #{out}" unless ok

      JSON.parse(out).map do |issue|
        { number: issue["number"].to_i, title: issue["title"],
          labels: Array(issue["labels"]).map { |label| label["name"] },
          assignee: Array(issue["assignees"]).map { |a| a["login"] }.first }
      end
    end

    def extract_number(text)
      match = text[/#(\d+)/, 1] || text[%r{/issues/(\d+)}, 1]
      match&.to_i
    end

    def extract_url(text)
      text.to_s[%r{https?://\S+}] || text.to_s.strip
    end

    # --- repo: (in-repo markdown issue files) ------------------------------
    #
    # `<path>/NNN-<slug>.md`, front matter + body. No existing in-repo
    # convention for individual issue files predates this (Inspector only
    # detects the presence of a docs/issues dir or ISSUES.md, not a file
    # shape) — this is a new, deliberately simple convention.

    def create_file(rel_dir, title:, body:, priority:)
      dir = File.join(@repo_root, rel_dir)
      FileUtils.mkdir_p(dir)
      number = next_file_number(dir)
      path = File.join(dir, format("%03d-%s.md", number, slugify(title)))
      File.write(path, file_contents(number: number, title: title, body: body, priority: priority))
      { number: number, path: path }
    end

    def slugify(title)
      title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
    end

    def file_contents(number:, title:, body:, priority:)
      front = {
        "number" => number.to_s,
        "title" => title.to_s,
        "labels" => [priority].compact.join(","),
        "state" => "open",
        "created" => @clock.call.strftime("%Y-%m-%d")
      }
      lines = ["---"] + front.map { |k, v| "#{k}: #{v}" } + ["---", ""]
      "#{lines.join("\n")}\n#{body}\n"
    end

    def next_file_number(dir)
      existing = Dir.glob(File.join(dir, "[0-9][0-9][0-9]-*.md"))
      existing.map { |f| File.basename(f)[/\A(\d+)-/, 1].to_i }.max.to_i + 1
    end

    def list_files(rel_dir)
      dir = File.join(@repo_root, rel_dir)
      return [] unless Dir.exist?(dir)

      Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse_issue_file(path) }
    end

    def parse_issue_file(path)
      contents = File.read(path)
      return nil unless contents.start_with?("---\n")

      _, front_matter, = contents.split("---\n", 3)
      data = {}
      front_matter.to_s.each_line do |line|
        key, value = line.split(":", 2)
        next unless key && value

        data[key.strip] = value.strip
      end
      return nil unless data["state"].to_s.empty? || data["state"] == "open"

      { number: data["number"].to_i, title: data["title"],
        labels: data["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
        assignee: data["assignee"] }
    end
  end
end
