/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import * as fs from 'node:fs';
import * as path from 'node:path';
import { debugLogger } from './debugLogger.js';
import { resolveToRealPath } from './paths.js';

export type Unsubscribe = () => void;

export interface AddDirectoriesResult {
  added: string[];
  failed: Array<{ path: string; error: Error }>;
}

/**
 * WorkspaceContext manages multiple workspace directories and validates paths
 * against them. This allows the CLI to operate on files from multiple directories
 * in a single session.
 */
export class WorkspaceContext {
  private directories = new Set<string>();
  private initialDirectories: Set<string>;
  private readOnlyPaths = new Set<string>();
  private onDirectoriesChangedListeners = new Set<() => void>();

  /**
   * Creates a new WorkspaceContext with the given initial directory and optional additional directories.
   * @param targetDir The initial working directory (usually cwd)
   * @param additionalDirectories Optional array of additional directories to include
   */
  constructor(
    readonly targetDir: string,
    additionalDirectories: string[] = [],
  ) {
    this.addDirectory(targetDir);
    this.addDirectories(additionalDirectories);
    this.initialDirectories = new Set(this.directories);
  }

  /**
   * Registers a listener that is called when the workspace directories change.
   * @param listener The listener to call.
   * @returns A function to unsubscribe the listener.
   */
  onDirectoriesChanged(listener: () => void): Unsubscribe {
    this.onDirectoriesChangedListeners.add(listener);
    return () => {
      this.onDirectoriesChangedListeners.delete(listener);
    };
  }

  private notifyDirectoriesChanged() {
    // Iterate over a copy of the set in case a listener unsubscribes itself or others.
    for (const listener of [...this.onDirectoriesChangedListeners]) {
      try {
        listener();
      } catch (e) {
        // Don't let one listener break others.
        debugLogger.warn(
          `Error in WorkspaceContext listener: (${e instanceof Error ? e.message : String(e)})`,
        );
      }
    }
  }

  /**
   * Adds a directory to the workspace.
   * @param directory The directory path to add (can be relative or absolute)
   * @param basePath Optional base path for resolving relative paths (defaults to cwd)
   * @throws Error if the directory cannot be added
   */
  addDirectory(directory: string): void {
    const result = this.addDirectories([directory]);
    if (result.failed.length > 0) {
      throw result.failed[0].error;
    }
  }

  /**
   * Adds multiple directories to the workspace.
   * Emits a single change event if any directories are added.
   * @param directories The directory paths to add
   * @returns Object containing successfully added directories and failures
   */
  addDirectories(directories: string[]): AddDirectoriesResult {
    const result: AddDirectoriesResult = { added: [], failed: [] };
    let changed = false;

    for (const directory of directories) {
      try {
        const resolved = this.resolveAndValidateDir(directory);
        if (!this.directories.has(resolved)) {
          this.directories.add(resolved);
          changed = true;
        }
        result.added.push(directory);
      } catch (err) {
        const error = err instanceof Error ? err : new Error(String(err));
        debugLogger.warn(
          `[WARN] Skipping unreadable directory: ${directory} (${error.message})`,
        );
        result.failed.push({ path: directory, error });
      }
    }

    if (changed) {
      this.notifyDirectoriesChanged();
    }

    return result;
  }

  /**
   * Adds a path to the read-only list.
   * These paths are allowed for reading but not for writing (unless they are also in the workspace).
   */
  addReadOnlyPath(pathToAdd: string): void {
    try {
      // Check if it exists
      if (!fs.existsSync(pathToAdd)) {
        return;
      }
      // Resolve symlinks
      const resolved = fs.realpathSync(path.resolve(this.targetDir, pathToAdd));
      this.readOnlyPaths.add(resolved);
    } catch (e) {
      debugLogger.warn(`Failed to add read-only path ${pathToAdd}:`, e);
    }
  }

  private resolveAndValidateDir(directory: string): string {
    const absolutePath = path.resolve(this.targetDir, directory);

    if (!fs.existsSync(absolutePath)) {
      throw new Error(`Directory does not exist: ${absolutePath}`);
    }
    const stats = fs.statSync(absolutePath);
    if (!stats.isDirectory()) {
      throw new Error(`Path is not a directory: ${absolutePath}`);
    }

    return fs.realpathSync(absolutePath);
  }

  /**
   * Gets a copy of all workspace directories.
   * @returns Array of absolute directory paths
   */
  getDirectories(): readonly string[] {
    return Array.from(this.directories);
  }

  getInitialDirectories(): readonly string[] {
    return Array.from(this.initialDirectories);
  }

  setDirectories(directories: readonly string[]): void {
    const newDirectories = new Set<string>();
    for (const dir of directories) {
      newDirectories.add(this.resolveAndValidateDir(dir));
    }

    if (
      newDirectories.size !== this.directories.size ||
      ![...newDirectories].every((d) => this.directories.has(d))
    ) {
      this.directories = newDirectories;
      this.notifyDirectoriesChanged();
    }
  }

  /**
   * Checks if a given path is within any of the workspace directories.
   * @param pathToCheck The path to validate
   * @returns True if the path is within the workspace, false otherwise
   */
  isPathWithinWorkspace(pathToCheck: string): boolean {
    try {
      const fullyResolvedPath = this.fullyResolvedPath(pathToCheck);

      for (const dir of this.directories) {
        if (this.isPathWithinRoot(fullyResolvedPath, dir)) {
          return true;
        }
      }
      return false;
    } catch {
      return false;
    }
  }

  /**
   * Checks if a path is allowed to be read.
   * This includes workspace paths and explicitly added read-only paths.
   * @param pathToCheck The path to validate
   * @returns True if the path is readable, false otherwise
   */
  isPathReadable(pathToCheck: string): boolean {
    if (this.isPathWithinWorkspace(pathToCheck)) {
      return true;
    }
    try {
      const fullyResolvedPath = this.fullyResolvedPath(pathToCheck);

      for (const allowedPath of this.readOnlyPaths) {
        // Allow exact matches or subpaths (if allowedPath is a directory)
        if (
          fullyResolvedPath === allowedPath ||
          this.isPathWithinRoot(fullyResolvedPath, allowedPath)
        ) {
          return true;
        }
      }
      return false;
    } catch {
      return false;
    }
  }

  /**
   * Fully resolves a path, including symbolic links.
   * If the path does not exist, it returns the fully resolved path as it would be
   * if it did exist.
   */
  private fullyResolvedPath(pathToCheck: string): string {
    return resolveToRealPath(path.resolve(this.targetDir, pathToCheck));
  }

  /**
   * Checks if a path is within a given root directory.
   * @param pathToCheck The absolute path to check
   * @param rootDirectory The absolute root directory
   * @returns True if the path is within the root directory, false otherwise
   */
  private isPathWithinRoot(
    pathToCheck: string,
    rootDirectory: string,
  ): boolean {
    const relative = path.relative(rootDirectory, pathToCheck);
    return (
      !relative.startsWith(`..${path.sep}`) &&
      relative !== '..' &&
      !path.isAbsolute(relative)
    );
  }
}
