package nl.andrewlalis.gymboardcdn.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboardcdn.api.FileMetadataResponse;
import nl.andrewlalis.gymboardcdn.model.FileMetadata;
import nl.andrewlalis.gymboardcdn.util.ULID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**
 * The service that manages storing and retrieving files from a base filesystem.
 */
@Service
public class FileService {
	private static final Logger log = LoggerFactory.getLogger(FileService.class);

	@Value("${app.files.storage-dir}")
	private String storageDir;

	@Value("${app.files.temp-dir}")
	private String tempDir;

	private final ULID ulid;

	private final ObjectMapper objectMapper = new ObjectMapper();

	public FileService(ULID ulid) {
		this.ulid = ulid;
	}

	public Path getStoragePathForFile(String rawId) {
		ULID.Value id = ULID.parseULID(rawId);
		LocalDateTime time = dateFromULID(id);
		Path dir = Path.of(storageDir)
				.resolve(Integer.toString(time.getYear()))
				.resolve(Integer.toString(time.getMonthValue()))
				.resolve(Integer.toString(time.getDayOfMonth()));
		return dir.resolve(rawId);
	}

	public String createNewFileIdentifier() {
		return ulid.nextULID();
	}

	public Path saveToTempFile(InputStream in, String filename) throws IOException {
		Path tempDir = getTempDir();
		String suffix = null;
		if (filename != null) {
			int idx = filename.lastIndexOf('.');
			if (idx >= 0) {
				suffix = filename.substring(idx);
			}
		}
		Path tempFile = Files.createTempFile(tempDir, null, suffix);
		try (var out = Files.newOutputStream(tempFile)) {
			in.transferTo(out);
		}
		return tempFile;
	}

	private static LocalDateTime dateFromULID(ULID.Value value) {
		return Instant.ofEpochMilli(value.timestamp())
				.atOffset(ZoneOffset.UTC)
				.toLocalDateTime();
	}

	private Path getStorageDir() throws IOException {
		Path dir = Path.of(storageDir);
		if (Files.notExists(dir)) {
			Files.createDirectories(dir);
		}
		return dir;
	}

	private Path getTempDir() throws IOException {
		Path dir = Path.of(tempDir);
		if (Files.notExists(dir)) {
			Files.createDirectories(dir);
		}
		return dir;
	}

	public String saveFile(InputStream in, String filename, String mimeType) throws IOException {
		ULID.Value id = ulid.nextValue();

		FileMetadata meta = new FileMetadata();
		meta.filename = filename;
		meta.mimeType = mimeType;
		byte[] metaBytes = objectMapper.writeValueAsBytes(meta);

		Path filePath = getStoragePathForFile(id.toString());
		Files.createDirectories(filePath.getParent());
		try (var out = Files.newOutputStream(filePath)) {
			// Write metadata first.
			ByteBuffer metaBuffer = ByteBuffer.allocate(2 + metaBytes.length);
			metaBuffer.putShort((short) metaBytes.length);
			metaBuffer.put(metaBytes);
			out.write(metaBuffer.array());

			// Now write real data.
			byte[] buffer = new byte[8192];
			int readCount;
			while ((readCount = in.read(buffer)) > 0) {
				out.write(buffer, 0, readCount);
			}
		}
		return id.toString();
	}

	public FileMetadata readMetadata(InputStream in) throws IOException {
		ByteBuffer b = ByteBuffer.allocate(2);
		int bytesRead = in.read(b.array());
		if (bytesRead != 2) throw new IOException("Missing metadata length.");
		short length = b.getShort();
		b = ByteBuffer.allocate(length);
		bytesRead = in.read(b.array());
		if (bytesRead != length) throw new IOException("Metadata body does not equal length header.");
		return objectMapper.readValue(b.array(), FileMetadata.class);
	}

	/**
	 * Streams the contents of a stored file to a client via the Http response.
	 * @param rawId The file's unique identifier.
	 * @param response The response to stream the content to.
	 */
	public void streamFile(String rawId, HttpServletResponse response) {
		Path filePath = getStoragePathForFile(rawId);
		if (!Files.exists(filePath)) {
			throw new ResponseStatusException(HttpStatus.NOT_FOUND);
		}

		try (var in = Files.newInputStream(filePath)) {
			FileMetadata metadata = readMetadata(in);
			response.setContentType(metadata.mimeType);
			response.setContentLengthLong(Files.size(filePath));
		}

		ULID.Value id = ULID.parseULID(rawId);
		Instant timestamp = Instant.ofEpochMilli(id.timestamp());
		LocalDateTime ldt = LocalDateTime.ofInstant(timestamp, ZoneOffset.UTC);
		response.setContentType(file.getMimeType());
		response.setContentLengthLong(file.getSize());
		try {
			Path filePath = getStoragePathForFile(file);
			try (var in = Files.newInputStream(filePath)) {
				in.transferTo(response.getOutputStream());
			}
		} catch (IOException e) {
			log.error("Failed to write file to response.", e);
			throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
		}
	}

	public FileMetadataResponse getFileMetadata(String id) {
		Path filePath = getStoragePathForFile(id);
		if (!Files.exists(filePath)) {
			throw new ResponseStatusException(HttpStatus.NOT_FOUND);
		}
		try (var in = Files.newInputStream(filePath)) {
			FileMetadata metadata = readMetadata(in);
			return new FileMetadataResponse(
					metadata.filename,
					metadata.mimeType,
					Files.size(filePath),
					Files.getLastModifiedTime(filePath)
							.toInstant().atOffset(ZoneOffset.UTC)
							.toLocalDateTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
					true
			);
		} catch (IOException e) {
			throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "IO error", e);
		}
	}
}