import { Readable } from "stream";
import { randomUUID, createHmac, timingSafeEqual } from "crypto";
import { promises as fs, createReadStream, createWriteStream } from "fs";
import path from "path";
import {
  S3Client,
  HeadObjectCommand,
  GetObjectCommand,
  PutObjectCommand,
  CopyObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
  ObjectAclPolicy,
  ObjectPermission,
  canAccessObject,
  type StoredObject,
} from "./objectAcl";

export class ObjectNotFoundError extends Error {
  constructor() {
    super("Object not found");
    this.name = "ObjectNotFoundError";
    Object.setPrototypeOf(this, ObjectNotFoundError.prototype);
  }
}

// Map a stored object's content type to a sensible file extension so
// downloads land with a usable name (stored paths are extension-less UUIDs).
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
  "image/jpeg": "jpg",
  "image/jpg": "jpg",
  "image/png": "png",
  "image/gif": "gif",
  "image/webp": "webp",
  "image/heic": "heic",
  "image/heif": "heif",
  "image/bmp": "bmp",
  "image/tiff": "tiff",
  "image/svg+xml": "svg",
  "application/pdf": "pdf",
};

export function extensionForContentType(
  contentType: string | undefined,
): string {
  if (!contentType) return "bin";
  const base = contentType.split(";")[0].trim().toLowerCase();
  return CONTENT_TYPE_EXTENSIONS[base] ?? "bin";
}

// ---------------------------------------------------------------------------
// Driver interface
// ---------------------------------------------------------------------------

interface StorageDriver {
  getObject(bucketName: string, objectName: string): StoredObject;
  signUploadUrl(bucketName: string, objectName: string, ttlSec: number): Promise<string>;
  /** Recognise a previously-issued upload URL and return the object path. */
  parseUploadUrl(rawUrl: string): { bucketName: string; objectName: string } | null;
}

// ---------------------------------------------------------------------------
// Local-FS driver — used when no S3_ENDPOINT is configured. Backs storage on
// the host filesystem under ./storage/<bucket>/<object>. Signed upload URLs
// are HMAC tokens served by the local API at /api/storage/_local/upload.
// ---------------------------------------------------------------------------

const LOCAL_STORAGE_ROOT = path.resolve(
  process.env.LOCAL_STORAGE_ROOT ?? "./storage",
);
const LOCAL_HMAC_SECRET = (() => {
  const s = process.env.LOCAL_STORAGE_SIGNING_SECRET ?? process.env.SESSION_SECRET;
  if (s) return s;
  if (process.env.NODE_ENV === "production") {
    throw new Error(
      "LOCAL_STORAGE_SIGNING_SECRET (or SESSION_SECRET) must be set in production " +
        "when using the local storage driver.",
    );
  }
  return "tx-dev-local-storage-secret-change-before-deploy";
})();

function localFsPath(bucketName: string, objectName: string): string {
  // Reject path traversal — joining and re-checking the absolute path
  // must remain inside LOCAL_STORAGE_ROOT.
  const joined = path.resolve(LOCAL_STORAGE_ROOT, bucketName, objectName);
  if (!joined.startsWith(LOCAL_STORAGE_ROOT + path.sep) && joined !== LOCAL_STORAGE_ROOT) {
    throw new Error("Invalid object path");
  }
  return joined;
}

function localMetadataPath(bucketName: string, objectName: string): string {
  return localFsPath(bucketName, objectName) + ".meta.json";
}

interface LocalMetadataFile {
  contentType?: string;
  aclPolicy?: ObjectAclPolicy | null;
}

class LocalStoredObject implements StoredObject {
  constructor(public bucketName: string, public objectName: string) {}

  async exists(): Promise<boolean> {
    try {
      await fs.access(localFsPath(this.bucketName, this.objectName));
      return true;
    } catch {
      return false;
    }
  }

  async getMetadata(): Promise<{
    contentType?: string;
    size?: number;
    aclPolicy?: ObjectAclPolicy | null;
  }> {
    const fp = localFsPath(this.bucketName, this.objectName);
    const stat = await fs.stat(fp);
    let meta: LocalMetadataFile = {};
    try {
      const raw = await fs.readFile(localMetadataPath(this.bucketName, this.objectName), "utf8");
      meta = JSON.parse(raw) as LocalMetadataFile;
    } catch {
      // No sidecar metadata file — that's fine.
    }
    return {
      contentType: meta.contentType,
      size: stat.size,
      aclPolicy: meta.aclPolicy ?? null,
    };
  }

  createReadStream(): NodeJS.ReadableStream {
    return createReadStream(localFsPath(this.bucketName, this.objectName));
  }

  async download(): Promise<[Buffer]> {
    const buf = await fs.readFile(localFsPath(this.bucketName, this.objectName));
    return [buf];
  }

  async setAclPolicy(policy: ObjectAclPolicy): Promise<void> {
    if (!(await this.exists())) {
      throw new Error(`Object not found: ${this.objectName}`);
    }
    let meta: LocalMetadataFile = {};
    try {
      meta = JSON.parse(
        await fs.readFile(localMetadataPath(this.bucketName, this.objectName), "utf8"),
      ) as LocalMetadataFile;
    } catch {
      // ignore
    }
    meta.aclPolicy = policy;
    await fs.writeFile(
      localMetadataPath(this.bucketName, this.objectName),
      JSON.stringify(meta),
      "utf8",
    );
  }

  async getAclPolicy(): Promise<ObjectAclPolicy | null> {
    const m = await this.getMetadata();
    return m.aclPolicy ?? null;
  }
}

class LocalDriver implements StorageDriver {
  getObject(bucketName: string, objectName: string): StoredObject {
    return new LocalStoredObject(bucketName, objectName);
  }

  async signUploadUrl(
    bucketName: string,
    objectName: string,
    ttlSec: number,
  ): Promise<string> {
    const exp = Math.floor(Date.now() / 1000) + ttlSec;
    const payload = Buffer.from(
      JSON.stringify({ b: bucketName, o: objectName, exp }),
    ).toString("base64url");
    const sig = createHmac("sha256", LOCAL_HMAC_SECRET).update(payload).digest("base64url");
    const token = `${payload}.${sig}`;
    // Return a SAME-ORIGIN path (not an absolute URL). The browser will
    // resolve it against `window.location.origin`, so the upload always
    // targets the public host the SPA is being served from — works for
    // localhost, Tailscale (tx.local), LAN IPs, and remote phones alike
    // without depending on PUBLIC_BASE_URL being set correctly.
    return `/api/storage/_local/upload?token=${token}`;
  }

  parseUploadUrl(rawUrl: string): { bucketName: string; objectName: string } | null {
    try {
      // Accept BOTH legacy absolute URLs (older signed URLs still in the
      // wild) and the new same-origin paths. URL() needs a base for the
      // relative form — the base is irrelevant since we only read
      // pathname/searchParams.
      const u = new URL(rawUrl, "http://placeholder.local");
      if (!u.pathname.endsWith("/api/storage/_local/upload")) return null;
      const token = u.searchParams.get("token");
      if (!token) return null;
      const verified = verifyLocalUploadToken(token);
      if (!verified) return null;
      return { bucketName: verified.b, objectName: verified.o };
    } catch {
      return null;
    }
  }
}

/**
 * Verify a local-driver upload token. Returns the decoded payload on
 * success (signature match + not expired), null otherwise.
 */
export function verifyLocalUploadToken(
  token: string,
): { b: string; o: string; exp: number } | null {
  const parts = token.split(".");
  if (parts.length !== 2) return null;
  const [payload, sig] = parts;
  const expectedSig = createHmac("sha256", LOCAL_HMAC_SECRET).update(payload).digest("base64url");
  const a = Buffer.from(sig);
  const b = Buffer.from(expectedSig);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  let decoded: { b: string; o: string; exp: number };
  try {
    decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
      b: string;
      o: string;
      exp: number;
    };
  } catch {
    return null;
  }
  if (typeof decoded.exp !== "number" || decoded.exp < Math.floor(Date.now() / 1000)) {
    return null;
  }
  return decoded;
}

/**
 * Persist an upload to the local-FS storage backend. Used by the
 * /api/storage/_local/upload route.
 */
export async function writeLocalObject(
  bucketName: string,
  objectName: string,
  body: Readable,
  contentType?: string,
): Promise<void> {
  const fp = localFsPath(bucketName, objectName);
  await fs.mkdir(path.dirname(fp), { recursive: true });
  await new Promise<void>((resolve, reject) => {
    const ws = createWriteStream(fp);
    body.pipe(ws);
    ws.on("finish", () => resolve());
    ws.on("error", reject);
    body.on("error", reject);
  });
  if (contentType) {
    let meta: LocalMetadataFile = {};
    try {
      meta = JSON.parse(await fs.readFile(localMetadataPath(bucketName, objectName), "utf8")) as LocalMetadataFile;
    } catch {
      // ignore
    }
    meta.contentType = contentType;
    await fs.writeFile(localMetadataPath(bucketName, objectName), JSON.stringify(meta), "utf8");
  }
}

// ---------------------------------------------------------------------------
// S3 driver — used when S3_ENDPOINT is configured. Targets MinIO or any
// S3-compatible bucket via @aws-sdk/client-s3 + s3-request-presigner.
// ---------------------------------------------------------------------------

const ACL_METADATA_HEADER = "x-amz-meta-acl-policy";

let s3ClientSingleton: S3Client | null = null;
function getS3Client(): S3Client {
  if (s3ClientSingleton) return s3ClientSingleton;
  const endpoint = process.env.S3_ENDPOINT;
  if (!endpoint) throw new Error("S3_ENDPOINT not set");
  const region = process.env.S3_REGION ?? "us-east-1";
  const accessKeyId = process.env.S3_ACCESS_KEY_ID;
  const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
  if (!accessKeyId || !secretAccessKey) {
    throw new Error("S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY must be set when S3_ENDPOINT is configured");
  }
  s3ClientSingleton = new S3Client({
    endpoint,
    region,
    credentials: { accessKeyId, secretAccessKey },
    forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false",
  });
  return s3ClientSingleton;
}

class S3StoredObject implements StoredObject {
  constructor(public bucketName: string, public objectName: string) {}

  async exists(): Promise<boolean> {
    try {
      await getS3Client().send(
        new HeadObjectCommand({ Bucket: this.bucketName, Key: this.objectName }),
      );
      return true;
    } catch (err) {
      const name = (err as Error & { name?: string }).name;
      if (name === "NotFound" || name === "NoSuchKey") return false;
      throw err;
    }
  }

  async getMetadata(): Promise<{
    contentType?: string;
    size?: number;
    aclPolicy?: ObjectAclPolicy | null;
  }> {
    const head = await getS3Client().send(
      new HeadObjectCommand({ Bucket: this.bucketName, Key: this.objectName }),
    );
    const aclRaw = head.Metadata?.["acl-policy"];
    let aclPolicy: ObjectAclPolicy | null = null;
    if (aclRaw) {
      try {
        aclPolicy = JSON.parse(aclRaw) as ObjectAclPolicy;
      } catch {
        aclPolicy = null;
      }
    }
    return {
      contentType: head.ContentType,
      size: head.ContentLength,
      aclPolicy,
    };
  }

  createReadStream(): NodeJS.ReadableStream {
    // Lazy stream: synchronously return a PassThrough that we'll pipe
    // into once the GetObject promise resolves.
    const { PassThrough } = require("stream") as typeof import("stream");
    const out = new PassThrough();
    getS3Client()
      .send(new GetObjectCommand({ Bucket: this.bucketName, Key: this.objectName }))
      .then((res) => {
        const body = res.Body as Readable | undefined;
        if (!body) {
          out.end();
          return;
        }
        body.on("error", (e) => out.destroy(e));
        body.pipe(out);
      })
      .catch((e) => out.destroy(e));
    return out;
  }

  async download(): Promise<[Buffer]> {
    const res = await getS3Client().send(
      new GetObjectCommand({ Bucket: this.bucketName, Key: this.objectName }),
    );
    const body = res.Body as Readable | undefined;
    if (!body) return [Buffer.alloc(0)];
    const chunks: Buffer[] = [];
    for await (const chunk of body) {
      chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk));
    }
    return [Buffer.concat(chunks)];
  }

  async setAclPolicy(policy: ObjectAclPolicy): Promise<void> {
    // S3 supports setting metadata only via copy-self.
    const json = JSON.stringify(policy);
    await getS3Client().send(
      new CopyObjectCommand({
        Bucket: this.bucketName,
        Key: this.objectName,
        CopySource: `/${this.bucketName}/${encodeURIComponent(this.objectName)}`,
        Metadata: { "acl-policy": json },
        MetadataDirective: "REPLACE",
      }),
    );
  }

  async getAclPolicy(): Promise<ObjectAclPolicy | null> {
    return (await this.getMetadata()).aclPolicy ?? null;
  }
}

class S3Driver implements StorageDriver {
  getObject(bucketName: string, objectName: string): StoredObject {
    return new S3StoredObject(bucketName, objectName);
  }

  async signUploadUrl(
    bucketName: string,
    objectName: string,
    ttlSec: number,
  ): Promise<string> {
    const cmd = new PutObjectCommand({ Bucket: bucketName, Key: objectName });
    return getSignedUrl(getS3Client(), cmd, { expiresIn: ttlSec });
  }

  parseUploadUrl(rawUrl: string): { bucketName: string; objectName: string } | null {
    try {
      const u = new URL(rawUrl);
      // Path-style: /<bucket>/<key...>
      const segments = u.pathname.replace(/^\/+/, "").split("/");
      if (segments.length < 2) return null;
      const bucketName = segments[0];
      const objectName = segments.slice(1).join("/");
      return { bucketName, objectName: decodeURIComponent(objectName) };
    } catch {
      return null;
    }
  }
}

// ---------------------------------------------------------------------------
// Driver selection
// ---------------------------------------------------------------------------

let driverSingleton: StorageDriver | null = null;
function driver(): StorageDriver {
  if (driverSingleton) return driverSingleton;
  const explicit = process.env.STORAGE_DRIVER;
  const useS3 = explicit ? explicit === "s3" : Boolean(process.env.S3_ENDPOINT);
  driverSingleton = useS3 ? new S3Driver() : new LocalDriver();
  return driverSingleton;
}

export const ACTIVE_STORAGE_DRIVER = (): "s3" | "local" =>
  driver() instanceof S3Driver ? "s3" : "local";

// ---------------------------------------------------------------------------
// ObjectStorageService — public API consumed by routes/storage.ts and
// routes/executive-meetings.ts. Surface is driver-agnostic and works
// identically against either the local-FS or S3-compatible backend.
// ---------------------------------------------------------------------------

export class ObjectStorageService {
  constructor() {}

  getPublicObjectSearchPaths(): Array<string> {
    const pathsStr = process.env.PUBLIC_OBJECT_SEARCH_PATHS || "";
    const paths = Array.from(
      new Set(
        pathsStr
          .split(",")
          .map((p) => p.trim())
          .filter((p) => p.length > 0),
      ),
    );
    if (paths.length === 0) {
      throw new Error(
        "PUBLIC_OBJECT_SEARCH_PATHS not set. Set this env var to a comma-separated " +
          "list of /<bucket>/<prefix> paths (see .env.example).",
      );
    }
    return paths;
  }

  getPrivateObjectDir(): string {
    const dir = process.env.PRIVATE_OBJECT_DIR || "";
    if (!dir) {
      throw new Error(
        "PRIVATE_OBJECT_DIR not set. Set this env var to /<bucket>/<prefix> " +
          "(see .env.example).",
      );
    }
    return dir;
  }

  async searchPublicObject(filePath: string): Promise<StoredObject | null> {
    for (const searchPath of this.getPublicObjectSearchPaths()) {
      const fullPath = `${searchPath}/${filePath}`;
      const { bucketName, objectName } = parseObjectPath(fullPath);
      const obj = driver().getObject(bucketName, objectName);
      if (await obj.exists()) {
        return obj;
      }
    }
    return null;
  }

  async downloadObject(file: StoredObject, cacheTtlSec: number = 3600): Promise<Response> {
    const metadata = await file.getMetadata();
    const isPublic = metadata.aclPolicy?.visibility === "public";

    const nodeStream = file.createReadStream();
    const webStream = Readable.toWeb(nodeStream as Readable) as ReadableStream;

    const headers: Record<string, string> = {
      "Content-Type": metadata.contentType ?? "application/octet-stream",
      "Cache-Control": `${isPublic ? "public" : "private"}, max-age=${cacheTtlSec}`,
    };
    if (typeof metadata.size === "number") {
      headers["Content-Length"] = String(metadata.size);
    }

    return new Response(webStream, { headers });
  }

  async getObjectEntityUploadURL(): Promise<string> {
    const privateObjectDir = this.getPrivateObjectDir();
    const objectId = randomUUID();
    const fullPath = `${privateObjectDir}/uploads/${objectId}`;
    const { bucketName, objectName } = parseObjectPath(fullPath);
    return driver().signUploadUrl(bucketName, objectName, 900);
  }

  async getObjectEntityFile(objectPath: string): Promise<StoredObject> {
    if (!objectPath.startsWith("/objects/")) {
      throw new ObjectNotFoundError();
    }
    const parts = objectPath.slice(1).split("/");
    if (parts.length < 2) {
      throw new ObjectNotFoundError();
    }
    const entityId = parts.slice(1).join("/");
    let entityDir = this.getPrivateObjectDir();
    if (!entityDir.endsWith("/")) entityDir = `${entityDir}/`;
    const objectEntityPath = `${entityDir}${entityId}`;
    const { bucketName, objectName } = parseObjectPath(objectEntityPath);
    const obj = driver().getObject(bucketName, objectName);
    if (!(await obj.exists())) {
      throw new ObjectNotFoundError();
    }
    return obj;
  }

  normalizeObjectEntityPath(rawPath: string): string {
    // Try the active driver's URL recogniser first — strip query string,
    // map a signed-URL back to its /objects/<id> form.
    const noQuery = rawPath.split("?")[0]!;
    const parsed = driver().parseUploadUrl(rawPath);
    let candidatePath = noQuery;
    if (parsed) {
      candidatePath = `/${parsed.bucketName}/${parsed.objectName}`;
    } else {
      // Legacy GCS URL form (pre-migration data) — strip the host.
      try {
        const u = new URL(rawPath);
        if (u.pathname.startsWith("/")) candidatePath = u.pathname;
      } catch {
        // already a relative path
      }
    }

    let objectEntityDir = this.getPrivateObjectDir();
    if (!objectEntityDir.endsWith("/")) objectEntityDir = `${objectEntityDir}/`;

    if (!candidatePath.startsWith(objectEntityDir)) {
      return candidatePath;
    }
    const entityId = candidatePath.slice(objectEntityDir.length);
    return `/objects/${entityId}`;
  }

  async trySetObjectEntityAclPolicy(
    rawPath: string,
    aclPolicy: ObjectAclPolicy,
  ): Promise<string> {
    const normalizedPath = this.normalizeObjectEntityPath(rawPath);
    if (!normalizedPath.startsWith("/")) return normalizedPath;
    const objectFile = await this.getObjectEntityFile(normalizedPath);
    await objectFile.setAclPolicy(aclPolicy);
    return normalizedPath;
  }

  async canAccessObjectEntity({
    userId,
    objectFile,
    requestedPermission,
  }: {
    userId?: string;
    objectFile: StoredObject;
    requestedPermission?: ObjectPermission;
  }): Promise<boolean> {
    return canAccessObject({
      userId,
      objectFile,
      requestedPermission: requestedPermission ?? ObjectPermission.READ,
    });
  }
}

function parseObjectPath(p: string): { bucketName: string; objectName: string } {
  if (!p.startsWith("/")) p = `/${p}`;
  const pathParts = p.split("/");
  if (pathParts.length < 3) {
    throw new Error("Invalid path: must contain at least a bucket name");
  }
  const bucketName = pathParts[1];
  const objectName = pathParts.slice(2).join("/");
  return { bucketName, objectName };
}
