uv/lib/dependabot/uv/file_updater/lock_file_updater.rb
# typed: strict
# frozen_string_literal: true

require "toml-rb"
require "open3"
require "dependabot/dependency"
require "dependabot/shared_helpers"
require "dependabot/uv/language_version_manager"
require "dependabot/uv/version"
require "dependabot/uv/requirement"
require "dependabot/uv/file_parser/python_requirement_parser"
require "dependabot/uv/file_updater"
require "dependabot/uv/native_helpers"
require "dependabot/uv/name_normaliser"

module Dependabot
  module Uv
    class FileUpdater
      class LockFileUpdater
        extend T::Sig

        require_relative "pyproject_preparer"

        REQUIRED_FILES = %w(pyproject.toml uv.lock).freeze # At least one of these files should be present

        sig { returns(T::Array[Dependency]) }
        attr_reader :dependencies

        sig { returns(T::Array[DependencyFile]) }
        attr_reader :dependency_files

        sig { returns(T::Array[Dependabot::Credential]) }
        attr_reader :credentials

        sig { returns(T.nilable(T::Array[T.nilable(String)])) }
        attr_reader :index_urls

        sig do
          params(
            dependencies: T::Array[Dependency],
            dependency_files: T::Array[DependencyFile],
            credentials: T::Array[Dependabot::Credential],
            index_urls: T.nilable(T::Array[T.nilable(String)])
          ).void
        end
        def initialize(dependencies:, dependency_files:, credentials:, index_urls: nil)
          @dependencies = dependencies
          @dependency_files = dependency_files
          @credentials = credentials
          @index_urls = index_urls
          @prepared_pyproject = T.let(nil, T.nilable(String))
          @updated_lockfile_content = T.let(nil, T.nilable(String))
          @pyproject = T.let(nil, T.nilable(Dependabot::DependencyFile))
        end

        sig { returns(T::Array[Dependabot::DependencyFile]) }
        def updated_dependency_files
          @updated_dependency_files ||= T.let(
            fetch_updated_dependency_files,
            T.nilable(T::Array[Dependabot::DependencyFile])
          )
        end

        private

        sig { returns(T.nilable(Dependabot::Dependency)) }
        def dependency
          # For now, we'll only ever be updating a single dependency
          T.must(dependencies.first)
        end

        sig { returns(T::Array[Dependabot::DependencyFile]) }
        def fetch_updated_dependency_files
          return [] unless create_or_update_lock_file?

          updated_files = []

          if file_changed?(pyproject)
            updated_files <<
              updated_file(
                file: T.must(pyproject),
                content: T.must(updated_pyproject_content)
              )
          end

          if lockfile
            # Use updated_lockfile_content which might raise if the lockfile doesn't change
            new_content = updated_lockfile_content
            raise "Expected lockfile to change!" if T.must(lockfile).content == new_content

            updated_files << updated_file(file: T.must(lockfile), content: new_content)
          end

          updated_files
        end

        sig { returns(T.nilable(String)) }
        def updated_pyproject_content
          content = T.must(pyproject).content
          return content unless file_changed?(T.must(pyproject))

          updated_content = content.dup

          T.must(dependency).requirements.zip(T.must(T.must(dependency).previous_requirements)).each do |new_r, old_r|
            next unless new_r[:file] == T.must(pyproject).name && T.must(old_r)[:file] == T.must(pyproject).name

            updated_content = replace_dep(T.must(dependency), T.must(updated_content), new_r, T.must(old_r))
          end

          raise DependencyFileContentNotChanged, "Content did not change!" if content == updated_content

          updated_content
        end

        sig do
          params(
            dep: Dependabot::Dependency,
            content: String,
            new_r: T::Hash[Symbol, T.untyped],
            old_r: T::Hash[Symbol, T.untyped]
          ).returns(String)
        end
        def replace_dep(dep, content, new_r, old_r)
          new_req = new_r[:requirement]
          old_req = old_r[:requirement]

          declaration_regex = declaration_regex(dep, old_r)
          declaration_match = content.match(declaration_regex)
          if declaration_match
            declaration = declaration_match[:declaration]
            new_declaration = T.must(declaration).sub(old_req, new_req)
            content.sub(T.must(declaration), new_declaration)
          else
            content
          end
        end

        sig { returns(String) }
        def updated_lockfile_content
          @updated_lockfile_content ||=
            begin
              original_content = T.must(lockfile).content
              # Extract the original requires-python value to preserve it
              original_requires_python = T.must(original_content)
                                          .match(/requires-python\s*=\s*["']([^"']+)["']/)&.captures&.first

              # Store the original Python version requirement for later use
              @original_python_version = T.let(original_requires_python, T.nilable(String))

              new_lockfile = updated_lockfile_content_for(prepared_pyproject)

              # Normalize line endings to ensure proper comparison
              new_lockfile = normalize_line_endings(new_lockfile, T.must(original_content))

              result = new_lockfile

              # Restore the original requires-python if it exists
              if original_requires_python
                result = result.gsub(
                  /requires-python\s*=\s*["'][^"']+["']/,
                  "requires-python = \"#{original_requires_python}\""
                )
              end

              result
            end
        end

        # Helper method to normalize line endings between two strings
        sig { params(content: String, reference: String).returns(String) }
        def normalize_line_endings(content, reference)
          # Check if reference has escaped newlines like "\n" +
          if reference.include?("\\n")
            content.gsub("\n", "\\n")
          else
            content
          end
        end

        sig { returns(String) }
        def prepared_pyproject
          @prepared_pyproject ||=
            begin
              content = updated_pyproject_content
              content = sanitize(T.must(content))
              content
            end
        end

        sig { params(pyproject_content: String).returns(String) }
        def sanitize(pyproject_content)
          PyprojectPreparer
            .new(pyproject_content: pyproject_content)
            .sanitize
        end

        sig { params(pyproject_content: String).returns(String) }
        def updated_lockfile_content_for(pyproject_content)
          SharedHelpers.in_a_temporary_directory do
            SharedHelpers.with_git_configured(credentials: credentials) do
              write_temporary_dependency_files(pyproject_content)

              # Set up Python environment using LanguageVersionManager
              setup_python_environment

              run_update_command

              File.read("uv.lock")
            end
          end
        end

        sig { returns(T.nilable(String)) }
        def run_update_command
          options = lock_options
          options_fingerprint = lock_options_fingerprint(options)

          # Use pyenv exec to ensure we're using the correct Python environment
          command = "pyenv exec uv lock --upgrade-package #{T.must(dependency).name} #{options}"
          fingerprint = "pyenv exec uv lock --upgrade-package <dependency_name> #{options_fingerprint}"

          run_command(command, fingerprint:)
        end

        sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
        def run_command(command, fingerprint: nil)
          Dependabot.logger.info("Running command: #{command}")
          SharedHelpers.run_shell_command(command, fingerprint: fingerprint)
        end

        sig { params(pyproject_content: String).returns(Integer) }
        def write_temporary_dependency_files(pyproject_content)
          dependency_files.each do |file|
            path = file.name
            FileUtils.mkdir_p(Pathname.new(path).dirname)
            File.write(path, file.content)
          end

          # Overwrite the pyproject with updated content
          File.write("pyproject.toml", pyproject_content)
        end

        sig { void }
        def setup_python_environment
          # Use LanguageVersionManager to determine and install the appropriate Python version
          Dependabot.logger.info("Setting up Python environment using LanguageVersionManager")

          begin
            # Install the required Python version
            language_version_manager.install_required_python

            # Set the local Python version
            python_version = language_version_manager.python_version
            Dependabot.logger.info("Setting Python version to #{python_version}")
            SharedHelpers.run_shell_command("pyenv local #{python_version}")

            # We don't need to install uv as it should be available in the Docker environment
            Dependabot.logger.info("Using pre-installed uv package")
          rescue StandardError => e
            Dependabot.logger.warn("Error setting up Python environment: #{e.message}")
            Dependabot.logger.info("Falling back to system Python")
          end
        end

        sig { params(url: String).returns(String) }
        def sanitize_env_name(url)
          url.gsub(%r{^https?://}, "").gsub(/[^a-zA-Z0-9]/, "_").upcase
        end

        sig { params(dep: T.untyped, old_req: T.untyped).returns(Regexp) }
        def declaration_regex(dep, old_req)
          escaped_name = Regexp.escape(dep.name)
          # Extract the requirement operator and version
          operator = old_req.fetch(:requirement).match(/^(.+?)[0-9]/)&.captures&.first
          # Escape special regex characters in the operator
          escaped_operator = Regexp.escape(operator) if operator

          # Match various formats of dependency declarations:
          # 1. "dependency==1.0.0" (with quotes around the entire string)
          # 2. dependency==1.0.0 (without quotes)
          # The declaration should only include the package name, operator, and version
          # without the enclosing quotes
          /
            ["']?(?<declaration>#{escaped_name}\s*#{escaped_operator}[\d\.\*]+)["']?
          /x
        end

        sig { returns(String) }
        def lock_options
          options = lock_index_options

          options.join(" ")
        end

        sig { returns(T::Array[String]) }
        def lock_index_options
          credentials
            .select { |cred| cred["type"] == "python_index" }
            .map do |cred|
            authed_url = AuthedUrlBuilder.authed_url(credential: cred)

            if cred.replaces_base?
              "--default-index #{authed_url}"
            else
              "--index #{authed_url}"
            end
          end
        end

        sig { params(options: String).returns(String) }
        def lock_options_fingerprint(options)
          options.sub(
            /--default-index\s+\S+/, "--default-index <default_index>"
          ).sub(
            /--index\s+\S+/, "--index <index>"
          )
        end

        sig { params(name: T.any(String, Symbol)).returns(String) }
        def escape(name)
          Regexp.escape(name).gsub("\\-", "[-_.]")
        end

        sig { params(file: T.nilable(DependencyFile)).returns(T::Boolean) }
        def file_changed?(file)
          return false unless file

          dependencies.any? do |dep|
            dep.requirements.any? { |r| r[:file] == file.name } &&
              requirement_changed?(file, dep)
          end
        end

        sig do
          params(file: T.nilable(DependencyFile), dependency: Dependency)
            .returns(T::Boolean)
        end
        def requirement_changed?(file, dependency)
          changed_requirements =
            dependency.requirements - T.must(dependency.previous_requirements)

          changed_requirements.any? { |f| f[:file] == T.must(file).name }
        end

        sig { params(file: Dependabot::DependencyFile, content: String).returns(Dependabot::DependencyFile) }
        def updated_file(file:, content:)
          updated_file = file.dup
          updated_file.content = content
          updated_file
        end

        sig { params(name: String).returns(String) }
        def normalise(name)
          NameNormaliser.normalise(name)
        end

        sig { returns(Dependabot::Uv::FileParser::PythonRequirementParser) }
        def python_requirement_parser
          @python_requirement_parser ||= T.let(
            FileParser::PythonRequirementParser.new(
              dependency_files: dependency_files
            ),
            T.nilable(FileParser::PythonRequirementParser)
          )
        end

        sig { returns(Dependabot::Uv::LanguageVersionManager) }
        def language_version_manager
          @language_version_manager ||= T.let(
            LanguageVersionManager.new(
              python_requirement_parser: python_requirement_parser
            ),
            T.nilable(LanguageVersionManager)
          )
        end

        sig { returns(T.nilable(Dependabot::DependencyFile)) }
        def pyproject
          @pyproject ||= T.let(
            dependency_files.find { |f| f.name == "pyproject.toml" },
            T.nilable(Dependabot::DependencyFile)
          )
        end

        sig { returns(T.nilable(Dependabot::DependencyFile)) }
        def lockfile
          @lockfile ||= T.let(uv_lock, T.nilable(Dependabot::DependencyFile))
        end

        sig { returns(String) }
        def python_helper_path
          NativeHelpers.python_helper_path
        end

        sig { returns(T.nilable(Dependabot::DependencyFile)) }
        def uv_lock
          dependency_files.find { |f| f.name == "uv.lock" }
        end

        sig { returns(T::Boolean) }
        def create_or_update_lock_file?
          T.must(dependency).requirements.select { _1[:file].end_with?(*REQUIRED_FILES) }.any?
        end
      end
    end
  end
end
