require "fileutils"

module Adr
  # Mechanical migration pass. Converts detected units into new-format ADRs
  # under docs/adr/ — heuristic-filling only structurally unambiguous fields —
  # and emits a manifest of the interpretive fields an LLM must fill. Writes
  # nothing outside docs/adr/; never touches source files.
  class Migrator
    PENDING_MARKER = "_pending LLM fill (see migration manifest)_"
    NOT_STATED_MARKER = "_not stated in source_"

    # Mechanical status normalization: canonical lifecycle values plus the one
    # unambiguous client-facing synonym. Anything else is copied raw.
    STATUS_MAP = {
      "accepted" => "Accepted", "proposed" => "Proposed",
      "superseded" => "Superseded", "deprecated" => "Deprecated",
      "confirmed" => "Accepted", "settled" => "Accepted", "open" => "Proposed"
    }.freeze

    # Source heading -> target section. Interpretive targets (consequences,
    # alternatives) get PENDING when absent; core targets (context, decision)
    # get NOT_STATED — they are never invented, by an LLM or otherwise.
    SECTION_SOURCES = {
      "Context" => [/\Acontext\z/i],
      "Decision" => [/\Adecision\z/i],
      "Consequences" => [/\Aconsequences\z/i],
      "Alternatives rejected" => [/\Aalternatives( (rejected|considered))?\z/i]
    }.freeze

    # Heading synonyms mapped as a heuristic: the content is copied verbatim
    # from the source, but because the mapping is a judgment call the file's
    # confidence is capped at medium.
    SECTION_FALLBACKS = {
      "Context" => [/\A(why|rationale|problem|background)\z/i],
      "Consequences" => [/\A(impact|implications|trade-?offs)\z/i]
    }.freeze
    CORE_SECTIONS = ["Context", "Decision"].freeze
    FILL_FIELDS = { "consequences" => "Consequences",
                    "alternatives" => "Alternatives rejected" }.freeze

    def initialize(repository:, detector:)
      @repository = repository
      @detector = detector
    end

    def migrate!
      FileUtils.mkdir_p(@repository.dir)
      recognized, unrecognized = @detector.units.partition { |u| u.shape != :unrecognized }
      fresh = recognized.reject { |u| migrated_sources.include?(u.source_ref) }
      results = fresh.map { |unit| convert(unit) }
      finalize!
      manifest(results, unrecognized)
    end

    # fills: {"docs/adr/NNNN-x.md" => {"consequences" => "...", "status" => "..."}}
    # Only pending-marker sections and an empty status are fillable — the
    # manifest is enforced structurally, not trusted from the caller.
    def apply_fills!(fills)
      fills.each do |relpath, fields|
        path = File.join(@repository.root, relpath)
        record = Record.parse(File.read(path))
        fields.each { |field, value| apply_fill(record, field, value) }
        File.write(path, record.to_markdown)
      end
      finalize!
    end

    private

    def migrated_sources
      @repository.entries.filter_map { |e| e.record.frontmatter["migration_source"] }
    end

    def convert(unit)
      extraction = Extraction.new(unit)
      sections, llm_fields, fallback_used = build_sections(extraction)
      confidence = confidence_for(sections, llm_fields, extraction, fallback_used)
      llm_fields << "status" if extraction.status.to_s.empty?

      record = Record.new(
        frontmatter: {
          "id" => @repository.next_id,
          "date" => extraction.date,
          "status" => extraction.status,
          "supersedes" => nil, "superseded-by" => nil,
          "affected-paths" => [], "affected-components" => [],
          "migration_confidence" => confidence,
          "migration_source" => unit.source_ref
        },
        title: unit.title,
        sections: sections
      )
      path = @repository.write(record)
      { "file" => relative(path), "source" => unit.source_ref, "shape" => unit.shape.to_s,
        "confidence" => confidence, "llm_fields" => llm_fields,
        "source_material" => llm_fields.empty? ? nil : unit.body }.compact
    end

    def build_sections(extraction)
      llm_fields = []
      sections = {}
      fallback_used = false
      Record::SECTION_ORDER.each do |name|
        found = extraction.section(name)
        if found.nil? && (fallback = extraction.fallback_section(name))
          found = fallback
          fallback_used = true
        end
        sections[name] =
          if found
            found
          elsif CORE_SECTIONS.include?(name)
            NOT_STATED_MARKER
          else
            llm_fields << FILL_FIELDS.key(name)
            PENDING_MARKER
          end
      end
      [sections, llm_fields, fallback_used]
    end

    def confidence_for(sections, llm_fields, extraction, fallback_used)
      return "low" if CORE_SECTIONS.any? { |name| sections[name] == NOT_STATED_MARKER }
      return "medium" if llm_fields.any? || fallback_used || extraction.status.to_s.empty?

      "high"
    end

    def apply_fill(record, field, value)
      if field == "status"
        record.frontmatter["status"] = value if record.status.to_s.empty?
      elsif (section = FILL_FIELDS[field])
        record.sections[section] = value if record.sections[section] == PENDING_MARKER
      end
    end

    def finalize!
      Index.new(repository: @repository).regenerate!
      MigrationReport.new(repository: @repository, detector: @detector).write!
    end

    def manifest(results, unrecognized)
      {
        "migrated" => results,
        "unrecognized" => unrecognized.map { |u| { "source" => u.source_ref, "title" => u.title } },
        "flag_rate" => MigrationReport.new(repository: @repository, detector: @detector).flag_rate
      }
    end

    def relative(path)
      path.sub(%r{\A#{Regexp.escape(@repository.root)}/?}, "")
    end

    # Mechanical field extraction from one unit's text: only structurally
    # unambiguous values (labelled lines, exact headings, ISO dates).
    class Extraction
      def initialize(unit)
        @unit = unit
        @body = unit.body
      end

      def status
        raw = labelled_value("Status") || heading_section("Status")&.lines&.first&.strip
        return nil if raw.nil? || raw.empty?

        STATUS_MAP.fetch(raw.split(/[\s(]/).first.downcase, raw)
      end

      def date
        raw = labelled_value("Date") || labelled_value("Decided")
        iso = raw&.[](/\d{4}-\d{2}-\d{2}/) ||
              @unit.source_path[/\d{4}-\d{2}-\d{2}/]
        iso && Date.parse(iso)
      end

      def section(target)
        match_section(SECTION_SOURCES.fetch(target))
      end

      def fallback_section(target)
        match_section(SECTION_FALLBACKS.fetch(target, []))
      end

      private

      def match_section(patterns)
        patterns.each do |pattern|
          headings.each do |name, content|
            return content if name.match?(pattern) && !content.empty?
          end
        end
        nil
      end

      def labelled_value(label)
        @body[/^[-*]?\s*\**#{label}\**\s*[:：]\s*\**([^\n*]+)/i, 1]&.strip
      end

      def heading_section(name)
        headings.find { |heading, _| heading.match?(/\A#{name}\z/i) }&.last
      end

      def headings
        @headings ||= heading_sections + bold_label_sections
      end

      def heading_sections
        @body.scan(/^\#{2,4} +([^\n]+)\n(.*?)(?=^\#{2,4} |\z)/m)
             .map { |name, content| [name.strip, strip_trailing_rule(content)] }
      end

      # "**Context.** text..." paragraphs (bold-label style, e.g. llf-schema):
      # the label acts as a section heading; content runs to the next bold
      # label, heading, or end of unit.
      def bold_label_sections
        @body.scan(/^\*\*([A-Za-z][^*\n]{0,40}?)[.:]?\*\*[.:]?\s*(.*?)(?=^\*\*[A-Za-z]|^\#{1,4} |\z)/m)
             .map { |name, content| [name.strip, strip_trailing_rule(content)] }
             .reject { |_, content| content.empty? }
      end

      def strip_trailing_rule(content)
        content.strip.sub(/\n+---+\s*\z/, "").strip
      end
    end
  end
end
