# frozen_string_literal: true
require_relative './environment.rb'

def app
  Sinatra::Application
end

describe Site do
  describe 'username' do
    it 'only passes valid hostnames for username' do
      _(Site.valid_username?('|\|0p|E')).must_equal false
      _(Site.valid_username?('nope-')).must_equal false
      _(Site.valid_username?('-nope')).must_equal false
      _(Site.valid_username?('do-pe')).must_equal true
      _(Site.valid_username?('d')).must_equal true
      _(Site.valid_username?('do')).must_equal true
    end
  end

  describe 'child sites' do
    it 'child sites do not require email validation' do
      parent_site = Fabricate :site, email: 'parent@example.com', email_confirmed: false
      child_site = Fabricate :site, parent_site_id: parent_site.id, email: nil

      _(parent_site.parent?).must_equal true
      _(child_site.parent?).must_equal false
      
      # Parent site needs email validation, child site does not
      _(parent_site.email_not_validated?).must_equal true
      _(child_site.email_not_validated?).must_equal false
    end
  end

  describe 'file type validation' do
    it 'allows css files when libmagic misidentifies them as appleworks' do
      _(Site.valid_file_mime_type_and_ext?('application/x-appleworks3', '.css')).must_equal true
    end

    it 'keeps the appleworks exception scoped to css files' do
      _(Site.valid_file_mime_type_and_ext?('application/x-appleworks3', '.txt')).must_be_nil
    end
  end

  describe 'tags' do
    it 'should handle empty tags in comma-separated list' do
      site = Fabricate.build :site, new_tags_string: 'art, , Seancore, microlables'
      _(site.valid?).must_equal true
      site.save
      _(site.tags.map(&:name).sort).must_equal ['art', 'microlables', 'seancore']
    end

    it 'should handle multiple empty tags' do
      site = Fabricate.build :site, new_tags_string: ', art, , , test, '
      _(site.valid?).must_equal true
      site.save
      _(site.tags.map(&:name).sort).must_equal ['art', 'test']
    end
  end

  describe 'email validation' do
    it 'accepts valid email addresses' do
      valid_emails = [
        'user@example.com',
        'test.email@domain.org',
        'user+tag@example.com',
        'user_name@domain.co.uk',
        'user-name@example-domain.com',
        'user@xn--1lqs71d.xn--wgv71a',
        'a.very.long.email.address@long-domain-name.example.com'
      ]

      valid_emails.each do |email|
        site = Fabricate.build(:site, email: email)
        site.valid?
        email_errors = site.errors[:email] || []
        format_errors = email_errors.select { |e| e.include?('valid email') }
        _(format_errors).must_be_empty
      end
    end

    it 'rejects invalid email addresses' do
      invalid_emails = [
        'user@domain.com💩💩💩',
        'user@domain.com extra text',
        'user @domain.com',
        'user@domain',
        'user@domain.',
        '@domain.com',
        'user@',
        'plaintext',
      ]

      invalid_emails.each do |email|
        site = Fabricate.build(:site, email: email)
        site.valid?
        email_errors = site.errors[:email] || []
        format_errors = email_errors.select { |e| e.include?('valid email') }
        _(format_errors).wont_be_empty
      end
    end

    it 'rejects emails that exceed byte length limit' do
      long_email = 'user@example.com' + 'a' * 300 # Will exceed 254 byte limit
      site = Fabricate.build(:site, email: long_email)
      site.valid?

      email_errors = site.errors[:email] || []
      length_errors = email_errors.select { |e| e.include?('too long') }
      _(length_errors).wont_be_empty
    end

    it 'rejects emails with unicode padding that exceed byte limit' do
      unicode_email = 'user@example.com' + '⠀' * 100 # Braille blank chars
      _(unicode_email.bytesize).must_be :>, Site::MAX_EMAIL_LENGTH

      site = Fabricate.build(:site, email: unicode_email)
      site.valid?

      email_errors = site.errors[:email] || []
      length_errors = email_errors.select { |e| e.include?('too long') }
      _(length_errors).wont_be_empty
    end

    it 'accepts emails just under the byte limit' do
      under_limit_email = 'a' * (Site::MAX_EMAIL_LENGTH - 15) + '@example.com'
      _(under_limit_email.bytesize).must_be :<=, Site::MAX_EMAIL_LENGTH

      site = Fabricate.build(:site, email: under_limit_email)
      site.valid?

      email_errors = site.errors[:email] || []
      length_errors = email_errors.select { |e| e.include?('too long') }
      _(length_errors).must_be_empty
    end
  end

  describe 'bitcoin address validation' do
    it 'accepts valid bitcoin addresses' do
      valid_addresses = [
        '1BoatSLRHtKNngkdXEeobR76b53LETtpyT',
        '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy',
        'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080',
        'BC1TESTADDRESS'
      ]

      valid_addresses.each do |address|
        _(Site.valid_bitcoin_address?(address)).must_equal true
      end
    end

    it 'rejects invalid bitcoin addresses' do
      invalid_addresses = [
        'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty',
        'bcrt1q4d4v8g4qv9nwx7v6p5d3y8d6p8ygj5y0yl9q9',
        'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn',
        'notabitcoinaddress'
      ]

      invalid_addresses.each do |address|
        _(Site.valid_bitcoin_address?(address)).must_equal false
      end
    end
  end

  describe 'banning' do
    it 'still makes files available' do
      site = Fabricate :site
      site.ban!
      _(File.exist?(site.current_files_path('index.html'))).must_equal true
      _(site.current_files_path('index.html')).must_equal File.join(Site::DELETED_SITES_ROOT, Site.sharding_dir(site.username), site.username, 'index.html')
    end

    it 'purges cache by default' do
      site = Fabricate :site
      PurgeCacheWorker.jobs.clear
      $redis_proxy.del(PurgeCacheWorker::PURGE_STREAM_KEY)

      expected_purges = site.site_files_dataset.count
      site.ban!
      PurgeCacheWorker.drain

      stream_length = $redis_proxy.xlen(PurgeCacheWorker::PURGE_STREAM_KEY)
      expected_stream_entries = expected_purges * (PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.length + 1)
      _(stream_length).must_equal expected_stream_entries
    end

    it 'can skip cache purge' do
      site = Fabricate :site
      PurgeCacheWorker.jobs.clear
      $redis_proxy.del(PurgeCacheWorker::PURGE_STREAM_KEY)

      site.ban!(false)
      PurgeCacheWorker.drain

      stream_length = $redis_proxy.xlen(PurgeCacheWorker::PURGE_STREAM_KEY)
      _(stream_length).must_equal 0
    end
  end

  describe 'unban' do
    it 'works' do
      site = Fabricate :site
      index_path = File.join site.base_files_path, 'index.html'
      site.ban!
      _(File.exist?(index_path)).must_equal false
      site.unban!
      site.reload
      _(site.is_banned).must_equal false
      _(site.banned_at).must_be_nil
      _(site.blackbox_whitelisted).must_equal true
      _(File.exist?(index_path)).must_equal true
    end
  end

  describe 'directory create' do
    it 'handles wacky pathnames' do
      ['/derp', '/derp/'].each do |path|
        site = Fabricate :site
        site_file_count = site.site_files_dataset.count
        site.create_directory path
        _(site.site_files.select {|s| s.path == '' || s.path == '.'}.length).must_equal 0
        _(site.site_files.select {|s| s.path == path.gsub('/', '')}.first).wont_be_nil
        _(site.site_files_dataset.count).must_equal site_file_count+1
      end
    end

    it 'scrubs ../ from directory' do
      site = Fabricate :site
      site.create_directory '../../test'
      _(site.site_files.select {|site_file| site_file.path =~ /\.\./}.length).must_equal 0
    end

    it 'blocks long directory create' do
      site = Fabricate :site
      long_path_string = 'a' * (SiteFile::FILE_PATH_CHARACTER_LIMIT + 1)
      res = site.create_directory long_path_string
      _(res).must_equal 'Directory path is too long.'
    end

    it 'blocks individual directory names that are too long' do
      site = Fabricate :site
      long_dir_name = 'a' * (SiteFile::FILE_NAME_CHARACTER_LIMIT + 1)
      res = site.create_directory "somedir/#{long_dir_name}/anotherdir"
      _(res).must_match /name is too long/i
    end

    it 'blocks directory names with backslashes' do
      site = Fabricate :site
      res = site.create_directory 'bad\\dir'
      _(res).must_equal 'Directory path contains invalid characters.'
      _(site.site_files_dataset.where(path: 'bad\\dir').count).must_equal 0
    end
  end

  describe 'scrubbed_path' do
    it 'preserves literal percent characters without URL decoding' do
      site = Fabricate :site

      test_paths = [
        '100% awesome.jpg',
        'derpking/70%off.png',
        '50% sale.txt',
        'discount%special.png',
        'garfield is 100% sexy.jpg',
        'path/with/100%valid.txt'
      ]

      test_paths.each do |path|
        scrubbed = site.scrubbed_path(path)
        _(scrubbed).must_equal path  # Should be exactly the same - no URL decoding
      end
    end

    it 'still handles path traversal and other security issues' do
      site = Fabricate :site

      # Should still block path traversal
      _(site.scrubbed_path('../../../etc/passwd')).must_equal 'etc/passwd'
      _(site.scrubbed_path('../../test')).must_equal 'test'

      # Should still remove empty components and dots
      _(site.scrubbed_path('/./test/./file.txt')).must_equal 'test/file.txt'
      _(site.scrubbed_path('test//file.txt')).must_equal 'test/file.txt'

      # But percent characters should be preserved
      _(site.scrubbed_path('test/70%off.png')).must_equal 'test/70%off.png'
    end

    it 'raises error for control characters' do
      site = Fabricate :site

      # Should still raise error for control characters (below ASCII 32)
      _(proc { site.scrubbed_path("test\x00file.txt") }).must_raise ArgumentError
      _(proc { site.scrubbed_path("test\x1Ffile.txt") }).must_raise ArgumentError
    end
  end

  describe 'custom_max_space' do
    it 'should use the custom max space if it is more' do
      site = Fabricate :site
      _(site.maximum_space).must_equal Site::PLAN_FEATURES[:free][:space]
      site.custom_max_space = 10**9
      site.save_changes
      _(site.maximum_space).must_equal 10**9
    end
  end

  describe 'can_email' do
    it 'should fail if send_emails is false' do
      site = Fabricate :site
      _(site.can_email?).must_equal true
      site.update send_emails: false
      _(site.can_email?).must_equal false
      _(site.can_email?(:send_comment_emails)).must_equal false
      site.update send_emails: true
      _(site.can_email?(:send_comment_emails)).must_equal true
      site.update send_comment_emails: false
      _(site.can_email?(:send_comment_emails)).must_equal false
    end
  end

  describe 'title extraction from HTML' do
    before do
      @site = Fabricate :site
    end

    it 'extracts valid UTF-8 titles correctly' do
      valid_html = '<html><head><title>Valid Title</title></head></html>'
      tempfile = Tempfile.new(['test', '.html'])
      tempfile.write(valid_html)
      tempfile.rewind
      
      original_title = @site.title
      @site.send(:store_file, 'index.html', tempfile, {})
      
      _(@site.title).must_equal 'Valid Title'
      tempfile.unlink
    end

    it 'handles invalid UTF-8 bytes gracefully' do
      invalid_html = "<html><head><title>Invalid\x83Title</title></head></html>"
      tempfile = Tempfile.new(['test', '.html'])
      tempfile.write(invalid_html)
      tempfile.rewind
      
      original_title = @site.title
      @site.send(:store_file, 'index.html', tempfile, {})
      
      _(@site.title).must_equal 'InvalidTitle'
      tempfile.unlink
    end
  end

  describe 'directory conflict handling' do
    it 'rejects file upload when directory exists with same name' do
      @site = Fabricate :site
      @site.create_directory('test.html')

      tmpfile = Tempfile.new
      tmpfile.write 'test'
      tmpfile.close

      results = @site.store_files [{filename: 'test.html', tempfile: tmpfile}]

      _(results[:error]).must_equal true
      _(results[:error_type]).must_equal 'directory_exists'
      _(results[:message]).must_include 'conflicts with an existing directory'
    end
  end

  describe 'send_email' do
    before do
      EmailWorker.jobs.clear
      @site = Fabricate :site
    end

    it 'works' do
      @site.send_email(subject: 'Subject', body: 'Body')
      _(EmailWorker.jobs.length).must_equal 1
      args = EmailWorker.jobs.first['args'].first
      _(args['from']).must_equal Site::FROM_EMAIL
      _(args['to']).must_equal @site.email
      _(args['subject']).must_equal 'Subject'
      _(args['body']).must_equal 'Body'
    end

    it 'fails if send_emails is false' do
      @site.update send_emails: false
      @site.send_email(subject: 'Subject', body: 'Body')
    end
  end

  describe 'plan_name' do
    it 'should set to free for missing stripe_customer_id' do
      site = Fabricate :site
      _(site.reload.plan_type).must_equal 'free'
    end

    it 'should be free for no plan_type entry' do
      site = Fabricate :site, stripe_customer_id: 'cust_derp'
      _(site.plan_type).must_equal 'free'
    end

    it 'should match plan_type' do
      %w{supporter free}.each do |plan_type|
        site = Fabricate :site, plan_type: plan_type
        _(site.plan_type).must_equal plan_type
      end
      site = Fabricate :site, plan_type: nil
      _(site.plan_type).must_equal 'free'
    end
  end

  describe 'suggestions' do
    it 'should return suggestions for tags' do
      site = Fabricate :site, new_tags_string: 'vegetables'
      Site::SUGGESTIONS_LIMIT.times { Fabricate :site, new_tags_string: 'vegetables', site_changed: true }

      _(site.suggestions.length).must_equal Site::SUGGESTIONS_LIMIT

      site.suggestions.each do |suggestion|
        _(suggestion.tags.first.name).must_equal 'vegetables'
      end

      site = Fabricate :site, new_tags_string: 'gardening'
      (Site::SUGGESTIONS_LIMIT-5).times {
        Fabricate :site, new_tags_string: 'gardening', views: Site::SUGGESTIONS_VIEWS_MIN, site_changed: true
      }

      _(site.suggestions.length).must_equal(Site::SUGGESTIONS_LIMIT)
    end
  end

  describe 'purge_cache' do
    before do
      @site = Fabricate :site
      PurgeCacheWorker.jobs.clear
    end

    it 'schedules follow-up purge jobs' do
      before_purge = Time.now.to_f
      @site.purge_cache '/file.png'
      after_purge = Time.now.to_f

      _(PurgeCacheWorker.jobs.length).must_equal PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.length + 1
      _(purge_cache_immediate_jobs.length).must_equal 1

      scheduled_jobs = purge_cache_scheduled_jobs.sort_by { |job| job['at'] }
      _(scheduled_jobs.length).must_equal PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.length
      scheduled_jobs.each do |job|
        _(job['args']).must_equal [@site.username, '/file.png']
      end

      PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.each_with_index do |delay, index|
        delay_seconds = delay.to_i
        _(scheduled_jobs[index]['at']).must_be :>=, before_purge + delay_seconds
        _(scheduled_jobs[index]['at']).must_be :<=, after_purge + delay_seconds
      end
    end

    it 'works for /index.html' do
      @site.purge_cache '/index.html'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/'
    end

    it 'works for /dir/index.html' do
      @site.purge_cache '/dir/index.html'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/dir/'
    end

    it 'works for /test.html' do
      @site.purge_cache '/test.html'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/test'
    end

    it 'works for /newdir/index.html' do
      @site.purge_cache '/newdir/test.html'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/newdir/test'
    end

    it 'works for /file.png' do
      @site.purge_cache '/file.png'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/file.png'
    end

    it 'works for /testdir/file.png' do
      @site.purge_cache '/testdir/file.png'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/testdir/file.png'
    end

    it 'works for /notindex.html' do
      @site.purge_cache '/notindex.html'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/notindex'
    end

    it 'works for index.html missing forward slash' do
      @site.purge_cache 'index.html'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/'
    end

    it 'works for photo.png missing forward slash' do
      @site.purge_cache 'photo.png'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/photo.png'
    end

    it 'works for testdir/photo.png missing forward slash' do
      @site.purge_cache 'testdir/photo.png'
      _(purge_cache_immediate_jobs.length).must_equal 1
      args = purge_cache_immediate_jobs.first['args']
      _(args.first).must_equal @site.username
      _(args.last).must_equal '/testdir/photo.png'
    end
  end
end
