/*
 * RiskScape™ Copyright New Zealand Institute for Earth Science Limited
 * (Earth Sciences New Zealand) is distributed for research purposes only
 * under the terms of AGPLv3.
 *
 * RiskScape™ Copyright 2026 New Zealand Institute for Earth Science
 * Limited (Earth Sciences New Zealand). All rights reserved. Source code
 * available under the AGPLv3.
 * 
 * This program is free software: you can redistribute it and/or modify it under
 *  the terms of the GNU Affero General Public License as published by the Free
 *  Software Foundation, either version 3 of the License, or (at your option) any
 *  later version.
 * 
 * This program is distributed for RESEARCH PURPOSES ONLY, in the hope that it will
 * be useful for research and education initiatives.
 * 
 * If you are not a researcher, or you are a researcher who wishes to use this
 * program on terms other than AGPLv3 (including those who wish to restrict the
 * distribution of any source code created using this program), please contact:
 * https://riskscape.org.nz
 * 
 * This program is distributed WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Affero General Public License for more details.  You should have received a copy
 * of the GNU Affero General Public License along with this program.  If not, see
 * <http://www.gnu.org/licenses/>.
 * 
 * By way of summary only, under the AGPLv3:
 *     • Permissions of this strongest copyleft license are conditioned
 *       on making available complete source code of licensed works and
 *       modifications, which include larger works using a licensed work,
 *       under the same license.
 *     • Copyright and license notices must be preserved.
 *     • Contributors provide an express grant of patent rights.
 *     • When a modified version is used to provide a service over a
 *       network, the complete source code of the modified version must be made
 *       available.
 */
package nz.org.riskscape.engine.output;

import static nz.org.riskscape.engine.Assert.*;
import static nz.org.riskscape.engine.output.ShapeFileNullMapper.*;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;

import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.data.shapefile.dbf.DbaseFileHeader;
import org.geotools.data.shapefile.dbf.DbaseFileWriter;
import org.geotools.data.shapefile.shp.ShapeType;
import org.geotools.data.shapefile.shp.ShapefileWriter;
import org.geotools.referencing.CRS;
import org.junit.Before;
import org.junit.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.MultiLineString;
import org.locationtech.jts.geom.MultiPoint;
import org.locationtech.jts.geom.MultiPolygon;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.mockito.ArgumentCaptor;

import com.google.common.collect.Lists;
import com.google.common.io.Files;

import nz.org.riskscape.engine.CrsHelper;
import nz.org.riskscape.engine.GeoHelper;
import nz.org.riskscape.engine.GeometryProblems;
import nz.org.riskscape.engine.ProjectTest;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.SRIDSet;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.gt.GeometryHelper;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Referenced;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.StandardCodes;

public class ShapefileWriter2Test extends ProjectTest implements CrsHelper {

  File tmpFile;
  GeometryFactory gf = new GeometryFactory();

  Point point1 = gf.createPoint(new Coordinate(2, 4));
  Point point2 = gf.createPoint(new Coordinate(6, 7));

  LineString ls1 = gf.createLineString(new Coordinate[] {new Coordinate(0, 0), new Coordinate(1, 1)});
  LineString ls2 = gf.createLineString(new Coordinate[] {new Coordinate(0, 0), new Coordinate(3, 3)});

  Polygon polygon1 = gf.createPolygon(new Coordinate[] {
      new Coordinate(0, 0), new Coordinate(1, 1), new Coordinate(2, 2), new Coordinate(0, 0)
  });
  Polygon polygon2 = gf.createPolygon(new Coordinate[] {
      new Coordinate(0, 0), new Coordinate(2, 2), new Coordinate(4, 4), new Coordinate(0, 0)
  });


  SRIDSet sridSet = project.getSridSet();

  List<Problem> problems = new ArrayList<>();
  ProblemSink problemSink = (p) -> problems.add(p);

  boolean prjSingleLine = false;

  @Before
  public void createTempFile() throws IOException {
    tmpFile = File.createTempFile("test", ".shp");
  }

  DbaseFileWriter mockDbaseWriter = mock(DbaseFileWriter.class);
  DbaseFileHeader mockDbaseFileHeader = mock(DbaseFileHeader.class);

  ShapefileWriter mockShpWriter = mock(ShapefileWriter.class);
  Struct type;
  ShapefileWriter2 subject;

  @Test
  public void canWriteSomeSimpleFeatureOut() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    doWriteSomeSimpleFeatureOutTest();
  }

  @Test
  public void canWritePointFeatureOut() throws Exception {
    // Using the point geometry subtype should result in same shp file as using geometry type
    type = Struct.of("foo", Types.TEXT, "bar", Types.POINT);
    doWriteSomeSimpleFeatureOutTest();
  }

  private void doWriteSomeSimpleFeatureOutTest() throws Exception {

    // we can't use an argument captor - the array is reused, so capturing that loses previous invocations
    List<String> captured = new ArrayList<>(2);
    doAnswer(inv-> {
      Object[] record = inv.getArgument(0);
      captured.add(record[0].toString());
      return null;
    }).when(mockDbaseWriter).write(any());

    write("some text", point1);
    write("more text", point2);
    verify(mockDbaseFileHeader).addColumn("foo", 'C', 254, 0);
    // geom doesn't belong in the dbase
    verify(mockDbaseFileHeader, never()).addColumn(eq("bar"), anyChar(), anyInt(), anyInt());


    verify(mockDbaseWriter, times(2)).write(any());
    assertEquals(Arrays.asList("some text", "more text"), captured);

    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter, times(2)).writeGeometry(geomCaptor.capture());
    assertEquals(Arrays.asList(point1, point2), geomCaptor.getAllValues());

    subject.close();

    Envelope envelopeInternal = point1.getEnvelopeInternal();
    envelopeInternal.expandToInclude(point2.getEnvelopeInternal());
    verify(mockShpWriter).writeHeaders(envelopeInternal, ShapeType.POINT, 2, 56);

    assertThat(problems, empty());
  }

  @Test
  public void canWriteNullableNestedTuples() throws Exception {
    Struct nestedType1 = Struct.of("foo", Types.TEXT, "bar", Types.TEXT);
    type = Struct.of("geom", Types.GEOMETRY, "nested", Nullable.of(nestedType1), "value", Types.TEXT);

    List<List> captured = new ArrayList<>(2);
    doAnswer(inv-> {
      Object[] record = inv.getArgument(0);
      captured.add(Lists.newArrayList(record));
      return null;
    }).when(mockDbaseWriter).write(any());

    write(point1, Tuple.ofValues(nestedType1, "foo", "bar"), "t1");
    write(point2, null, "t2");
    write(point1, Tuple.ofValues(nestedType1, "foo", "bar"), "t3");

    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter, times(3)).writeGeometry(geomCaptor.capture());
    assertEquals(Arrays.asList(point1, point2, point1), geomCaptor.getAllValues());

    assertThat(captured, contains(
        Lists.newArrayList("foo", "bar", "t1"),
        Lists.newArrayList(NULL_VALUE_TEXT, NULL_VALUE_TEXT, "t2"),
        Lists.newArrayList("foo", "bar", "t3")
    ));
    assertThat(problems, empty());
  }

  @Test
  public void canWriteNullableNestedTuples2() throws Exception {
    // Has two layers of nullable nested tuples
    Struct nestedType1 = Struct.of("foo", Types.TEXT, "bar", Types.TEXT);
    Struct nestedType2 = Struct.of("geom", Types.GEOMETRY, "n1", Nullable.of(nestedType1), "bar", Types.TEXT);
    type = Struct.of("n2", Nullable.of(nestedType2), "foo", Types.TEXT);

    List<List> captured = new ArrayList<>(2);
    doAnswer(inv-> {
      Object[] record = inv.getArgument(0);
      captured.add(Lists.newArrayList(record));
      return null;
    }).when(mockDbaseWriter).write(any());

    write(Tuple.ofValues(nestedType2, point1, Tuple.ofValues(nestedType1, "foo", "bar"), "baz1"), "bazza1");
    write(Tuple.ofValues(nestedType2, point2, null, "baz2"), "bazza2");
    write(null, "bazza3");

    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter, times(3)).writeGeometry(geomCaptor.capture());
    assertEquals(Arrays.asList(point1, point2, null), geomCaptor.getAllValues());

    assertThat(captured, contains(
        Lists.newArrayList("foo", "bar", "baz1", "bazza1"),
        Lists.newArrayList(NULL_VALUE_TEXT, NULL_VALUE_TEXT, "baz2", "bazza2"),
        Lists.newArrayList(NULL_VALUE_TEXT, NULL_VALUE_TEXT, NULL_VALUE_TEXT, "bazza3")
    ));
    assertThat(problems, empty());
  }

  @Test
  public void anSridOnGeometryWillCauseAPrjFileToBeWritten() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);

    // we can't use an argument captor - the array is reused, so capturing that loses previous invocations
    List<String> captured = new ArrayList<>(2);
    doAnswer(inv-> {
      Object[] record = inv.getArgument(0);
      captured.add(record[0].toString());
      return null;
    }).when(mockDbaseWriter).write(any());

    CoordinateReferenceSystem crs = CRS.decode("EPSG:2193", true);
    point1.setSRID(sridSet.get(crs));
    write("some text", point1);

    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter).writeGeometry(geomCaptor.capture());
    // we write point1 and expect that to be written
    assertEquals(point1, geomCaptor.getValue());

    subject.close();

    File prjFile = new File(tmpFile.getParent(), tmpFile.getName().replace(".shp", ".prj"));
    assertTrue(prjFile.exists());
    String wkt = Files.asCharSource(prjFile, Charset.defaultCharset()).read();
    assertEquals(wkt.trim(), crs.toWKT().trim());
    assertThat(problems, empty());
  }

  @Test
  public void anSridThatIsYXWillBeProjectedToXY() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);

    // we can't use an argument captor - the array is reused, so capturing that loses previous invocations
    List<String> captured = new ArrayList<>(2);
    doAnswer(inv-> {
      Object[] record = inv.getArgument(0);
      captured.add(record[0].toString());
      return null;
    }).when(mockDbaseWriter).write(any());

    CoordinateReferenceSystem crs = CRS.decode("EPSG:2193", false);
    Point toWrite = gf.createPoint(new Coordinate(5501000, 1801000));
    Point expectedWritten = gf.createPoint(new Coordinate(1801000, 5501000));

    toWrite.setSRID(sridSet.get(crs));
    write("some text", toWrite);

    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter).writeGeometry(geomCaptor.capture());
    // we write point1, but expect something that looks like expectedWritten to be written
    assertThat(geomCaptor.getValue(), GeoHelper.geometryMatch(expectedWritten, GeoHelper.METER_TOLERANCE_NEAREST_MM));

    subject.close();

    CoordinateReferenceSystem crsInXY = CRS.decode("EPSG:2193", true);
    File prjFile = new File(tmpFile.getParent(), tmpFile.getName().replace(".shp", ".prj"));
    assertTrue(prjFile.exists());
    String wkt = Files.asCharSource(prjFile, Charset.defaultCharset()).read();
    assertEquals(wkt.trim(), crsInXY.toWKT().trim());
    assertThat(problems, empty());
  }

  @Test
  public void anSridThatIsYXWillWarnIfXYPrjCannotByCreated() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);

    // we can't use an argument captor - the array is reused, so capturing that loses previous invocations
    List<String> captured = new ArrayList<>(2);
    doAnswer(inv-> {
      Object[] record = inv.getArgument(0);
      captured.add(record[0].toString());
      return null;
    }).when(mockDbaseWriter).write(any());

    // use a custom CRS, that has an YX axis and isn't known by any EPSG code. RiskScape won't try to
    // reconstruct the CRS in XY in this case (too many hoops).
    CoordinateReferenceSystem crs = customCrs();
    // sanity check it's a YX axis order
    assertThat(CRS.getAxisOrder(crs), is(CRS.AxisOrder.NORTH_EAST));
    Point toWrite = gf.createPoint(new Coordinate(5501000, 1801000));

    toWrite.setSRID(sridSet.get(crs));
    write("some text", toWrite);

    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter).writeGeometry(geomCaptor.capture());
    // we expect that the YX geometry has been flipped and written in XY axis order
    assertThat(geomCaptor.getValue(), is(gf.createPoint(new Coordinate(1801000, 5501000))));

    subject.close();

    // there should be a warning that the .prj file might be wrong (compared to the written geometries)
    assertThat(problems, contains(Problems.foundWith(tmpFile.toURI(),
        AxisSwapper.PROBLEMS.axisOrderIncorrectInPrj()
    )));

    // the prj file was written from the original CRS despite the axis flipping. but we gave a warning
    // about this, and presumably shapefile software will ignore the axis order (in .prj) when reading the
    // shapefile anyway.
    File prjFile = new File(tmpFile.getParent(), tmpFile.getName().replace(".shp", ".prj"));
    assertTrue(prjFile.exists());
    String wkt = Files.asCharSource(prjFile, Charset.defaultCharset()).read();
    assertEquals(wkt.trim(), crs.toWKT().trim());
  }

  @Test
  public void anSridThatIsLatLongWillBeProjectedToLongLat() throws Exception {
    //Input geometries are 4326, Lat/Long
    CoordinateReferenceSystem crs = CRS.decode("EPSG:4326", false);
    CoordinateReferenceSystem crsLongitudeFirst = CRS.decode("EPSG:4326", true);

    point1 = sridSet.getGeometryFactory(crs).createPoint(new Coordinate(-40, 172));
    point2 = sridSet.getGeometryFactory(crsLongitudeFirst).createPoint(new Coordinate(172, -40));

    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    write("some text", point1);

    verify(mockDbaseWriter).write(new Object[] {"some text"});

    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter).writeGeometry(geomCaptor.capture());
    // we write point1, but expect something that looks like point2 to be written
    assertEquals(point2, geomCaptor.getValue());

    subject.close();

    // make sure the envelope is reprojected as well
    verify(mockShpWriter).writeHeaders(point2.getEnvelopeInternal(), ShapeType.POINT, 1, 28);


    File prjFile = new File(tmpFile.getParent(), tmpFile.getName().replace(".shp", ".prj"));
    assertTrue(prjFile.exists());
    String wkt = Files.asCharSource(prjFile, Charset.defaultCharset()).read();
    assertNotEquals(wkt.trim(), crs.toWKT().trim());
    assertEquals(wkt.trim(), crsLongitudeFirst.toWKT().trim());
    assertThat(problems, empty());
  }

  @Test
  public void noWhiteSpaceInPrj() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    prjSingleLine = true;

    createSubject();
    subject.close(); // Closing the subject initiates writing the prj file

    String wkt = readPrj();
    assertThat(wkt, not(containsString("\n")));
  }

  @Test
  public void canWriteAFeatureWithMultipleGeometryAttributesGeometryGeometry() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY, "baz", Types.GEOMETRY);
    doWriteAFeatureWithMultipleGeometryAttributesTest();
  }

  @Test
  public void canWriteAFeatureWithMultipleGeometryAttributesGeometryPoint() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY, "baz", Types.POINT);
    doWriteAFeatureWithMultipleGeometryAttributesTest();
  }

  @Test
  public void canWriteAFeatureWithMultipleGeometryAttributesPointGeometry() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.POINT, "baz", Types.GEOMETRY);
    doWriteAFeatureWithMultipleGeometryAttributesTest();
  }

  @Test
  public void canWriteAFeatureWithMultipleGeometryAttributesPointPoint() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.POINT, "baz", Types.POINT);
    doWriteAFeatureWithMultipleGeometryAttributesTest();
  }

  private void doWriteAFeatureWithMultipleGeometryAttributesTest() throws Exception {

    write("some text", point1, point2);

    verify(mockDbaseFileHeader).addColumn("foo", 'C', 254, 0);
    // 2nd geometry is WKTd out
    verify(mockDbaseFileHeader).addColumn("baz", 'C', 254, 0);

    verify(mockDbaseWriter).write(eq(new Object[] {"some text", "POINT (6 7)"}));
    verify(mockShpWriter).writeGeometry(point1);

    assertThat(problems, empty());
  }

  @Test
  public void multiPointIsSupported() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);

    GeometryCollection gc = new MultiPoint(new Point[] {point1, point2}, gf);
    write("some text", gc);

    verify(mockShpWriter).writeGeometry(gc);
    subject.close();

    verify(mockShpWriter).writeHeaders(gc.getEnvelopeInternal(), ShapeType.MULTIPOINT, 1, 80);
  }


  @Test
  public void lineStringsAreWrittenAsACollection() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    write("some text", ls1);

    verify(mockShpWriter).writeGeometry(gf.createMultiLineString(new LineString[] {ls1}));

    subject.close();

    verify(mockShpWriter).writeHeaders(ls1.getEnvelopeInternal(), ShapeType.ARC, 1, 88);
  }

  @Test
  public void multiLineStringsWorkToo() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    MultiLineString mls = gf.createMultiLineString(new LineString[] {ls1, ls2});
    write("some text", mls);

    verify(mockShpWriter).writeGeometry(gf.createMultiLineString(new LineString[] {ls1, ls2}));

    subject.close();

    verify(mockShpWriter).writeHeaders(mls.getEnvelopeInternal(), ShapeType.ARC, 1, 124);
  }

  @Test
  public void polygonsAreWrittenAsACollection() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    write("some text", polygon1);

    verify(mockShpWriter).writeGeometry(gf.createMultiPolygon(new Polygon[] {
        polygon1
    }));

    subject.close();

    verify(mockShpWriter).writeHeaders(polygon1.getEnvelopeInternal(), ShapeType.POLYGON, 1, 120);
  }

  @Test
  public void multiPolygonsWorkToo() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    MultiPolygon mp = gf.createMultiPolygon(new Polygon[] {polygon1, polygon2});
    write("some text", mp);

    verify(mockShpWriter).writeGeometry(gf.createMultiPolygon(new Polygon[] {
        polygon1, polygon2
    }));

    subject.close();

    verify(mockShpWriter).writeHeaders(mp.getEnvelopeInternal(), ShapeType.POLYGON, 1, 188);
  }

  @Test
  public void canWriteNestedStructsByFlatteningThem() throws Exception {
    Struct lhs = Struct.of("foo", Types.GEOMETRY, "bar", Types.INTEGER);
    Struct middle = Struct.of("bar", Types.INTEGER);
    Struct rhs = Struct.of("interesting", Types.TEXT, "story", Types.DATE);

    type = Struct.of(
      "lhs", lhs,
      "midde", middle,
      "rhs", rhs
    );

    write(Tuple.ofValues(lhs, point1, 1L), Tuple.ofValues(middle, 2L), Tuple.ofValues(rhs, "facts", new Date(0)));

    verify(mockDbaseFileHeader).addColumn("bar", 'N', 19, 0);
    verify(mockDbaseFileHeader).addColumn("bar_1", 'N', 19, 0);
    verify(mockDbaseFileHeader).addColumn("interestin", 'C', 254, 0);
    verify(mockDbaseFileHeader).addColumn("story", 'D', 255, 0);


    verify(mockDbaseWriter).write(eq(new Object[] {1L, 2L, "facts", new Date(0)}));
    verify(mockShpWriter).writeGeometry(point1);
  }


  @Test
  public void canWriteAFruitSaladStruct() throws Exception {
    Struct lhs = Struct.of("foo", Types.GEOMETRY, "bar", Types.INTEGER);
    Struct rhs = Struct.of("interesting", Types.TEXT, "story", Types.DATE);

    type = Struct.of(
      "lhs", lhs,
      "piggy", Types.TEXT,
      "rhs", rhs
    );

    write(Tuple.ofValues(lhs, point1, 1L), "in the middle", Tuple.ofValues(rhs, "facts", new Date(0)));

    verify(mockDbaseWriter).write(eq(new Object[] {1L, "in the middle", "facts", new Date(0)}));
    verify(mockShpWriter).writeGeometry(point1);
  }


  @Test
  public void testColumnRenaming() throws Exception {
    subject = new ShapefileWriter2(tmpFile, Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY), null, problemSink);
    // nothing required
    assertEquals("Shape_Leng", subject.adjust("Shape_Leng", Arrays.asList("bar")));
    assertEquals("AshDepth", subject.adjust("AshDepth", Arrays.asList("bar")));
    assertEquals("foo", subject.adjust("foo", Arrays.asList("bar")));
    // too long
    assertEquals("foo_bar_ba", subject.adjust("foo_bar_baz_bam", Arrays.asList()));
    // not allowed
    assertEquals("the_geom_1", subject.adjust("the_geom", Arrays.asList()));
    // duplicate
    assertEquals("foo_1", subject.adjust("foo", Arrays.asList("foo")));

    // another duplicate
    assertEquals("foo_2", subject.adjust("foo", Arrays.asList("foo", "foo_1")));

    // truncated length duplicated
    assertEquals("foo_bar__1", subject.adjust("foo_bar_baz", Arrays.asList("foo_bar_ba")));

    // good grief...
    assertEquals("foo_bar_10", subject.adjust("foo_bar_baz", Arrays.asList(
        "foo_bar_ba",
        "foo_bar__1",
        "foo_bar__2",
        "foo_bar__3",
        "foo_bar__4",
        "foo_bar__5",
        "foo_bar__6",
        "foo_bar__7",
        "foo_bar__8",
        "foo_bar__9"
    )));

    // non alphas_get_dedded
    assertEquals("foo_bar", subject.adjust("foo bar", Arrays.asList()));

    // no special treatment for accented characters
    assertEquals("fo__bar", subject.adjust("foá bar", Arrays.asList()));
  }

  @Test
  public void failureIfNoGeometry() {
    // you can't write a shapefile with no geometry
    type = Struct.of("value", Types.TEXT);
    RiskscapeException ex = assertThrows(RiskscapeException.class,
        () -> new ShapefileWriter2(tmpFile, type, sridSet, problemSink));
    assertThat(ex.getProblem(), is(Problem.error(StandardCodes.GEOMETRY_REQUIRED, type)));
  }

  @Test
  public void aMismatchingGeometryCrsWillBeReprojected() throws Exception {
    CoordinateReferenceSystem wgs84 = CRS.decode("EPSG:4326", true);

    point1 = GeometryHelper.longLat(sridSet).point(172, -40);
    point2 = GeometryHelper.nzTransverseMercatorWithOriginShift(sridSet).point(0, 0);
    Geometry reprojected = sridSet.reproject(point2, point1.getSRID());

    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    write("first", point1);
    // we write point1, but expect the reprojected point to be written to file
    // (the shapefile geometry should always match what it says it is in the .prj file)
    write("second", point2);
    ArgumentCaptor<Geometry> geomCaptor = ArgumentCaptor.forClass(Geometry.class);
    verify(mockShpWriter, times(2)).writeGeometry(geomCaptor.capture());
    assertEquals(Arrays.asList(point1, reprojected), geomCaptor.getAllValues());
    subject.close();

    // we should warn the user if this happens
    assertThat(problems, contains(
        GeometryProblems.get().crsMixtureReprojected(subject.getStoredAt(), wgs84)
    ));
  }

  @Test
  public void canWriteEmptyShapefileWithReferencedType() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Referenced.of(Types.POLYGON, SRIDSet.EPSG2193_NZTM));
    createSubject();
    subject.close();
    // no records, but we infer ShapeType from RS type
    verify(mockShpWriter).writeHeaders(new Envelope(), ShapeType.POLYGON, 0, 0);

    // check a .prj file still gets written too
    File prjFile = new File(tmpFile.getParent(), tmpFile.getName().replace(".shp", ".prj"));
    assertTrue(prjFile.exists());
    String wkt = Files.asCharSource(prjFile, Charset.defaultCharset()).read();
    // CRS doesn't really matter here, as long as it's valid WKT (and should really be in long, lat)
    assertEquals(wkt.trim(), SRIDSet.EPSG4326_LONLAT.toWKT().trim());
    assertThat(problems, empty());
  }

  @Test
  public void canWriteEmptyShapefile() throws Exception {
    type = Struct.of("foo", Types.TEXT, "bar", Types.GEOMETRY);
    createSubject();
    subject.close();
    // no records, so we default to arbitrary type (point)
    verify(mockShpWriter).writeHeaders(new Envelope(), ShapeType.POINT, 0, 0);
  }

  private void createSubject() throws IOException {
    if (subject == null) {
      subject = new ShapefileWriter2(tmpFile, type, sridSet, problemSink, prjSingleLine) {

        @Override
        protected DbaseFileWriter createDbaseWriter() throws IOException {
          return mockDbaseWriter;
        }

        @Override
        protected ShapefileWriter createShpWriter() throws IOException {
          return mockShpWriter;
        }

        @Override
        protected DbaseFileHeader createDbaseHeader() {
          return mockDbaseFileHeader;
        }
      };
    }
  }

  private void write(Object... tupleValues) throws IOException {
    createSubject();
    subject.write(Tuple.ofValues(type, tupleValues));
  }

  private String readPrj() throws IOException {
    File prjFile = new File(tmpFile.getParent(), tmpFile.getName().replace(".shp", ".prj"));
    assertTrue(prjFile.exists());
    return Files.asCharSource(prjFile, Charset.defaultCharset()).read();
  }
}
