import path from 'node:path';
import fsp from 'node:fs/promises';
import { constants } from 'node:fs';
import type { Request } from 'express';
import type {
  Adapter as AdapterInterface,
  AuthResponse,
  Method,
  User,
} from 'nephele';
import {
  BadGatewayError,
  MethodNotImplementedError,
  MethodNotSupportedError,
  ResourceNotFoundError,
} from 'nephele';

import {
  userReadBit,
  userWriteBit,
  userExecuteBit,
  groupReadBit,
  groupWriteBit,
  groupExecuteBit,
  otherReadBit,
  otherWriteBit,
  otherExecuteBit,
} from './FileSystemBits.js';
import Resource from './Resource.js';

export type AdapterConfig = {
  /**
   * The absolute path of the directory that acts as the root directory for the
   * service.
   */
  root: string;
  /**
   * Whether to follow symlinks.
   */
  followLinks?: boolean;
  /**
   * How to handle client requested properties.
   *
   * The client can request to add any arbitrary property it wants (the WebDAV
   * spec calls these "dead properties"), and this controls how that situation
   * is handled.
   *
   * - "meta-files": Save these properties in ".nephelemeta" files.
   * - "disallow": Refuse to save them and return an error to the client.
   * - "emulate": Don't actually save them, but return a success to the client.
   *
   * "meta-files" is the default, as the WebDAV spec states that WebDAV servers
   * "should" support setting these properties. However, if you don't want meta
   * files cluttering up your file system, you can make a choice:
   *
   * "disallow" will tell the client that any property it tries to set is
   * protected. A well written client will understand this and move on.
   *
   * "emulate" will tell the client that the property was successfully set, even
   * though it wasn't really. If a client is poorly written and can't handle an
   * error on property setting, this will allow Nephele to still work with that
   * client.
   *
   * This setting does not affect "live properties", like last modified date and
   * content length.
   */
  properties?: 'meta-files' | 'disallow' | 'emulate';
  /**
   * How to handle client requested locks.
   *
   * This works the same as "properties", except that "disallow" also causes
   * Nephele to report to the client that locks are not supported at all.
   *
   * Again, a poorly written WebDAV client may require "emulate" to work with
   * Nephele.
   */
  locks?: 'meta-files' | 'disallow' | 'emulate';
  /**
   * The maximum filesize in bytes to calculate etags by a CRC-32C checksum of
   * the file contents.
   *
   * Any files above this file size will use an etag of a CRC-32C checksum of
   * the size, created time, and modified time. This will significantly speed up
   * responses to requests for these files, but at the cost of reduced accuracy
   * of etags. A file that has the exact same content, but a different modified
   * time will not be pulled from cache by the client.
   *
   * - Set this value to `Infinity` if you wish to fully follow the WebDAV spec
   *   to the letter.
   * - Set this value to `-1` if you want to absolutely minimize disk IO.
   *
   * By default, all etags will be based on file size, created date, and
   * modified date, since this only requires retrieving metadata from the file
   * system, which is very fast compared to actually retrieving file contents.
   * This could technically go against the WebDAV spec section 8.8, which reads,
   * 'For any given URL, an "ETag" value MUST NOT be reused for different
   * representations returned by GET.' A file the exact same size and exact same
   * created and modified dates with different contents, though extremely
   * unlikely, would return the same etag.
   */
  contentEtagMaxBytes?: number;
};

/**
 * Nephele file system adapter.
 */
export default class Adapter implements AdapterInterface {
  root: string;
  followLinks: boolean;
  properties: 'meta-files' | 'disallow' | 'emulate';
  locks: 'meta-files' | 'disallow' | 'emulate';
  stat: typeof fsp.stat;
  contentEtagMaxBytes: number;

  constructor({
    root,
    followLinks = true,
    properties = 'meta-files',
    locks = 'meta-files',
    contentEtagMaxBytes = -1,
  }: AdapterConfig) {
    this.root = root.replace(
      new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
      () => path.sep,
    );
    this.followLinks = followLinks;
    this.properties = properties;
    this.locks = locks;
    this.stat = this.followLinks ? fsp.stat : fsp.lstat;
    this.contentEtagMaxBytes = contentEtagMaxBytes;
  }

  urlToRelativePath(url: URL, baseUrl: URL) {
    if (
      !decodeURIComponent(url.pathname)
        .replace(/\/?$/, () => '/')
        .startsWith(decodeURIComponent(baseUrl.pathname))
    ) {
      return null;
    }

    return path.join(
      path.sep,
      ...decodeURIComponent(url.pathname)
        .substring(decodeURIComponent(baseUrl.pathname).length)
        .replace(/\/?$/, '')
        .split('/'),
    );
  }

  urlToAbsolutePath(url: URL, baseUrl: URL) {
    const relativePath = this.urlToRelativePath(url, baseUrl);

    if (relativePath == null) {
      return null;
    }

    return path.join(this.root, relativePath);
  }

  async getUid(user: User): Promise<number> {
    return user.uid == null ? -1 : user.uid;
  }

  async getGid(user: User): Promise<number> {
    return user.gid == null ? -1 : user.gid;
  }

  async getGids(user: User): Promise<number[]> {
    return user.gids == null ? [] : user.gids;
  }

  async getComplianceClasses(
    _url: URL,
    _request: Request,
    _response: AuthResponse,
  ) {
    if (this.locks === 'disallow') {
      // Locks are disabled.
      return [];
    }

    // This adapter supports locks.
    return ['2'];
  }

  async getAllowedMethods(
    _url: URL,
    _request: Request,
    _response: AuthResponse,
  ) {
    // This adapter doesn't support any WebDAV extensions that require
    // additional methods.
    return [];
  }

  async getOptionsResponseCacheControl(
    _url: URL,
    _request: Request,
    _response: AuthResponse,
  ) {
    // This adapter doesn't do anything special for individual URLs, so a max
    // age of one week is fine.
    return 'max-age=604800';
  }

  async isAuthorized(url: URL, method: string, baseUrl: URL, user: User) {
    // What type of file access do we need?
    let access = 'u';

    if (['GET', 'HEAD', 'COPY', 'OPTIONS', 'PROPFIND'].includes(method)) {
      // Read operations.
      access = 'r';
    }

    if (
      ['POST', 'PUT', 'DELETE', 'MOVE', 'MKCOL', 'PROPPATCH'].includes(method)
    ) {
      // Write operations.
      access = 'w';
    }

    if (['SEARCH'].includes(method)) {
      // Execute operations. (Directory listing.)
      access = 'x';
    }

    if (['LOCK', 'UNLOCK'].includes(method)) {
      // Require the user to have write permission to lock and unlock a
      // resource.
      access = 'w';
    }

    if (access === 'u') {
      return false;
    }

    // We need the user and group IDs.
    const uid = await this.getUid(user);
    const gids = await this.getGids(user);

    // First make sure the server process and user has access to all
    // directories in the tree.
    const pathname = this.urlToRelativePath(url, baseUrl);
    const absolutePathname = this.urlToAbsolutePath(url, baseUrl);
    if (pathname == null || absolutePathname == null) {
      return false;
    }
    const parts = [
      this.root,
      ...pathname.split(path.sep).filter((str) => str !== ''),
    ];

    let exists = true;
    try {
      await fsp.access(
        absolutePathname,
        access === 'w' ? constants.W_OK : constants.R_OK,
      );
    } catch (e: any) {
      exists = false;
    }

    if (uid >= 0) {
      for (let i = 1; i <= parts.length; i++) {
        const ipathname = path.join(path.sep, ...parts.slice(0, i));

        // Check if the user can access it.
        try {
          const stats = await this.stat(ipathname);

          if (access === 'x' || i < parts.length) {
            if (
              !(
                stats.mode & otherExecuteBit ||
                (stats.uid === uid && stats.mode & userExecuteBit) ||
                (gids.includes(stats.gid) && stats.mode & groupExecuteBit)
              )
            ) {
              return false;
            }
          }

          if (i === parts.length && access === 'r' && exists) {
            if (
              !(
                stats.mode & otherReadBit ||
                (stats.uid === uid && stats.mode & userReadBit) ||
                (gids.includes(stats.gid) && stats.mode & groupReadBit)
              )
            ) {
              return false;
            }
          }

          if (
            (i === parts.length && access === 'w') ||
            (!exists && i === parts.length - 1)
          ) {
            if (
              !(
                stats.mode & otherWriteBit ||
                (stats.uid === uid && stats.mode & userWriteBit) ||
                (gids.includes(stats.gid) && stats.mode & groupWriteBit)
              )
            ) {
              return false;
            }
          }
        } catch (e: any) {
          if (exists || (i < parts.length && e.code !== 'ENOENT')) {
            return false;
          }
        }
      }
    }

    // If we get to here, it means either the file exists and user has
    // permission, or the file doesn't exist, and the user has access to all
    // directories above it.
    return true;
  }

  async getResource(url: URL, baseUrl: URL) {
    const path = this.urlToRelativePath(url, baseUrl);

    if (path == null) {
      throw new BadGatewayError(
        'The given path is not managed by this server.',
      );
    }

    const resource = new Resource({ adapter: this, baseUrl, path });

    if (!(await resource.exists())) {
      throw new ResourceNotFoundError('Resource not found.');
    }

    return resource;
  }

  async newResource(url: URL, baseUrl: URL) {
    const path = this.urlToRelativePath(url, baseUrl);

    if (path == null) {
      throw new BadGatewayError(
        'The given path is not managed by this server.',
      );
    }

    return new Resource({ adapter: this, baseUrl, path, collection: false });
  }

  async newCollection(url: URL, baseUrl: URL) {
    const path = this.urlToRelativePath(url, baseUrl);

    if (path == null) {
      throw new BadGatewayError(
        'The given path is not managed by this server.',
      );
    }

    return new Resource({ adapter: this, baseUrl, path, collection: true });
  }

  getMethod(method: string): typeof Method {
    // No additional methods to handle.
    if (method === 'POST' || method === 'PATCH') {
      throw new MethodNotSupportedError('Method not supported.');
    }
    throw new MethodNotImplementedError('Method not implemented.');
  }
}
