#!/usr/bin/env ruby
# encoding: UTF-8
# frozen_string_literal: true

# rebuild-graph — repair the store-wide sources/chain frontmatter graph across the
# global, plastic, and knowdb stores (intent 49). Deterministic, idempotent, and
# one-directional (intent 68 I-invariants): dedupe, I3 (formative edge wins), I1
# in-store backlinks, I2 preserved; cross-store refs resolved via a multi-hop
# relocation map (relocation wins over coincidental id reuse). Emits a
# before/after audit, then writes minimal style-preserving frontmatter.
#
# Usage:
#   rebuild-graph [--plastic-home PATH] [--dry-run] [--audit-path PATH]
#
# Pure-Ruby (no bash). The pure logic lives in lib/graph_rebuild.rb and
# lib/frontmatter_writer.rb; this shell does only discovery, IO, and reporting.
# Never pushes ~/.plastic (no git ops here).

require "yaml"
require "date"
require "time"
require "fileutils"

require_relative "lib/graph_rebuild"
require_relative "lib/frontmatter_writer"
require_relative "lib/intent_validator"
require_relative "lib/store_discovery"
require_relative "lib/revisions_writer"

class RebuildGraph
  DEFAULT_HOME = File.join(Dir.home, ".plastic")

  # The 49 intent dir audit destination (relative to plastic_home).
  DEFAULT_AUDIT_REL =
    "projects/plastic/store/49--store-wide-double-link-symmetry/resources/audit--graph-rebuild.md"

  KIND_LABELS = {
    dedupe: "Dedupes",
    i3: "I3 resolutions (kept in sources, dropped from chain)",
    repoint: "Cross-store repoints",
    collapse: "Cross-store collapses (to bare same-store id)",
    drop: "Dropped dead refs",
    i1_backlink: "I1 backlinks added",
  }.freeze

  KIND_ORDER = %i[dedupe i3 repoint collapse drop i1_backlink].freeze

  def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
    @plastic_home = plastic_home
    @dry_run = dry_run
    @write_failures = []

    # A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
    # to run --dry-run to review the plan). When no explicit --audit-path is given,
    # a dry run writes to a distinct `.dry-run.md` sibling, leaving the canonical
    # real-run audit untouched. An explicit --audit-path is always honored verbatim
    # (it is the caller's responsibility, and tests inject it).
    canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
    @audit_path =
      if audit_path
        audit_path
      elsif dry_run
        canonical.sub(/\.md\z/, ".dry-run.md")
      else
        canonical
      end
  end

  attr_reader :plastic_home, :dry_run, :audit_path, :write_failures

  def any_write_failures?
    !write_failures.empty?
  end

  # Every store in scope: global plus every projects/<slug>/store directory that exists
  # (intent 189). A superset of reality, not a hardcoded list: missing a real store here
  # means a live cross-store ref into it gets classified :dead and DELETED (the exact bug
  # this discovery fixes); including one nobody registered is harmless. Single source of
  # truth is StoreDiscovery, shared with project-links, doctor.rb, and new-intent.
  def stores
    discovery[:stores]
  end

  # Registered projects.yml slugs with no store directory on disk (legal;
  # plastic-store-provisioning exists for exactly this state). Reported in the audit
  # (intent 189 D5) so this never looks like a silent empty store.
  def missing_stores
    discovery[:missing]
  end

  def discovery
    @discovery ||= StoreDiscovery.discover(plastic_home)
  end

  # { id => { sources:, chain:, path: } } for one store.
  def load_nodes(store_dir)
    nodes = {}
    Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
      dir = File.join(store_dir, entry)
      next unless File.directory?(dir)

      md = File.join(dir, "#{entry}.md")
      next unless File.exist?(md)

      fm = IntentValidator.parse_frontmatter(md)
      next unless fm.is_a?(Hash) && fm["id"]

      nodes[fm["id"].to_s] = {
        sources: Array(fm["sources"]).map(&:to_s),
        chain: Array(fm["chain"]).map(&:to_s),
        path: md,
      }
    end
    nodes
  end

  def run
    store_list = stores
    nodes_by_store = {}
    index_texts = {}
    store_index = {}

    store_list.each do |s|
      nodes_by_store[s[:key]] = load_nodes(s[:store])
      index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
      store_index[s[:key]] = nodes_by_store[s[:key]].keys
    end

    relocation_map = GraphRebuild.build_relocation_map(index_texts)

    results = {}
    store_list.each do |s|
      key = s[:key]
      input = nodes_by_store[key].transform_values { |v| { sources: v[:sources], chain: v[:chain] } }
      results[key] = GraphRebuild.rebuild_store(
        input,
        referer_store: key,
        relocation_map: relocation_map,
        store_index: store_index
      )
    end

    write_back(store_list, nodes_by_store, results) unless dry_run
    emit_audit(store_list, nodes_by_store, results)

    results
  end

  # Write changed frontmatter back via the minimal style-preserving writer. Every applied
  # change writes a revisions.md receipt FIRST (intent 197): if the receipt cannot be
  # written, the frontmatter change is withheld (reported in write_failures) rather than
  # left unrecorded.
  def write_back(store_list, nodes_by_store, results)
    store_list.each do |s|
      key = s[:key]
      new_nodes = results[key][:nodes]
      changes_by_id = results[key][:changes].group_by { |c| c[:intent] }
      nodes_by_store[key].each do |id, original|
        rebuilt = new_nodes[id]
        next if rebuilt.nil?
        next if rebuilt[:sources] == original[:sources] && rebuilt[:chain] == original[:chain]

        content = File.read(original[:path])
        updated = FrontmatterWriter.rewrite_arrays(content,
                                                   sources: rebuilt[:sources],
                                                   chain: rebuilt[:chain])
        next if updated == content

        id_changes = changes_by_id[id] || []
        begin
          RevisionsWriter.append!(
            File.dirname(original[:path]),
            why: "graph rebuild: #{id_changes.map { |c| c[:kind] }.uniq.join(", ")}",
            rule: "graph-rebuild",
            prior_location: "#{File.basename(original[:path], ".md")}.md frontmatter - sources/chain",
            change: id_changes.map { |c| format_change(c) }.join("; ")
          )
        rescue RevisionsWriter::WriteFailed => e
          @write_failures << { id: id, error: e.message }
          next
        end
        File.write(original[:path], updated)
      end
    end
  end

  # Render the audit and write it (always, even in dry-run, so the human reviews
  # the dry-run plan). Returns the rendered string.
  def emit_audit(store_list, _nodes_by_store, results)
    text = render_audit(store_list, results)
    FileUtils.mkdir_p(File.dirname(audit_path))
    File.write(audit_path, text)
    text
  end

  # PURE-ish formatter (string from results). Per-store, grouped by kind.
  def render_audit(store_list, results)
    total = store_list.sum { |s| results[s[:key]][:changes].size }
    preserved_unknown = store_list.flat_map { |s| results[s[:key]][:preserved] }
    lines = []
    lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
    lines << ""
    lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
    lines << ""
    lines << "Total changes across all stores: #{total}"
    lines << "Unknown-store refs preserved (NOT dropped): #{preserved_unknown.size}"
    lines << ""

    unless preserved_unknown.empty?
      lines << "## Unknown-store refs preserved (store not recognized this run; check " \
               "projects.yml and store discovery before trusting a zero-change result)"
      lines << ""
      preserved_unknown.each do |p|
        lines << "- #{p[:intent]}.#{p[:field]}: #{p[:ref]} (store #{p[:store].inspect} not recognized)"
      end
      lines << ""
    end

    unless write_failures.empty?
      lines << "## Frontmatter changes that could NOT be written (revisions.md receipt failed)"
      lines << ""
      write_failures.each { |f| lines << "- #{f[:id]}: #{f[:error]}" }
      lines << ""
    end

    unless missing_stores.empty?
      lines << "## Registered projects with no store on disk"
      lines << ""
      missing_stores.each { |m| lines << "- #{m[:slug]} (#{m[:project_dir]})" }
      lines << ""
    end

    store_list.each do |s|
      key = s[:key]
      changes = results[key][:changes]
      lines << "## #{key}"
      lines << ""
      if changes.empty?
        lines << "No changes."
        lines << ""
        next
      end

      KIND_ORDER.each do |kind|
        group = changes.select { |c| c[:kind] == kind }
        next if group.empty?

        lines << "### #{KIND_LABELS[kind]} (#{group.size})"
        group.each { |c| lines << "- #{format_change(c)}" }
        lines << ""
      end
    end

    lines.join("\n") + "\n"
  end

  def format_change(c)
    case c[:kind]
    when :dedupe
      "#{c[:intent]}: sources #{c[:before][:sources].inspect} → #{c[:after][:sources].inspect}, " \
        "chain #{c[:before][:chain].inspect} → #{c[:after][:chain].inspect}"
    when :i3
      "#{c[:intent]}: #{c[:before]} kept in sources, dropped from chain"
    when :repoint
      "#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (relocated cross-store)"
    when :collapse
      "#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (collapsed to bare same-store id)"
    when :drop
      "#{c[:intent]}.#{c[:field]}: #{c[:before]} dropped (resolves nowhere)"
    when :i1_backlink
      "#{c[:intent]}.chain += #{c[:backlink]} (formative backlink)"
    else
      c.inspect
    end
  end
end

if $PROGRAM_NAME == __FILE__
  home = RebuildGraph::DEFAULT_HOME
  dry = false
  audit = nil
  i = 0
  while i < ARGV.length
    case ARGV[i]
    when "--plastic-home" then home = ARGV[i + 1]; i += 2
    when "--dry-run" then dry = true; i += 1
    when "--audit-path" then audit = ARGV[i + 1]; i += 2
    else
      abort "rebuild-graph: unknown argument #{ARGV[i].inspect} " \
            "(usage: --plastic-home PATH | --dry-run | --audit-path PATH)"
    end
  end

  tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
  results = tool.run
  total = results.values.sum { |r| r[:changes].size }
  preserved = results.values.sum { |r| r[:preserved].size }
  puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
  puts "Unknown-store refs preserved (not dropped): #{preserved}" if preserved.positive?
  puts "Audit: #{tool.audit_path}"
  puts "Write failures (receipt could not be recorded, change withheld): #{tool.write_failures.size}" if tool.any_write_failures?
  exit 1 if tool.any_write_failures?
end
