# Copyright (c) 2023 Yuki Kimoto
# MIT License

class File::Find {
  version "0.023";
  
  use StringList;
  use File::Spec;
  use Sys;
  use Regex;
  use File::Basename;
  use Fn;
  use File::Find::Handler;
  use Sys::OS;
  
  static method find : void ($cb : File::Find::Handler, $top_dir : string, $options : object[] = undef) {
    
    unless ($cb) {
      die "The \$cb must be defined";
    }
    
    unless ($top_dir) {
      die "The \$top_dir must be defined";
    }
    
    if ($top_dir eq "") {
      die "The \$top_dir must be non-empty string";
    }
    
    # Options
    my $options_h = Hash->new($options);
    my $follow = $options_h->delete_or_default_int("follow", 0);
    for my $name (@{$options_h->keys}) {
      die "The \"$name\" option is invalid";
    }
    
    # The path separator is converted to "/"
    my $top_dir_slash = (string)undef;
    if (Sys::OS->is_windows) {
      $top_dir_slash = copy $top_dir;
      Fn->replace_chars((mutable string)$top_dir_slash, '\\', '/');
    }
    else {
      $top_dir_slash = $top_dir;
    }
    
    # The trailing "/" is removed
    unless (File::Spec->file_name_is_root($top_dir_slash)) {
      my $top_dir_slash_ref = [$top_dir_slash];
      
      Re->s($top_dir_slash_ref, "/\z", "");
      
      $top_dir_slash = $top_dir_slash_ref->[0];
    }
    
    my $found_files_h = Hash->new;
    
    &_find_dir($cb, $top_dir_slash, $follow, $found_files_h);
  }
  
  private static method _find_dir : void ($cb : File::Find::Handler, $dir_name : string, $follow : int, $found_files_h : Hash) {

    $cb->($dir_name, undef);

    $dir_name = &_resolve_dir($dir_name, $follow, $found_files_h);
    
    unless ($dir_name) {
      return;
    }
    
    my $dir_stream = Sys::IO->opendir($dir_name);
    my $file_base_names_list = StringList->new;
    
    while (my $dir_ent = Sys::IO->readdir($dir_stream)) {
      my $file_base_name = $dir_ent->d_name;
      $file_base_names_list->push($file_base_name);
    }
    my $file_base_names = $file_base_names_list->to_array;
    
    Sys::IO->closedir($dir_stream);
    
    for (my $i = 0; $i < @$file_base_names; $i++) {
      my $file_base_name = $file_base_names->[$i];
      
      if (Re->m($file_base_name, "^\.{1,2}\z")) {
        next;
      }
      
      my $next_dir_name = "$dir_name/$file_base_name";
      if (Sys->d($next_dir_name)) {
        &_find_dir($cb, $next_dir_name, $follow, $found_files_h);
      }
      else {
        $cb->($dir_name, $file_base_name);
      }
    }
  }
  
  private static method _resolve_dir : string ($path : string, $follow : int, $found_files_h : Hash) {
    
    if ($follow) {
      while (1) {
        my $is_symlink = Sys->l($path);
        
        unless ($is_symlink) {
          last;
        }
        
        my $lstat = Sys::IO::Stat->new;
        Sys::IO::Stat->lstat($path, $lstat);
        
        my $dev = $lstat->st_dev;
        my $inode = $lstat->st_ino;
        
        my $found = $found_files_h->get_int("$dev-$inode");
        
        if ($found) {
          die "The $path is encountered a second time";
        }
        
        my $link = Sys->readlink($path);
        
        if (Sys::OS->is_windows) {
          Fn->replace_chars((mutable string)$link, '\\', '/');
        }
        
        my $lstat_link = Sys::IO::Stat->new;
        my $lstat_link_status = Sys::IO::Stat->lstat($link, $lstat_link);
        
        if ($lstat_link_status == 0) {
          $path = $link;
        }
        else {
          $path = undef;
          last;
        }
      }
    }
    else {
      unless (Sys->d($path)) {
        $path = undef;
      }
    }
    
    return $path;
  }
}