#! /usr/bin/python3
# vim: set filetype=python:

# preplace: replace one string with another in a file (much like lreplace)

# Copyright (C) 2025-2026 by Brian Lindholm.  This file is part of the
# littleutils utility set.
#
# The preplace utility is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by the Free
# Software Foundation; either version 3, or (at your option) any later version.
#
# The preplace utility is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# the littleutils.  If not, see <https://www.gnu.org/licenses/>.

import getopt, os, signal, sys

### PREP SIGNAL HANDLER ###
interrupted = False
def handler(signum, frame):
    global interrupted
    interrupted = True
for signal_VAL in (signal.SIGHUP, signal.SIGINT, signal.SIGPIPE, signal.SIGQUIT, signal.SIGTERM):
    signal.signal(signal_VAL, handler)

### GET INPUT ARGUMENTS ###
# print online help
def usage(rc: int) -> None:
    print('preplace 1.4.0')
    print('usage: preplace -i INSTRING -o OUTSTRING [-e encoding] [-f filelist]')
    print('         [-h(elp)] [-m max_filesize] [-q(uiet)] [-v(erbose)]')
    print('         [-z(ero_length_input_processed)] [-Z(ero_length_output_permitted)]')
    print('         filename ...')
    sys.exit(rc)
# load list of files
def load_list_from_file() -> None:
    if not os.path.isfile(opt_f):  # abort if file does not exist
        print('preplace error: file list %s does not exist' % opt_f, file=sys.stderr)
        sys.exit(1)
    try:
        FILE = open(opt_f, 'r')
    except:  # abort if file cannot be opened for read
        print('preplace error: file list %s cannot be opened' % opt_f, file=sys.stderr)
        sys.exit(1)
    filelist.extend(FILE.read().splitlines())
    FILE.close()
# load list of files from stdin
def load_list_from_stdin() -> None:
    filelist.extend(sys.stdin.read().splitlines())
    sys.stdin.close()
# set defaults
filelist = []
default_filesize_limit = 1024 * 1024 * 1024  # 1 GiB
opt_e = sys.getdefaultencoding()
opt_f = None  # file containing list of files to process
opt_i = None  # input string to replace
opt_o = None  # output string
opt_m = default_filesize_limit  # maximum permissible filesize
opt_p = False  # read list of files to process from stdin
opt_q = False  # be quiet
opt_v = False  # be verbose
opt_z = False  # permit zero-length input
opt_Z = False  # permit zero-length output
# get command-line options
try:
    opts, filelist = getopt.getopt(sys.argv[1:], 'e:f:hi:m:o:pqvzZ', 'help')
except getopt.error as msg:
    # print help if bad opts used, then quit
    print(msg)
    usage(1)
# parse options
for o, v in opts:
    if o in ('-h', '--help'): usage(0)
    elif o == '-e': opt_e = str(v)
    elif o == '-f': opt_f = str(v)
    elif o == '-i': opt_i = str(v)
    elif o == '-m':
        opt_m = int(v)
        if opt_m < 1: opt_m = default_filesize_limit
    elif o == '-o': opt_o = str(v)
    elif o == '-p': opt_p = True
    elif o == '-q': opt_q = True
    elif o == '-v': opt_v = True
    elif o == '-z': opt_z = True
    elif o == '-Z': opt_Z = True
# ensure that both input and output strings are set, and that they differ
if (opt_i == None) or (opt_o == None): usage(1)
if opt_i == opt_o:
    print('preplace error: input and output strings are identical', file=sys.stderr)
    usage(1)
# load file list from file and/or stdin if requested
if opt_f != None: load_list_from_file()
if opt_p: load_list_from_stdin()
# make sure we have at least one file to process
if len(filelist) == 0:
    if (not opt_f) and (not opt_p): usage(1)
    sys.exit(0)
# remove leading './' and trim list to unique items
filelist = [x.removeprefix('./') for x in filelist]
seen = set()
unique_filelist = [x for x in filelist if x not in seen and (seen.add(x) or True)]

### MAIN PROGRAM ###
# replace strings in a file
def process_file(filename: str) -> None:
    # skip if file does not exist
    if interrupted: return
    if not os.path.isfile(filename):
        if not opt_q: print('preplace error: %s is not a file' % filename, file=sys.stderr)
        return
    # skip on zero-length input or if size is too large
    size = os.path.getsize(filename)
    if (not opt_z) and (size == 0):
        if opt_v: print('preplace error: skipping zero length %s' % filename, file=sys.stderr)
        return
    if size > opt_m:
        print('preplace error: skipping oversized file %s' % filename, file=sys.stderr)
        return
    # open file for read, aborting if it fails
    try:
        FILE = open(filename, 'rb', buffering=0)
    except:
        if not opt_q: print('preplace error: %s cannot be opened' % filename, file=sys.stderr)
        return
    buffer_old = FILE.read()
    FILE.close()
    # perform the string replacements
    buffer_new = buffer_old.replace(opt_i.encode(opt_e), opt_o.encode(opt_e))
    if interrupted: return
    # skip if there is no change
    if buffer_old == buffer_new:
        if opt_v: print('%s: unchanged' % filename)
        return
    # abort if output is unpermitted zero length
    if (not opt_Z) and (len(buffer_new) == 0):
        if opt_v: print('preplace error: skipping zero-length output for %s' % filename, file=sys.stderr)
        return
    # re-open file for write and abort if it fails
    try:
        FILE = open(filename, 'wb', buffering=0)
    except:
        if not opt_q: print('preplace error: %s cannot be opened for writing' % filename, file=sys.stderr)
        return
    if not opt_q: print('%s: text replaced' % filename)
    FILE.write(buffer_new)
    FILE.close()

# process files
for filename in unique_filelist:
    if interrupted: break
    process_file(filename)
