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

# new-intent (intent 60b) - the one-call scaffolding contract.
#
# A single invocation scaffolds a COMPLETE intent: it allocates the Folgezettel
# id (root vs branch by --parent), creates the directory tree (<id>--<slug>/ plus
# actions/ and resources/), renders the born-complete intent file from
# templates/intent.md, writes sentinel placeholder lifecycle files (spec.md,
# plan.md, checklist.md, outcome.md, each carrying <!-- plastic:placeholder -->
# as its first line), wires reciprocal file links, and self-validates with
# IntentValidator (exit non-zero if not born complete).
#
# It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
# responsibilities (see plastic-intent-creating).
#
# Usage:
#   new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
#              [--parent <id>] [--author <name>] [--sources id,id] \
#              [--tags tag,tag] [--templates <dir>]
#
# Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).

require "fileutils"
require "date"
require_relative "lib/bridge"
require_relative "lib/intent_validator"
require_relative "lib/graph_rebuild"
require_relative "lib/links_projection"
require_relative "lib/links_section"
require_relative "lib/store_discovery"

# --- Explicit flag parsing (no eval, no global injection) ------------------

def parse_args(argv)
  opts = {
    store: nil, intent: nil, slug: nil, parent: nil,
    author: "claude-code", sources: [], tags: [], templates: nil
  }
  i = 0
  while i < argv.length
    arg = argv[i]
    case arg
    when "--store"     then opts[:store] = argv[i += 1]
    when "--intent"    then opts[:intent] = argv[i += 1]
    when "--slug"      then opts[:slug] = argv[i += 1]
    when "--parent"    then opts[:parent] = argv[i += 1]
    when "--author"    then opts[:author] = argv[i += 1]
    when "--sources"   then opts[:sources] = split_list(argv[i += 1])
    when "--tags"      then opts[:tags] = split_list(argv[i += 1])
    when "--templates" then opts[:templates] = argv[i += 1]
    else
      abort "new-intent: unknown argument #{arg.inspect}"
    end
    i += 1
  end
  opts
end

def split_list(value)
  value.to_s.split(",").map(&:strip).reject(&:empty?)
end

def expand(path)
  File.expand_path(path.to_s.sub(/\A~/, Dir.home))
end

# Default templates dir: sibling of this script's dir. Works in-repo
# (<repo>/scripts/new-intent -> <repo>/templates) and installed
# (~/.plastic/scripts/new-intent -> ~/.plastic/templates when present).
def default_templates_dir
  File.expand_path("../templates", __dir__)
end

def render_tokens(text, tokens)
  # Block-form gsub: a String replacement argument reinterprets backslash
  # sequences (so a doubled backslash from escape_double_quoted_scalar
  # collapses back to one); the block form substitutes the value literally.
  tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}") { v.to_s } }
end

# Escape the characters that would break a double-quoted YAML scalar: backslash
# first, so a real backslash in the input isn't re-escaped by the quote step,
# then the double quote itself. Used only for the frontmatter-bound INTENT
# token; the body's DESCRIPTION copy stays raw (markdown prose, not a YAML
# scalar) so escaping it would corrupt what the user actually typed.
def escape_double_quoted_scalar(value)
  value.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\"' }
end

# Add an id to a source intent's frontmatter `chain` array, idempotently (I1
# reciprocity: `child in parent.sources` => `parent.chain` gains `child`). A
# targeted edit that preserves every existing entry and appends the new id in
# the SAME on-disk YAML style (flow `chain: [...]` or block `chain:` + indented
# `- "id"` lines) the target already uses; the body (including `## Links`) is
# preserved byte-for-byte and no other frontmatter key is touched, so the file
# stays born-complete. No-op when the id is already present.
def add_to_chain(file_path, new_id)
  return unless File.exist?(file_path)
  content = File.read(file_path)
  return unless content.start_with?("---")

  parts = content.split("---", 3)
  return unless parts.length >= 3

  fm = parts[1]
  lines = fm.lines
  chain_idx = lines.index { |l| l.match?(/\A\s*chain\s*:/) }
  return unless chain_idx

  flow_match = lines[chain_idx].match(/\A\s*chain\s*:\s*\[(.*?)\]\s*\z/m)

  if flow_match
    ids = flow_match[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
    return if ids.include?(new_id)

    ids << new_id
    lines[chain_idx] = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]\n"
  else
    entry_re = /\A(\s*-\s*)(?:"([^"]*)"|'([^']*)'|(\S+))\s*\z/
    entry_lines = []
    j = chain_idx + 1
    while j < lines.length && lines[j].match?(entry_re)
      entry_lines << lines[j]
      j += 1
    end

    ids = entry_lines.map { |l| (m = l.match(entry_re)) && (m[2] || m[3] || m[4]) }
    return if ids.include?(new_id)

    if entry_lines.empty?
      prefix, quote = "  - ", "\""
    else
      m = entry_lines.last.match(entry_re)
      prefix = m[1]
      quote = m[2] ? "\"" : (m[3] ? "'" : "")
    end
    lines.insert(chain_idx + 1 + entry_lines.length, "#{prefix}#{quote}#{new_id}#{quote}\n")
  end

  new_fm = lines.join
  File.write(file_path, ["", new_fm, parts[2]].join("---"))
end

# Derive [plastic_home, referer_store_key] from a store directory path so the
# cross-store resolver (shared with project-links and the doctor check) can be
# built. A global store is `<home>/store` (key "global"); a project store is
# `<home>/projects/<slug>/store` (key "project:<slug>"). Returns
# [nil, "global"] only if the layout is unrecognized, in which case Links
# projection falls back to single-store resolution rooted at this store.
def store_context(store)
  store = File.expand_path(store)
  parent = File.dirname(store)               # `<home>` or `<home>/projects/<slug>`
  if File.basename(store) == "store" && File.basename(File.dirname(parent)) == "projects"
    slug = File.basename(parent)
    home = File.dirname(File.dirname(parent)) # strip projects/<slug>
    [home, "project:#{slug}"]
  elsif File.basename(store) == "store"
    [parent, "global"]                        # `<home>/store`
  else
    [parent, "global"]
  end
end

# The in-scope stores under `plastic_home`, each { key:, store: }. Delegates to
# StoreDiscovery, the single source of truth also used by project-links, rebuild-graph,
# and doctor.rb (intent 189), so cross-store resolution spans EVERY real store, not a
# hardcoded few.
def family_stores(plastic_home)
  StoreDiscovery.discover(plastic_home)[:stores].map { |s| { key: s[:key], store: s[:store] } }
end

# Build the cross-store maps the LinksProjection resolver needs:
#   store_index    => { store_key => [bare ids] }
#   node_index     => { store_key => { id => { basename:, label: } } }
#   relocation_map => from GraphRebuild.build_relocation_map over every INDEX.md
# `fallback_store` is the store the new intent lives in; it is always included so
# resolution works even for a brand-new store with no INDEX.md yet.
def build_cross_store_maps(plastic_home, fallback_store_key, fallback_store_dir)
  stores = family_stores(plastic_home)
  # Ensure the fallback store is represented even if family discovery missed it.
  unless stores.any? { |s| s[:key] == fallback_store_key }
    stores << { key: fallback_store_key, store: fallback_store_dir }
  end

  store_index = Hash.new { |h, k| h[k] = [] }
  node_index = Hash.new { |h, k| h[k] = {} }
  index_texts = {}

  stores.each do |s|
    next unless File.directory?(s[:store])

    Dir.children(s[:store]).reject { |e| e.start_with?(".") }.sort.each do |entry|
      dir = File.join(s[:store], 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"]

      id = fm["id"].to_s
      store_index[s[:key]] << id
      node_index[s[:key]][id] = { basename: entry, label: fm["intent"].to_s.strip }
    end

    idx = File.join(File.dirname(s[:store]), "INDEX.md")
    index_texts[s[:key]] = File.read(idx) if File.exist?(idx)
  end

  relocation_map = GraphRebuild.build_relocation_map(index_texts)
  { store_index: store_index, node_index: node_index, relocation_map: relocation_map }
end

# Re-project ONE intent file's `## Links` as the canonical I5 projection of its
# OWN frontmatter sources+chain, using the shared cross-store resolver. Writes the
# file only if the section changed (idempotent). Born-canonical: a fresh root with
# no edges gets the empty-state comment; the fence-aware rewriter touches only the
# real `## Links` section. Returns true on success, false when the intent could
# not be read or a ref was unresolvable (left unwritten, never a guessed link).
def project_links_for(file_path, referer_store_key, maps)
  return false unless File.exist?(file_path)

  content = File.read(file_path)
  fm = IntentValidator.parse_frontmatter_text(content)
  return false unless fm.is_a?(Hash)

  resolve = lambda do |ref|
    LinksProjection.resolve_ref_projection(
      ref,
      referer_store: referer_store_key,
      relocation_map: maps[:relocation_map],
      store_index: maps[:store_index],
      node_index: maps[:node_index]
    )
  end

  begin
    section_text = LinksProjection.section(
      sources: Array(fm["sources"]).map(&:to_s),
      chain: Array(fm["chain"]).map(&:to_s),
      resolve: resolve
    )
    updated = LinksSection.rewrite(content, section_text)
  rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
    warn "new-intent: could not project ## Links for #{File.basename(file_path)}: #{e.message}"
    return false
  end

  File.write(file_path, updated) if updated != content
  true
end

def main(argv)
  opts = parse_args(argv)
  abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
  abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
  abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?

  store = expand(opts[:store])
  abort "new-intent: store dir does not exist: #{store}" unless Dir.exist?(store)

  templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
  abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)

  # 1. Allocate id via the existing folgezettel-id logic (root vs branch).
  folg = File.expand_path("folgezettel-id", __dir__)
  cmd = [folg, store]
  cmd << opts[:parent] if opts[:parent] && !opts[:parent].empty?
  id = `#{cmd.map { |c| "'#{c}'" }.join(" ")}`.strip
  abort "new-intent: id allocation failed" if id.empty?

  slug = opts[:slug]
  intent_dir = File.join(store, "#{id}--#{slug}")
  abort "new-intent: #{intent_dir} already exists" if File.exist?(intent_dir)

  # 2. Create dirs.
  FileUtils.mkdir_p(File.join(intent_dir, "actions"))
  File.write(File.join(intent_dir, "actions", ".gitkeep"), "")
  FileUtils.mkdir_p(File.join(intent_dir, "resources"))

  # 3. Render the born-complete intent file from templates/intent.md.
  sources = opts[:sources]
  sources = sources | [opts[:parent]] if opts[:parent] && !opts[:parent].empty? && !sources.include?(opts[:parent])
  sources_str = sources.map { |s| "\"#{s}\"" }.join(", ")
  tags_str = opts[:tags].map { |t| "\"#{t}\"" }.join(", ")

  intent_template = File.read(File.join(templates, "intent.md"))
  intent_body = render_tokens(intent_template, {
    "ID" => id,
    "INTENT" => escape_double_quoted_scalar(opts[:intent]),
    "SOURCES" => sources_str,
    "DATE" => Date.today.iso8601,
    "AUTHOR" => opts[:author],
    "TAGS" => tags_str,
    "DESCRIPTION" => opts[:intent],
  })
  intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
  File.write(intent_file, intent_body)

  # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
  # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
  # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
  # folds in `--parent`). Collect the touched source files so their `## Links` can be
  # re-projected once the chain edges are on disk.
  source_files = []
  sources.each do |src_id|
    next if src_id.nil? || src_id.empty?

    src_dir = Dir.glob(File.join(store, "#{src_id}--*")).find { |d| File.directory?(d) }
    next unless src_dir

    src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
    add_to_chain(src_file, id)
    source_files << src_file
  end

  # 4b. Canonical `## Links` projection (intent 72): born-canonical, drift PREVENTED
  # at the source. Build the cross-store resolver maps AFTER the chain backlinks are
  # written, then project the NEW intent's Links from its own frontmatter (sources at
  # birth, chain empty) and RE-project each source/parent's Links (it just gained the
  # new id in its chain). The fence-aware rewriter and the resolved-target dedup are
  # the same ones project-links and the doctor check use, so a freshly created intent
  # and its sources both PASS graph_links_projection. A no-source root gets the
  # canonical empty-state comment.
  plastic_home, referer_store_key = store_context(store)
  maps = build_cross_store_maps(plastic_home, referer_store_key, store)

  project_links_for(intent_file, referer_store_key, maps)
  source_files.uniq.each { |sf| project_links_for(sf, referer_store_key, maps) }

  # 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
  # line; the rendered template body follows so the file is a usable starting
  # point once an agent deletes the sentinel.
  %w[spec.md plan.md checklist.md outcome.md].each do |name|
    template_path = File.join(templates, name)
    body = File.exist?(template_path) ? File.read(template_path) : ""
    body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
    File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
  end

  # 6. Stamp the born savepoint line (intent 81). The first ledger line is the
  # `What` bookend, written deterministically at creation rather than relying on
  # a PostToolUse gate firing on the intent-file write (which is missed in some
  # sessions / harnesses). The intent file is never a sentinel placeholder, so
  # append_savepoint records `What  {id}--{slug}.md`. Idempotent: a later gate
  # fire adds nothing.
  Bridge.append_savepoint(intent_dir, intent_file)

  # 7. Self-validate (frontmatter + sanctioned sections), including that every
  # sources/chain cross-store token names a real store (intent 189 D3).
  known_stores = StoreDiscovery.known_slugs(plastic_home)
  result = IntentValidator.validate(intent_dir, known_stores: known_stores)
  unless result[:ok]
    warn "new-intent: scaffolded intent is NOT born complete:"
    result[:missing].each { |f| warn "  missing field: #{f}" }
    result[:errors].each { |e| warn "  #{e}" }
    exit 1
  end

  puts intent_dir
  exit 0
end

main(ARGV) if $PROGRAM_NAME == __FILE__
