require 'rails_helper'
require 'tempfile'

RSpec.describe Post, type: :model do
  before do
    PostUrlSanitisationRule.unscoped.delete_all
  end

  describe 'URL normalisation' do
    it 'normalises the HTTP URL before applying sanitisation rules' do
      PostUrlSanitisationRule.create!(
        priority: 10,
        source_pattern: '\\Ahttps://example\\.com/videos/([^/]+)\\z',
        replacement: 'https://example.com/watch/\\1'
      )

      post = described_class.create!(
        title: 'normalised URL',
        url: '  https://EXAMPLE.com/videos/123/  '
      )

      expect(post.url).to eq('https://example.com/watch/123')
    end

    it 'does not normalise an unchanged URL when another attribute changes' do
      post = create(:post)
      post.update_column(:url, 'https://EXAMPLE.com/unchanged/')

      post.update!(title: 'updated title')

      expect(post.reload.url).to eq('https://EXAMPLE.com/unchanged/')
    end

    it 'validates the sanitised URL length' do
      path = 'a' * 375

      PostUrlSanitisationRule.create!(
        priority: 10,
        source_pattern: '\\Ahttps://example\\.com/(a+)\\z',
        replacement: 'https://example.com/\\1\\1'
      )

      post = described_class.new(
        title: 'long URL',
        url: "https://example.com/#{ path }"
      )

      expect(post).to be_invalid
      expect(post.errors.details.fetch(:url)).to include(
        error: :too_long,
        count: 768
      )
    end

    it 'validates uniqueness after sanitisation' do
      PostUrlSanitisationRule.create!(
        priority: 10,
        source_pattern: '\\Ahttps://example\\.com/alias\\z',
        replacement: 'https://example.com/canonical'
      )
      create(:post, url: 'https://example.com/canonical')

      post = described_class.new(title: 'duplicate URL', url: 'https://example.com/alias')

      expect(post).to be_invalid
      expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
    end
  end

  describe 'thumbnail processing' do
    def image_blob(width:, height:, background:, draw: nil)
      Tempfile.create(['post-thumbnail', '.png']) do |file|
        MiniMagick::Tool::Convert.new do |convert|
          convert.size "#{ width }x#{ height }"
          convert.xc background
          draw&.call(convert)
          convert << file.path
        end
        File.binread(file.path)
      end
    end

    def upload_for(blob)
      StringIO.new(blob).tap(&:rewind)
    end

    def read_image(attachment)
      blob =
          attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
      MiniMagick::Image.read(blob)
    end

    def colour_at(image, x, y)
      image.get_pixels.fetch(y).fetch(x)
    end

    def expect_green(pixel)
      expect(pixel[1]).to be > pixel[0] + 40
      expect(pixel[1]).to be > pixel[2] + 40
    end

    def expect_red(pixel)
      expect(pixel[0]).to be > pixel[1] + 40
      expect(pixel[0]).to be > pixel[2] + 40
    end

    def expect_blue(pixel)
      expect(pixel[2]).to be > pixel[0] + 40
      expect(pixel[2]).to be > pixel[1] + 40
    end

    describe '.resized_thumbnail_attachment' do
      it 'fits a wide image within 180x180 without distorting it' do
        blob = image_blob(
            width: 360,
            height: 180,
            background: 'red',
            draw: -> convert {
              convert.fill 'green'
              convert.draw 'rectangle 90,0 269,179'
            })

        resized = described_class.resized_thumbnail_attachment(upload_for(blob))
        image = read_image(resized)

        expect(image.dimensions).to eq([180, 90])
        expect_red(colour_at(image, 0, 45))
        expect_green(colour_at(image, 90, 45))
        expect_red(colour_at(image, 179, 45))
      end

      it 'fits a tall image within 180x180 without distorting it' do
        blob = image_blob(
            width: 180,
            height: 360,
            background: 'red',
            draw: -> convert {
              convert.fill 'green'
              convert.draw 'rectangle 0,90 179,269'
            })

        resized = described_class.resized_thumbnail_attachment(upload_for(blob))
        image = read_image(resized)

        expect(image.dimensions).to eq([90, 180])
        expect_red(colour_at(image, 45, 0))
        expect_green(colour_at(image, 45, 90))
        expect_red(colour_at(image, 45, 179))
      end

      it 'keeps a square image square without distortion' do
        blob = image_blob(
            width: 180,
            height: 180,
            background: 'red',
            draw: -> convert {
              convert.fill 'blue'
              convert.draw 'rectangle 90,0 179,179'
            })

        resized = described_class.resized_thumbnail_attachment(upload_for(blob))
        image = read_image(resized)

        expect(image.dimensions).to eq([180, 180])
        expect_red(colour_at(image, 20, 90))
        expect_blue(colour_at(image, 160, 90))
      end
    end

    describe '#attach_thumbnail_from_url!' do
      it 'attaches a fetched remote image through the common resize path' do
        blob = image_blob(
            width: 240,
            height: 180,
            background: 'red',
            draw: -> convert {
              convert.fill 'green'
              convert.draw 'rectangle 30,0 209,179'
            })
        response = Preview::HttpFetcher::Response.new(
            blob,
            'image/png',
            'https://example.com/thumb.png')
        allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
            .with('https://example.com/thumb.png')
            .and_return(response)

        post = described_class.create!(title: 'title', url: 'https://example.com/post')

        expect(post.thumbnail).to receive(:attach).once.and_call_original
        post.attach_thumbnail_from_url!('https://example.com/thumb.png')

        expect(post.thumbnail).to be_attached
        image = read_image(post.thumbnail)
        expect(image.dimensions).to eq([180, 135])
      end

      it 'does not attach anything when thumbnail conversion fails' do
        response = Preview::HttpFetcher::Response.new(
            'not-an-image',
            'image/png',
            'https://example.com/thumb.png')
        allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
            .with('https://example.com/thumb.png')
            .and_return(response)
        allow(described_class).to receive(:resized_thumbnail_attachment)
            .and_raise(MiniMagick::Error, 'convert failed')

        post = described_class.create!(title: 'title', url: 'https://example.com/post')

        expect {
          post.attach_thumbnail_from_url!('https://example.com/thumb.png')
        }.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')

        expect(post.thumbnail).not_to be_attached
      end

      it 'keeps an existing thumbnail when remote conversion fails' do
        existing = described_class.create!(title: 'title', url: 'https://example.com/post')
        existing.thumbnail.attach(
            io: StringIO.new('existing'),
            filename: 'existing.jpg',
            content_type: 'image/jpeg')
        blob_id = existing.thumbnail.blob.id
        response = Preview::HttpFetcher::Response.new(
            'not-an-image',
            'image/png',
            'https://example.com/thumb.png')
        allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
            .with('https://example.com/thumb.png')
            .and_return(response)
        allow(described_class).to receive(:resized_thumbnail_attachment)
            .and_raise(MiniMagick::Error, 'convert failed')

        expect {
          existing.attach_thumbnail_from_url!('https://example.com/thumb.png')
        }.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')

        existing.reload
        expect(existing.thumbnail).to be_attached
        expect(existing.thumbnail.blob.id).to eq(blob_id)
      end
    end
  end

  describe 'original created datetime validation' do
    it 'allows unrelated updates on persisted posts with second-bearing datetimes' do
      post = described_class.create!(title: 'title', url: 'https://example.com/post')
      post.update_columns(
        original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
        original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
      )

      post.title = 'updated title'

      expect(post).to be_valid
      expect { post.save! }.not_to raise_error
    end

    it 'rejects second-bearing updates when the datetime field changes' do
      post = described_class.create!(title: 'title', url: 'https://example.com/post')

      post.original_created_from = '2024-01-01T12:34:30Z'

      expect(post).to be_invalid
      expect(post.errors[:original_created_from]).to eq(
        [described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
      )
    end

    it 'accepts fixing persisted datetimes to minute precision' do
      post = described_class.create!(title: 'title', url: 'https://example.com/post')
      post.update_columns(
        original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
        original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
      )

      post.original_created_from = '2024-01-01T12:34Z'
      post.original_created_before = '2024-01-01T12:35Z'

      expect(post).to be_valid
      expect { post.save! }.not_to raise_error
    end

    it 'adds only the minute-precision error for second precision values' do
      post = described_class.new(
        title: 'title',
        url: 'https://example.com/post',
        original_created_from: '2024-01-01T12:34:30',
        original_created_before: '2024-01-01T12:35'
      )

      expect(post).to be_invalid
      expect(post.errors[:original_created_from]).to eq(
        [described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
      )
      expect(post.errors[:original_created_at]).to be_empty
    end

    it 'adds only the minute-precision error for fractional-second values' do
      post = described_class.new(
        title: 'title',
        url: 'https://example.com/post',
        original_created_from: '2024-01-01T12:34:00.123',
        original_created_before: '2024-01-01T12:35'
      )

      expect(post).to be_invalid
      expect(post.errors[:original_created_from]).to eq(
        [described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
      )
      expect(post.errors[:original_created_at]).to be_empty
    end

    it 'checks range rules only for valid minute-precision endpoints' do
      post = described_class.new(
        title: 'title',
        url: 'https://example.com/post',
        original_created_from: '2024-01-01T12:34',
        original_created_before: '2024-01-01T12:34'
      )

      expect(post).to be_invalid
      expect(post.errors[:original_created_at]).to eq(
        [described_class::ORIGINAL_CREATED_ORDER_MESSAGE]
      )
    end

    it 'accepts a one-minute range at minute precision' do
      post = described_class.new(
        title: 'title',
        url: 'https://example.com/post',
        original_created_from: '2024-01-01T12:34',
        original_created_before: '2024-01-01T12:35'
      )

      expect(post).to be_valid
    end

    it 'rejects invalid calendar dates and invalid hours' do
      invalid_dates = [
        '2024-02-31T12:00',
        '2023-02-29T12:00',
        '2024-02-29T24:00'
      ]

      invalid_dates.each do |value|
        post = described_class.new(
          title: 'title',
          url: 'https://example.com/post',
          original_created_from: value
        )

        expect(post).to be_invalid
        expect(post.errors[:original_created_from]).to eq(
          [described_class::ORIGINAL_CREATED_INVALID_MESSAGE]
        )
      end
    end
  end
end
