/**
 * Detect file paths inside a plain-text string so a markdown
 * post-processor can wrap them in clickable links. The link target uses
 * a custom `claudettepath:` URI so the markdown `<a>` override knows to
 * route the click into the workspace file opener instead of treating it as
 * a web URL — and so rehype-sanitize doesn't strip it as an unsafe protocol
 * (we extend the allowed-scheme list in `markdown.ts`).
 *
 * Recognized:
 *  - POSIX absolute      `/tmp/foo.csv`, `/Users/.../bar.png`
 *  - POSIX home          `~/Downloads/foo.csv`
 *  - Windows drive       `C:\Users\foo\bar.csv`, `C:/Users/foo/bar.csv`
 *  - Windows UNC         `\\server\share\file.txt`
 *  - Relative files      `README.md`, `src/main.rs`, `package.json`
 *
 * Deliberately ignored:
 *  - URLs (`https://...`) — the leading `://` lookbehind keeps the regex
 *    from matching the `/example.com/...` substring inside one.
 *  - Domain-like prose (`example.com`) — common URL-ish hostnames are not
 *    workspace files.
 */

export interface FilePathMatch {
  /** Inclusive start offset in the source text (UTF-16 code units). */
  start: number;
  /** Exclusive end offset, after trailing punctuation has been trimmed. */
  end: number;
  /** The matched path with surrounding sentence punctuation stripped. */
  path: string;
  /** Optional source text to display when it differs from the file target. */
  text?: string;
}

export interface FilePathTarget {
  path: string;
  startLine?: number;
  startColumn?: number;
  endLine?: number;
  endColumn?: number;
}

/**
 * The path body uses `[^\s<>"|*?]` rather than a stricter set so:
 *  - paths with spaces would still split on whitespace (we don't try to
 *    handle quoted paths, by design — too ambiguous in prose)
 *  - shell metacharacters that can't appear in a filename on either
 *    POSIX or NTFS act as natural terminators
 *  - the regex stays compact enough to read
 *
 * The "must not be in the middle of an existing path or URL" rule
 * (e.g. `https://x.com/y` — the `/y` should not match) is enforced via
 * an explicit JS-side check on the preceding character below, NOT via
 * a regex lookbehind. WebKit on macOS 11 (our `minimumSystemVersion`)
 * ships JavaScriptCore without `RegExp` lookbehind support, so a
 * `(?<!…)` group throws a `SyntaxError` at module-eval time and breaks
 * markdown rendering across the entire chat surface. Lookbehind lands
 * in WebKit 16.4 (Safari 16.4, ~ macOS 13.3); until we bump the OS
 * floor we have to do this in code.
 */
const PATH_REGEX =
  /(?:[A-Za-z]:[\\/][^\s<>"|*?]+|\\\\[^\s<>"|*?\\/]+\\[^\s<>"|*?\\/]+(?:\\[^\s<>"|*?\\/]+)*|~?\/[^\s<>"|*?]+)/g;

const RELATIVE_FILE_REGEX =
  /@?(?:\.{1,2}[\\/])?(?:[A-Za-z0-9_$+.-]+[\\/])*[A-Za-z0-9_$+.-]+(?:\.[A-Za-z0-9][A-Za-z0-9+-]{0,15})+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?/g;

/**
 * Characters that, if they sit immediately before a regex match, indicate
 * the match started in the middle of an existing token (URL scheme tail,
 * another path, hyphenated word). Mirrors the old lookbehind's character
 * class: word chars, colon, dot, both slash kinds, hyphen.
 */
const FORBIDDEN_PREV_CHAR_REGEX = /[\w:.\\@-]/;

function isForbiddenPreviousChar(char: string): boolean {
  return char === "/" || FORBIDDEN_PREV_CHAR_REGEX.test(char);
}

/**
 * Sentence-final characters that tend to follow a path in prose but are
 * almost never part of a real filename. Stripped from the tail of every
 * match. Backticks are included so a path written like `` `…` `` doesn't
 * absorb the closing backtick when it appears inside inline-code-adjacent
 * prose. */
const TRAILING_PUNCT_REGEX = /[.,;:!?)\]'"`]+$/;

/** Minimum-length heuristic for detected paths after trailing punctuation
 *  is stripped. This filters out very short matches like `/a` (which are
 *  almost always punctuation noise rather than real filenames) without
 *  enforcing the presence of an additional inner separator — that would
 *  reject legitimate two-segment paths like `/etc` or `~/foo`. */
const MIN_PATH_LENGTH = 3;

const COMMON_HOSTLIKE_EXTENSIONS = new Set([
  "app",
  "biz",
  "co",
  "com",
  "dev",
  "io",
  "net",
  "org",
]);

const KNOWN_FILE_EXTENSIONS = new Set([
  "astro",
  "bash",
  "c",
  "cc",
  "cfg",
  "clj",
  "cljs",
  "cmake",
  "conf",
  "cpp",
  "cs",
  "css",
  "csv",
  "cts",
  "cu",
  "cxx",
  "dart",
  "diff",
  "dockerignore",
  "env",
  "erb",
  "fish",
  "go",
  "graphql",
  "h",
  "hpp",
  "hs",
  "html",
  "java",
  "jl",
  "js",
  "json",
  "jsx",
  "kt",
  "kts",
  "less",
  "lua",
  "m",
  "md",
  "mdx",
  "mjs",
  "mm",
  "mts",
  "nix",
  "patch",
  "php",
  "pl",
  "plist",
  "proto",
  "py",
  "r",
  "rb",
  "rs",
  "sass",
  "scala",
  "scss",
  "sh",
  "sql",
  "svelte",
  "svg",
  "swift",
  "toml",
  "tsx",
  "ts",
  "txt",
  "vue",
  "xml",
  "yaml",
  "yml",
  "zig",
  "zsh",
]);

const KNOWN_FILENAMES = new Set([
  ".env",
  ".gitignore",
  ".npmrc",
  ".prettierrc",
  "cargo.lock",
  "cargo.toml",
  "dockerfile",
  "justfile",
  "makefile",
  "package-lock.json",
  "package.json",
  "pnpm-lock.yaml",
  "readme",
  "readme.md",
  "tsconfig.json",
  "vite.config.ts",
  "yarn.lock",
]);

export function detectFilePaths(text: string): FilePathMatch[] {
  const matches: FilePathMatch[] = [];
  for (const m of text.matchAll(PATH_REGEX)) {
    if (m.index === undefined) continue;
    // Lookbehind-equivalent guard: skip matches that started in the
    // middle of another token. See PATH_REGEX comment for why this is
    // a JS check rather than a regex lookbehind.
    if (m.index > 0 && isForbiddenPreviousChar(text[m.index - 1])) {
      continue;
    }
    const raw = m[0];
    const stripped = raw.replace(TRAILING_PUNCT_REGEX, "");
    if (stripped.length < MIN_PATH_LENGTH) continue;
    matches.push({
      start: m.index,
      end: m.index + stripped.length,
      path: stripped,
    });
  }
  return matches;
}

export function isLikelyRelativeFileReference(value: string): boolean {
  const rawCandidate = value.trim().replace(TRAILING_PUNCT_REGEX, "");
  const candidate = parseFilePathTarget(stripMentionPrefix(rawCandidate)).path;
  if (!candidate || candidate.length < MIN_PATH_LENGTH) return false;
  if (/^[a-z][a-z0-9+.-]*:/i.test(candidate)) return false;
  if (/^[\\/]|^[A-Za-z]:[\\/]/.test(candidate)) return false;
  if (candidate.includes("://") || candidate.includes("@")) return false;

  const normalized = candidate.replace(/\\/g, "/").replace(/^\.\//, "");
  if (
    normalized === "." ||
    normalized === ".." ||
    normalized.startsWith("../") ||
    normalized.includes("/../")
  ) {
    return false;
  }

  const parts = normalized.split("/");
  if (parts.some((part) => !part || part === "." || part === "..")) {
    return false;
  }

  const basename = parts[parts.length - 1].toLowerCase();
  if (KNOWN_FILENAMES.has(basename)) return true;

  const dot = basename.lastIndexOf(".");
  if (dot <= 0 || dot === basename.length - 1) return false;
  const ext = basename.slice(dot + 1);
  if (COMMON_HOSTLIKE_EXTENSIONS.has(ext) && parts.length === 1) return false;
  return KNOWN_FILE_EXTENSIONS.has(ext);
}

function stripMentionPrefix(value: string): string {
  return value.startsWith("@") ? value.slice(1) : value;
}

export function parseFilePathTarget(path: string): FilePathTarget {
  const match = path.match(/:(\d+)(?::(\d+))?(?:-(\d+)(?::(\d+))?)?$/);
  if (!match) return { path };
  const startLine = Number.parseInt(match[1], 10);
  const startColumn = match[2] ? Number.parseInt(match[2], 10) : undefined;
  const endLine = match[3] ? Number.parseInt(match[3], 10) : startLine;
  const endColumn = match[4] ? Number.parseInt(match[4], 10) : undefined;
  if (!Number.isFinite(startLine) || startLine < 1) return { path };
  return {
    path: path.slice(0, match.index),
    startLine,
    startColumn,
    endLine,
    endColumn,
  };
}

export function stripFileLineSuffix(path: string): string {
  return parseFilePathTarget(path).path;
}

/**
 * Match an absolute path that sits under a Claudette-managed git worktree
 * directory (`~/.claudette/workspaces/<project>/<workspace>/<rest>`) and
 * capture the `<rest>` portion — the file's path relative to its worktree
 * root. Used to recognize cross-worktree references: a plan or chat message
 * authored in one workspace that mentions paths under a sibling workspace
 * of the same repository.
 */
const CLAUDETTE_WORKTREE_RELATIVE_REGEX =
  /\/\.claudette\/workspaces\/[^/]+\/[^/]+\/(.+)$/;

/**
 * If `path` points inside a Claudette-managed worktree directory, return
 * the workspace-relative file path; otherwise return `null`. Because every
 * worktree of a given repo shares the same file structure, the returned
 * relative path is also the equivalent file in any other worktree of that
 * repo — including the one the user is currently viewing.
 *
 * Captures containing `..` traversal segments are rejected so a path like
 * `~/.claudette/workspaces/proj/ws/../other` doesn't slip past callers'
 * parent-traversal guards as a bare `../other` workspace-relative path.
 */
export function extractClaudetteWorktreeRelativePath(
  path: string,
): string | null {
  const normalized = path.replace(/\\/g, "/");
  const captured = normalized.match(CLAUDETTE_WORKTREE_RELATIVE_REGEX)?.[1];
  if (!captured) return null;
  if (
    captured === ".." ||
    captured.startsWith("../") ||
    captured.endsWith("/..") ||
    captured.includes("/../")
  ) {
    return null;
  }
  return captured;
}

export function formatFilePathDisplayLabel(path: string): string {
  const target = parseFilePathTarget(path);
  const normalizedPath = target.path.replace(/\\/g, "/");
  const displayPath =
    extractClaudetteWorktreeRelativePath(normalizedPath) ??
    normalizedPath.split("/").filter(Boolean).pop() ??
    normalizedPath;
  const rangeSuffix = formatFilePathRangeSuffix(target);
  return `${displayPath}${rangeSuffix}`;
}

function formatFilePathRangeSuffix(target: FilePathTarget): string {
  if (typeof target.startLine !== "number") return "";
  let suffix = `:${target.startLine}`;
  if (typeof target.startColumn === "number") {
    suffix += `:${target.startColumn}`;
  }
  if (
    typeof target.endLine === "number" &&
    (target.endLine !== target.startLine ||
      typeof target.endColumn === "number")
  ) {
    suffix += `-${target.endLine}`;
    if (typeof target.endColumn === "number") {
      suffix += `:${target.endColumn}`;
    }
  }
  return suffix;
}

export function isLikelyFilePathTarget(value: string): boolean {
  const candidate = stripFileLineSuffix(value.trim().replace(TRAILING_PUNCT_REGEX, ""));
  if (!candidate || candidate.length < MIN_PATH_LENGTH) return false;
  if (isLikelyRelativeFileReference(candidate)) return true;

  const normalized = candidate.replace(/\\/g, "/");
  const absoluteish =
    normalized.startsWith("/") ||
    normalized.startsWith("~/") ||
    /^[A-Za-z]:\//.test(normalized);
  if (!absoluteish) return false;
  const basename = normalized.split("/").pop()?.toLowerCase() ?? "";
  if (KNOWN_FILENAMES.has(basename)) return true;
  const dot = basename.lastIndexOf(".");
  if (dot <= 0 || dot === basename.length - 1) return false;
  return KNOWN_FILE_EXTENSIONS.has(basename.slice(dot + 1));
}

export function isExplicitFilePathTarget(value: string): boolean {
  const candidate = value.trim().replace(TRAILING_PUNCT_REGEX, "");
  if (!candidate || candidate.length < MIN_PATH_LENGTH) return false;

  const parsed = parseFilePathTarget(candidate);
  const normalized = parsed.path.replace(/\\/g, "/");
  if (!isKnownLocalPathRoot(normalized)) return false;

  const basename = normalized.split("/").filter(Boolean).pop()?.toLowerCase();
  if (!basename || basename === "." || basename === "..") return false;
  if (KNOWN_FILENAMES.has(basename)) return true;
  if (typeof parsed.startLine === "number") return true;
  return basename.includes(".");
}

export function decodeLocalhostFileUrl(href: string): string | null {
  const target = decodeLocalhostFileUrlTarget(href);
  return target ? stripFileLineSuffix(target) : null;
}

export function decodeLocalhostFileUrlTarget(href: string): string | null {
  let parsed: URL;
  try {
    parsed = new URL(href);
  } catch {
    return null;
  }
  if (!/^https?:$/.test(parsed.protocol)) return null;
  const hostname = parsed.hostname.toLowerCase();
  if (
    hostname !== "localhost" &&
    hostname !== "127.0.0.1" &&
    hostname !== "[::1]" &&
    hostname !== "::1"
  ) {
    return null;
  }

  let pathname: string;
  try {
    pathname = decodeURI(parsed.pathname);
  } catch {
    pathname = parsed.pathname;
  }
  const candidate =
    /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
  return isExplicitFilePathTarget(candidate) ? candidate : null;
}

function isKnownLocalPathRoot(normalizedPath: string): boolean {
  if (
    normalizedPath.startsWith("~/") ||
    /^[A-Za-z]:\//.test(normalizedPath) ||
    /^\/\/[^/]+\/[^/]+/.test(normalizedPath)
  ) {
    return true;
  }
  return /^\/(?:Users|home|tmp|var|private|Volumes|workspaces|workspace)\//.test(
    normalizedPath,
  );
}

export function detectFileReferences(text: string): FilePathMatch[] {
  const absoluteMatches = detectFilePaths(text);
  const matches: FilePathMatch[] = [...absoluteMatches];

  for (const m of text.matchAll(RELATIVE_FILE_REGEX)) {
    if (m.index === undefined) continue;
    if (m.index > 0 && isForbiddenPreviousChar(text[m.index - 1])) {
      continue;
    }
    if (
      absoluteMatches.some(
        (absolute) => m.index! >= absolute.start && m.index! < absolute.end,
      )
    ) {
      continue;
    }

    const raw = m[0];
    const stripped = raw.replace(TRAILING_PUNCT_REGEX, "");
    if (!isLikelyRelativeFileReference(stripped)) continue;
    const path = stripMentionPrefix(stripped);
    matches.push({
      start: m.index,
      end: m.index + stripped.length,
      path,
      ...(path === stripped ? {} : { text: stripped }),
    });
  }

  return matches.sort((a, b) => a.start - b.start);
}

/** URI scheme used by the markdown autolinker to mark its synthesized
 *  `<a>` nodes. The MARKDOWN_COMPONENTS.a override looks for this prefix
 *  to route clicks into the Tauri `open_in_editor` command.
 *
 *  No hyphen on purpose — hast-util-sanitize's URL parser scans the
 *  scheme portion strictly and a `claudette-path:foo` URL was being
 *  emitted with an empty href in practice. A single-token scheme name
 *  matches the ABNF for `URI-scheme` and survives sanitization
 *  unconditionally once added to `protocols.href`. */
export const FILE_PATH_SCHEME = "claudettepath:";

export function encodeFilePathHref(path: string): string {
  // encodeURI keeps slashes/backslashes legible in the DOM (helpful for
  // debugging); only space and the few characters that genuinely break
  // a URI need escaping.
  return `${FILE_PATH_SCHEME}${encodeURI(path)}`;
}

export function decodeFilePathHref(href: string): string | null {
  if (!href.startsWith(FILE_PATH_SCHEME)) return null;
  const tail = href.slice(FILE_PATH_SCHEME.length);
  // `decodeURI` throws `URIError` on malformed percent-encoding (e.g. a
  // dangling `%` or invalid `%x` pair). A bad `claudettepath:` link in
  // assistant markdown shouldn't be able to crash the click handler or
  // the markdown render — fall back to the raw, undecoded tail so the
  // caller still has *something* to pass to `open_in_editor`. The
  // backend will surface its own error if the path doesn't exist.
  try {
    return decodeURI(tail);
  } catch {
    return tail;
  }
}
