package au.com.southsky.jfreesane;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.net.InetAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;

import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

/**
 * Tests JFreeSane's interactions with the backend.
 *
 * <p>
 * This test assumes a sane daemon is listening on port 6566 on the local host. The daemon must have
 * a password-protected device named 'test'. The username should be 'testuser' and the password
 * should be 'goodpass'.
 *
 * <p>
 * If you cannot run a SANE server locally, you can set the {@code SANE_TEST_SERVER_ADDRESS}
 * environment variable to the address of a SANE server in "host[:port]" format.
 *
 * <p>
 * If you can't create this test environment, feel free to add the {@link org.junit.Ignore}
 * annotation to the test class.
 *
 * @author James Ring ([email protected])
 */
@RunWith(JUnit4.class)
public class SaneSessionTest {

  private static final Logger log = Logger.getLogger(SaneSessionTest.class.getName());
  private SaneSession session;
  private SanePasswordProvider correctPasswordProvider =
      SanePasswordProvider.forUsernameAndPassword("testuser", "goodpass");

  @Rule public TemporaryFolder tempFolder = new TemporaryFolder();

  @Rule public ExpectedException expectedException = ExpectedException.none();

  @Before
  public void initSession() throws Exception {
    String address = System.getenv("SANE_TEST_SERVER_ADDRESS");
    if (address == null) {
      address = "localhost";
    }
    URI hostAndPort = URI.create("my://" + address);
    this.session =
        SaneSession.withRemoteSane(
            InetAddress.getByName(hostAndPort.getHost()),
            hostAndPort.getPort() == -1 ? 6566 : hostAndPort.getPort());
    session.setPasswordProvider(correctPasswordProvider);
  }

  @After
  public void closeSession() throws Exception {
    session.close();
  }

  @Test
  public void listDevicesSucceeds() throws Exception {
    List<SaneDevice> devices = session.listDevices();
    log.info("Got " + devices.size() + " device(s): " + devices);
    // Sadly the test device apparently does not show up in the device list.
    // assertThat(devices).isNotEmpty();
  }

  @Test
  public void openDeviceSucceeds() throws Exception {
    try (SaneDevice device = session.getDevice("test")) {
      device.open();
    }
  }

  @Test
  public void optionGroupsArePopulated() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      assertThat(device.getOptionGroups()).isNotEmpty();
    }
  }

  @Test
  public void imageAcquisitionSucceeds() throws Exception {
    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      BufferedImage image = device.acquireImage();
      File file = File.createTempFile("image", ".png", tempFolder.getRoot());
      ImageIO.write(image, "png", file);
      System.out.println("Successfully wrote " + file);
    }
  }

  @Test
  public void listOptionsSucceeds() throws Exception {
    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      List<SaneOption> options = device.listOptions();
      Assert.assertTrue("Expect multiple SaneOptions", options.size() > 0);
      System.out.println("We found " + options.size() + " options");
      for (SaneOption option : options) {
        System.out.println(option.toString());
        if (option.getType() != OptionValueType.BUTTON) {
          System.out.println(option.getValueCount());
        }
      }
    }
  }

  @Test
  public void getOptionValueSucceeds() throws Exception {
    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      List<SaneOption> options = device.listOptions();
      Assert.assertTrue("Expect multiple SaneOptions", options.size() > 0);
      // option 0 is always "Number of options"
      // must be greater than zero

      int optionCount = options.get(0).getIntegerValue();
      Assert.assertTrue("Option count must be > 0", optionCount > 0);

      // print out the value of all integer-valued options

      for (SaneOption option : options) {
        System.out.print(option.getTitle());

        if (!option.isActive()) {
          System.out.print(" [inactive]");
        } else {
          if (option.getType() == OptionValueType.INT
              && option.getValueCount() == 1
              && option.isActive()) {
            System.out.print("=" + option.getIntegerValue());
          } else if (option.getType() == OptionValueType.STRING) {
            System.out.print("=" + option.getStringValue(StandardCharsets.US_ASCII));
          }
        }

        System.out.println();
      }
    }
  }

  @Test
  public void setOptionValueSucceedsForString() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      SaneOption modeOption = device.getOption("mode");
      assertThat(modeOption.setStringValue("Gray")).isEqualTo("Gray");
    }
  }

  @Test
  public void adfAcquisitionSucceeds() throws Exception {
    SaneDevice device = session.getDevice("test");
    device.open();
    assertThat(device.getOption("source").getStringConstraints())
        .contains("Automatic Document Feeder");
    device.getOption("source").setStringValue("Automatic Document Feeder");

    for (int i = 0; i < 20; i++) {
      try {
        device.acquireImage();
      } catch (SaneException e) {
        if (e.getStatus() == SaneStatus.STATUS_NO_DOCS) {
          // out of documents to read, that's fine
          break;
        } else {
          throw e;
        }
      }
    }
  }

  @Test
  public void acquireImageSucceedsAfterOutOfPaperCondition() throws Exception {
    SaneDevice device = session.getDevice("test");
    device.open();
    assertThat(device.getOption("source").getStringConstraints())
        .contains("Automatic Document Feeder");
    device.getOption("source").setStringValue("Automatic Document Feeder");

    expectedException.expect(SaneException.class);
    expectedException.expectMessage("STATUS_NO_DOCS");
    for (int i = 0; i < 20; i++) {
      device.acquireImage();
    }
  }

  @Test
  public void acquireMonoImage() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      SaneOption modeOption = device.getOption("mode");
      assertEquals("Gray", modeOption.setStringValue("Gray"));
      BufferedImage image = device.acquireImage();

      File file = File.createTempFile("mono-image", ".png", tempFolder.getRoot());
      ImageIO.write(image, "png", file);
      System.out.println("Successfully wrote " + file);
    }
  }

  @Test
  public void readsAndSetsStringsCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      assertThat(device.getOption("mode").getStringValue(StandardCharsets.US_ASCII))
          .matches("Gray|Color");
      assertThat(device.getOption("mode").setStringValue("Gray")).isEqualTo("Gray");
      assertThat(device.getOption("mode").getStringValue(StandardCharsets.US_ASCII))
          .isEqualTo("Gray");
      assertThat(device.getOption("read-return-value").getStringValue(StandardCharsets.US_ASCII))
          .isEqualTo("Default");
    }
  }

  @Test
  public void readsFixedPrecisionCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();

      // this option gets rounded to the nearest whole number by the backend
      assertEquals(123, device.getOption("br-x").setFixedValue(123.456), 0.0001);
      assertEquals(123, device.getOption("br-x").getFixedValue(), 0.0001);
    }
  }

  @Test
  public void readsBooleanOptionsCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();

      SaneOption option = device.getOption("hand-scanner");
      assertThat(option.setBooleanValue(true)).isTrue();
      assertThat(option.getBooleanValue()).isTrue();
      assertThat(option.setBooleanValue(false)).isFalse();
      assertThat(option.getBooleanValue()).isFalse();
    }
  }

  @Test
  public void readsStringListConstraintsCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();

      SaneOption option = device.getOption("string-constraint-string-list");
      assertThat(option).isNotNull();
      assertThat(option.getConstraintType())
          .isEqualTo(OptionValueConstraintType.STRING_LIST_CONSTRAINT);
      assertThat(option.getStringConstraints())
          .has()
          .exactly(
              "First entry",
              "Second entry",
              "This is the very long third entry. Maybe the frontend has an idea how to display it");
    }
  }

  @Test
  public void readIntegerValueListConstraintsCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();

      SaneOption option = device.getOption("int-constraint-word-list");
      assertNotNull(option);
      assertEquals(OptionValueConstraintType.VALUE_LIST_CONSTRAINT, option.getConstraintType());
      assertEquals(
          Arrays.asList(-42, -8, 0, 17, 42, 256, 65536, 16777216, 1073741824),
          option.getIntegerValueListConstraint());
    }
  }

  @Test
  public void readFixedValueListConstraintsCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();

      SaneOption option = device.getOption("fixed-constraint-word-list");
      assertNotNull(option);
      assertEquals(OptionValueConstraintType.VALUE_LIST_CONSTRAINT, option.getConstraintType());
      List<Double> expected = Arrays.asList(-32.7d, 12.1d, 42d, 129.5d);
      List<Double> actual = option.getFixedValueListConstraint();
      assertEquals(expected.size(), actual.size());

      for (int i = 0; i < expected.size(); i++) {
        assertEquals(expected.get(i), actual.get(i), 0.00001);
      }
    }
  }

  @Test
  public void readIntegerConstraintRangeCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();

      SaneOption option = device.getOption("int-constraint-range");
      assertNotNull(option);
      assertEquals(OptionValueConstraintType.RANGE_CONSTRAINT, option.getConstraintType());
      assertEquals(4, option.getRangeConstraints().getMinimumInteger());
      assertEquals(192, option.getRangeConstraints().getMaximumInteger());
      assertEquals(2, option.getRangeConstraints().getQuantumInteger());
    }
  }

  @Test
  public void readFixedConstraintRangeCorrectly() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();

      SaneOption option = device.getOption("fixed-constraint-range");
      assertNotNull(option);
      assertEquals(OptionValueConstraintType.RANGE_CONSTRAINT, option.getConstraintType());
      assertEquals(-42.17, option.getRangeConstraints().getMinimumFixed(), 0.00001);
      assertEquals(32767.9999, option.getRangeConstraints().getMaximumFixed(), 0.00001);
      assertEquals(2.0, option.getRangeConstraints().getQuantumFixed(), 0.00001);
    }
  }

  @Test
  public void arrayOption() throws Exception {

    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      device.getOption("enable-test-options").setBooleanValue(true);

      SaneOption option = device.getOption("int-constraint-array-constraint-range");
      assertNotNull(option);
      assertThat(option.isConstrained()).isTrue();
      assertThat(option.getConstraintType()).isEqualTo(OptionValueConstraintType.RANGE_CONSTRAINT);
      assertEquals(OptionValueType.INT, option.getType());
      List<Integer> values = new ArrayList<>();

      RangeConstraint constraints = option.getRangeConstraints();
      for (int i = 0; i < option.getValueCount(); i++) {
        values.add(constraints.getMinimumInteger() + i * constraints.getQuantumInteger());
      }

      assertEquals(values, option.setIntegerValue(values));
      assertEquals(values, option.getIntegerArrayValue());
    }
  }

  @Test
  @Ignore // This test fails on Travis with UNSUPPORTED.
  public void multipleListDevicesCalls() throws Exception {
    session.listDevices();
    session.listDevices();
  }

  @Test
  public void multipleGetDeviceCalls() throws Exception {
    session.getDevice("test");
    session.getDevice("test");
  }

  @Test
  public void multipleOpenDeviceCalls() throws Exception {
    {
      SaneDevice device = session.getDevice("test");
      openAndCloseDevice(device);
    }

    {
      SaneDevice device = session.getDevice("test");
      openAndCloseDevice(device);
    }
  }

  @Test
  public void handScanning() throws Exception {
    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      device.getOption("hand-scanner").setBooleanValue(true);
      device.acquireImage();
    }
  }

  @Test
  public void threePassScanning() throws Exception {
    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      assertEquals(
          "Color pattern", device.getOption("test-picture").setStringValue("Color pattern"));
      assertEquals("Color", device.getOption("mode").setStringValue("Color"));
      assertTrue(device.getOption("three-pass").setBooleanValue(true));
      for (int i = 0; i < 5; i++) {
        File file = File.createTempFile("three-pass", ".png", tempFolder.getRoot());
        ImageIO.write(device.acquireImage(), "png", file);
        System.out.println("Wrote three-pass test to " + file);
      }
    }
  }

  @Test
  public void reducedArea() throws Exception {
    try (SaneDevice device = session.getDevice("test")) {
      device.open();
      device.getOption("mode").setStringValue("Color");
      device.getOption("resolution").setFixedValue(200);
      device.getOption("tl-x").setFixedValue(0.0);
      device.getOption("tl-y").setFixedValue(0.0);
      device.getOption("br-x").setFixedValue(105.0);
      device.getOption("br-y").setFixedValue(149.0);
      device.acquireImage();
    }
  }

  @Test
  public void passwordAuthentication() throws Exception {
    // assumes that test is a password-authenticated device
    SaneDevice device = session.getDevice("test");
    device.open();
    device.acquireImage();
  }

  /**
   * This test assumes that you have protected the "test" device with a username of "testuser" and a
   * password other than "badpassword".
   */
  @Test
  public void invalidPasswordCausesAccessDeniedError() throws Exception {
    session.setPasswordProvider(
        SanePasswordProvider.forUsernameAndPassword("testuser", "badpassword"));
    try (SaneDevice device = session.getDevice("test")) {
      expectedException.expect(SaneException.class);
      expectedException.expectMessage("STATUS_ACCESS_DENIED");
      device.open();
    }
  }

  /**
   * Checks to ensure a STATUS_ACCESS_DENIED exception is raised if the authenticator is unable to
   * authenticate.
   */
  @Test
  public void cannotAuthenticateThrowsAccessDeniedError() throws Exception {
    session.setPasswordProvider(
        new SanePasswordProvider() {
          @Override
          public String getUsername(String resource) {
            return null;
          }

          @Override
          public String getPassword(String resource) {
            return null;
          }

          @Override
          public boolean canAuthenticate(String resource) {
            return false;
          }
        });

    try (SaneDevice device = session.getDevice("test")) {
      expectedException.expect(SaneException.class);
      expectedException.expectMessage("STATUS_ACCESS_DENIED");
      device.open();
    }
  }

  @Test
  public void passwordAuthenticationFromLocalFileSpecified() throws Exception {
    File passwordFile = tempFolder.newFile("sane.pass");
    Files.write(
        passwordFile.toPath(), "testuser:goodpass:test".getBytes(StandardCharsets.US_ASCII));
    session.setPasswordProvider(
        SanePasswordProvider.usingSanePassFile(passwordFile.getAbsolutePath()));
    SaneDevice device = session.getDevice("test");
    device.open();
    device.acquireImage();
  }

  @Test
  public void listenerReceivesScanStartedEvent() throws Exception {
    final CompletableFuture<SaneDevice> notifiedDevice = new CompletableFuture<>();
    final AtomicInteger frameCount = new AtomicInteger();
    final Set<FrameType> framesSeen = EnumSet.noneOf(FrameType.class);

    ScanListener listener =
        new ScanListenerAdapter() {
          @Override
          public void scanningStarted(SaneDevice device) {
            notifiedDevice.complete(device);
          }

          @Override
          public void frameAcquisitionStarted(
              SaneDevice device,
              SaneParameters parameters,
              int currentFrame,
              int likelyTotalFrames) {
            frameCount.incrementAndGet();
            framesSeen.add(parameters.getFrameType());
          }
        };

    SaneDevice device = session.getDevice("test");
    device.open();
    device.getOption("resolution").setFixedValue(1200);
    device.getOption("mode").setStringValue("Color");
    device.getOption("three-pass").setBooleanValue(true);
    device.acquireImage(listener);
    assertThat(notifiedDevice.get()).isSameAs(device);
    assertThat(frameCount.get()).isEqualTo(3);
    assertThat(framesSeen).containsExactly(FrameType.RED, FrameType.GREEN, FrameType.BLUE);
  }

  private void openAndCloseDevice(SaneDevice device) throws Exception {
    try {
      device.open();
      device.listOptions();
    } finally {
      device.close();
    }
  }
}