class ReactOnRails::VersionChecker::NodePackageVersion

rubocop:disable Metrics/ClassLength

def self.build

def self.build
  new(package_json_path, yarn_lock_path, package_lock_path)
end

def self.package_json_path

def self.package_json_path
  Rails.root.join(ReactOnRails.configuration.node_modules_location, "package.json")
end

def self.package_lock_path

def self.package_lock_path
  # Lockfiles are in the same directory as package.json
  # If node_modules_location is empty, use Rails.root
  base_dir = ReactOnRails.configuration.node_modules_location.presence || ""
  Rails.root.join(base_dir, "package-lock.json").to_s
end

def self.yarn_lock_path

def self.yarn_lock_path
  # Lockfiles are in the same directory as package.json
  # If node_modules_location is empty, use Rails.root
  base_dir = ReactOnRails.configuration.node_modules_location.presence || ""
  Rails.root.join(base_dir, "yarn.lock").to_s
end

def initialize(package_json, yarn_lock = nil, package_lock = nil)

def initialize(package_json, yarn_lock = nil, package_lock = nil)
  @package_json = package_json
  @yarn_lock = yarn_lock
  @package_lock = package_lock
end

def local_path_or_url?

def local_path_or_url?
  # See https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies
  # All path and protocol "version ranges" include / somewhere,
  # but we want to make an exception for npm:@scope/pkg@version.
  !raw.nil? && raw.include?("/") && !raw.start_with?("npm:")
end

def local_path_or_url_version?(version)

Check if a version string represents a local path or URL
def local_path_or_url_version?(version)
  return false if version.nil?
  version.include?("/") && !version.start_with?("npm:")
end

def package_installed?(package_name)

def package_installed?(package_name)
  return false unless File.exist?(package_json)
  parsed = parsed_package_contents
  parsed.dig("dependencies", package_name).present?
end

def package_json_contents

def package_json_contents
  @package_json_contents ||= File.read(package_json)
end

def package_name

def package_name
  return "react-on-rails-pro" if react_on_rails_pro_package?
  "react-on-rails"
end

def parsed_package_contents

def parsed_package_contents
  return @parsed_package_contents if defined?(@parsed_package_contents)
  begin
    @parsed_package_contents = JSON.parse(package_json_contents)
  rescue JSON::ParserError => e
    raise ReactOnRails::Error, <<~MSG.strip
      **ERROR** ReactOnRails: Failed to parse package.json file.
      Location: #{package_json}
      Error: #{e.message}
      The package.json file contains invalid JSON. Please check the file for syntax errors.
      Common issues:
        - Missing or extra commas
        - Unquoted keys or values
        - Trailing commas (not allowed in JSON)
        - Comments (not allowed in standard JSON)
    MSG
  end
end

def parts

def parts
  return if local_path_or_url?
  match = raw.match(VERSION_PARTS_REGEX)
  unless match
    raise ReactOnRails::Error, "Cannot parse version number '#{raw}' (only exact versions are supported)"
  end
  match.captures.compact
end

def range_operator?

def range_operator?
  raw.start_with?(/[~^><*]/)
end

def range_syntax?

def range_syntax?
  raw.include?(" - ") || raw.include?(" || ")
end

def raw

def raw
  return @raw if defined?(@raw)
  return @raw = nil unless File.exist?(package_json)
  parsed = parsed_package_contents
  return @raw = nil unless parsed.key?("dependencies")
  deps = parsed["dependencies"]
  # Check for react-on-rails-pro first (Pro takes precedence)
  if deps.key?("react-on-rails-pro")
    @raw = resolve_version(deps["react-on-rails-pro"], "react-on-rails-pro")
    return @raw
  end
  # Fall back to react-on-rails
  if deps.key?("react-on-rails")
    @raw = resolve_version(deps["react-on-rails"], "react-on-rails")
    return @raw
  end
  # Neither package found
  msg = "No 'react-on-rails' or 'react-on-rails-pro' entry in the dependencies of " \
        "#{NodePackageVersion.package_json_path}, which is the expected location according to " \
        "ReactOnRails.configuration.node_modules_location"
  Rails.logger.warn(msg)
  @raw = nil
end

def react_on_rails_package?

def react_on_rails_package?
  package_installed?("react-on-rails")
end

def react_on_rails_pro_package?

def react_on_rails_pro_package?
  package_installed?("react-on-rails-pro")
end

def resolve_version(package_json_version, package_name)

rubocop:disable Metrics/CyclomaticComplexity
Resolve version from lockfiles if available, otherwise use package.json version
def resolve_version(package_json_version, package_name)
  # If package.json specifies a local path or URL, don't try to resolve from lockfiles
  # Lockfiles may contain placeholder versions like "0.0.0" for local links
  return package_json_version if local_path_or_url_version?(package_json_version)
  # Try yarn.lock first
  if yarn_lock && File.exist?(yarn_lock)
    lockfile_version = version_from_yarn_lock(package_name)
    return lockfile_version if lockfile_version
  end
  # Try package-lock.json
  if package_lock && File.exist?(package_lock)
    lockfile_version = version_from_package_lock(package_name)
    return lockfile_version if lockfile_version
  end
  # Fall back to package.json version
  package_json_version
end

def semver_wildcard?

def semver_wildcard?
  # See https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies
  # We want to disallow all expressions other than exact versions
  # and the ones allowed by local_path_or_url?
  return true if raw.blank?
  special_version_string? || wildcard_or_x_range? || range_operator? || range_syntax?
end

def special_version_string?

def special_version_string?
  %w[latest next canary beta alpha rc].include?(raw.downcase)
end

def version_from_package_lock(package_name)

rubocop:disable Metrics/CyclomaticComplexity
Supports both v1 (dependencies) and v2/v3 (packages) formats
Parse version from package-lock.json
def version_from_package_lock(package_name)
  return nil unless package_lock && File.exist?(package_lock)
  begin
    parsed = JSON.parse(File.read(package_lock))
    # Try v2/v3 format first (packages)
    if parsed["packages"]
      # Look for node_modules/package-name entry
      node_modules_key = "node_modules/#{package_name}"
      package_data = parsed["packages"][node_modules_key]
      return package_data["version"] if package_data&.key?("version")
    end
    # Fall back to v1 format (dependencies)
    if parsed["dependencies"]
      dependency_data = parsed["dependencies"][package_name]
      # In v1, the dependency can be a hash with a "version" key
      return dependency_data["version"] if dependency_data.is_a?(Hash) && dependency_data.key?("version")
    end
  rescue JSON::ParserError
    # If we can't parse the lockfile, fall back to package.json version
    nil
  end
  nil
end

def version_from_yarn_lock(package_name)

rubocop:disable Metrics/CyclomaticComplexity
(e.g., "react-on-rails" won't match "react-on-rails-pro")
The pattern ensures exact package name match to avoid matching similar names
version "16.1.1"
react-on-rails@^16.1.1:
Looks for entries like:
Parse version from yarn.lock
def version_from_yarn_lock(package_name)
  return nil unless yarn_lock && File.exist?(yarn_lock)
  in_package_block = false
  File.foreach(yarn_lock) do |line|
    # Check if we're starting the block for our package
    # Pattern: optionally quoted package name, followed by @, ensuring it's not followed by more word chars
    # This prevents "react-on-rails" from matching "react-on-rails-pro"
    if line.match?(/^"?#{Regexp.escape(package_name)}@/)
      in_package_block = true
      next
    end
    # If we're in the package block, look for the version line
    if in_package_block
      # Version line looks like:  version "16.1.1"
      if (match = line.match(/^\s+version\s+"([^"]+)"/))
        return match[1]
      end
      # If we hit a blank line or new package, we've left the block
      break if line.strip.empty? || (line[0] != " " && line[0] != "\t")
    end
  end
  nil
end

def wildcard_or_x_range?

def wildcard_or_x_range?
  raw == "*" ||
    raw =~ /^[xX*]$/ ||
    raw =~ /^[xX*]\./ ||
    raw =~ /\.[xX*]\b/ ||
    raw =~ /\.[xX*]$/
end