"""LearnAI P03 — fold-local classical ML, CPU only.
Download array-inspection.py and iris.csv beside this file; install
pipeline-requirements.txt in a Python 3.11+ environment.

python classical-pipeline.py --develop
python classical-pipeline.py --freeze choice.json --candidate logistic --reason "..."
python classical-pipeline.py --evaluate choice.json
python classical-pipeline.py --check  # learner prediction TODO initially fails

All data are public and previously used in LearnAI. Reserved here means only
excluded from this script's candidate comparison; it is NOT previously unseen.
Never load pickle/joblib files: freezes contain inert JSON and replay fitting.
"""
import argparse
import hashlib
import importlib.util
import json
import math
import platform
import unittest
from pathlib import Path
import numpy as np
import sklearn
import scipy
from sklearn.dummy import DummyClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, log_loss
from sklearn.model_selection import GroupKFold, TimeSeriesSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier

PROTOCOL='iris-virginica-fold-local-v1'
CANDIDATES=('majority','logistic','tree-depth-2','tree-depth-4')
EXPOSURE='Public Iris data appeared in previous lessons. Reusing reserved results for selection makes them development evidence. A new filename does not create new independent data.'


def loader():
    path=Path(__file__).with_name('array-inspection.py')
    spec=importlib.util.spec_from_file_location('iris_arrays',path)
    module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
    return module


def learner_probability(features, weights, bias):
    """Implement a stable binary logistic prediction, without calling sklearn.
    Accept same-length 1..16 sequences of finite numbers with |value| <= 1000
    and finite |bias| <= 1000. Reject invalid inputs with ValueError. Do not
    mutate inputs. Score = sum(x_j * w_j) + bias, probability = sigmoid(score).
    For nonnegative score use 1/(1+exp(-score)); for negative score use
    exp(score)/(1+exp(score)). This avoids overflowing exp at extreme scores.
    Inputs are already transformed; never refit a scaler during prediction.
    """
    raise NotImplementedError('Implement learner_probability, then --check.')


def arrays(rows):
    return (np.array([r['x'] for r in rows],dtype=np.float64),
            np.array([int(r['species']==2) for r in rows],dtype=np.int64),
            np.array([','.join(map(str,r['x'])) for r in rows]))


def candidate(name):
    if name not in CANDIDATES:raise ValueError('Unknown candidate.')
    model=(DummyClassifier(strategy='most_frequent') if name=='majority' else
           LogisticRegression(C=1.0,solver='lbfgs',max_iter=500,tol=1e-8) if name=='logistic' else
           DecisionTreeClassifier(max_depth=int(name[-1]),min_samples_leaf=2,random_state=17))
    return Pipeline([('fill',SimpleImputer(strategy='mean',keep_empty_features=True)),
                     ('scale',StandardScaler()),('model',model)])


def fit(name,x,y):
    if x.ndim!=2 or len(x)!=len(y) or not 1<=len(x)<=1000 or not 1<=x.shape[1]<=16:
        raise ValueError('Invalid training shape.')
    if np.isinf(x).any() or np.any(np.all(np.isnan(x),axis=0)) or not set(y.tolist())<={0,1} or len(set(y.tolist()))!=2:
        raise ValueError('Require observed training features and both binary classes.')
    fitted=candidate(name).fit(x,y)
    model=fitted.named_steps['model']
    if name=='logistic' and max(model.n_iter_)>=500:raise ValueError('Solver reached iteration cap; do not accept convergence silently.')
    return fitted


def metrics(y,probability):
    prediction=(probability>=.5).astype(int)
    table=confusion_matrix(y,prediction,labels=[0,1])
    return dict(count=len(y),errors=int(np.sum(prediction!=y)),accuracy=float(np.mean(prediction==y)),
                log_loss=float(log_loss(y,np.column_stack([1-probability,probability]),labels=[0,1])),
                confusion=table.tolist(),confusion_order='rows true [other, virginica]; columns predicted [other, virginica]',
                positive_recall=None if not np.sum(y==1) else float(np.mean(prediction[y==1]==1)))


def grouped_folds(x,y,groups):
    if len(x)!=len(y) or len(y)!=len(groups) or len(set(groups.tolist()))<3:raise ValueError('Need aligned rows and at least three groups.')
    return list(GroupKFold(n_splits=3).split(x,y,groups))


def develop(parts):
    x,y,groups=arrays(parts['training']);dx,dy,_=arrays(parts['development'])
    folds=grouped_folds(x,y,groups)
    comparisons=[]
    for name in CANDIDATES:
        traces=[];out=np.empty(len(y));seen=np.zeros(len(y),dtype=int)
        for number,(train,valid) in enumerate(folds):
            fitted=fit(name,x[train],y[train])
            probabilities=fitted.predict_proba(x[valid])[:,1]
            out[valid]=probabilities;seen[valid]+=1
            traces.append(dict(fold=number+1,training_ids=[parts['training'][i]['id'] for i in train],
                               validation_ids=[parts['training'][i]['id'] for i in valid],
                               fitted_fill=fitted.named_steps['fill'].statistics_.tolist(),
                               fitted_mean=fitted.named_steps['scale'].mean_.tolist(),
                               metrics=metrics(y[valid],probabilities)))
        if not np.all(seen==1):raise ValueError('Each training row must be validated once.')
        fitted=fit(name,x,y)
        comparisons.append(dict(candidate=name,cv=metrics(y,out),folds=traces,
                                development=metrics(dy,fitted.predict_proba(dx)[:,1])))
    selected=min(comparisons,key=lambda row:(row['cv']['errors'],CANDIDATES.index(row['candidate'])))['candidate']
    return dict(protocol=PROTOCOL,exposure=EXPOSURE,task='Predict virginica versus other species from four measurements.',
                comparisons=comparisons,suggested_by_cv_error=selected,
                selection_rule='Lowest pooled 3-fold classification error; ties use listed candidate order. Development scores are diagnostic; selecting with them is additional development.',
                fits=len(CANDIDATES)*(len(folds)+1),final_evaluation_revealed=False)


def snapshot(fitted):
    model=fitted.named_steps['model']
    state=dict(fill=fitted.named_steps['fill'].statistics_.tolist(),mean=fitted.named_steps['scale'].mean_.tolist(),
               scale=fitted.named_steps['scale'].scale_.tolist(),classes=model.classes_.tolist())
    if isinstance(model,LogisticRegression):state.update(weights=model.coef_.tolist(),bias=model.intercept_.tolist())
    elif isinstance(model,DecisionTreeClassifier):
        tree=model.tree_;state.update(left=tree.children_left.tolist(),right=tree.children_right.tolist(),feature=tree.feature.tolist(),threshold=tree.threshold.tolist(),value=tree.value.tolist())
    else:state['priors']=model.class_prior_.tolist()
    return state


def recipe():
    return dict(protocol=PROTOCOL,script_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                loader_sha256=hashlib.sha256(Path(__file__).with_name('array-inspection.py').read_bytes()).hexdigest(),
                numpy=np.__version__,sklearn=sklearn.__version__,scipy=scipy.__version__,python=platform.python_version())


def freeze(parts,name,reason):
    if not isinstance(reason,str) or not reason.strip() or len(reason)>2000:raise ValueError('Supply a 1–2,000 character selection reason.')
    x,y,_=arrays(parts['training']);fitted=fit(name,x,y)
    return dict(recipe=recipe(),candidate=name,reason=reason.strip(),source_sha256=loader().CSV_HASH,
                training_ids=[r['id'] for r in parts['training']],development_ids=[r['id'] for r in parts['development']],
                learned=snapshot(fitted),exposure=EXPOSURE)


def evaluate(parts,frozen):
    if not isinstance(frozen,dict) or frozen.get('recipe')!=recipe() or frozen.get('source_sha256')!=loader().CSV_HASH:
        raise ValueError('Source, script or environment differs from the frozen recipe.')
    name=frozen.get('candidate')
    replay=freeze(parts,name,frozen.get('reason'))
    if frozen!=replay:raise ValueError('Frozen metadata or fitted parameters do not replay exactly. Do not silently refreeze.')
    x,y,_=arrays(parts['training']);fitted=fit(name,x,y)
    ex,ey,_=arrays(parts['evaluation']);prob=fitted.predict_proba(ex)[:,1]
    return dict(protocol=PROTOCOL,candidate=name,exposure=EXPOSURE,metrics=metrics(ey,prob),
                rows=[dict(id=r['id'],target=int(y),probability=float(p),prediction=int(p>=.5)) for r,y,p in zip(parts['evaluation'],ey,prob)],
                review='Learner-selected recipe; human reasoning/code review pending.')


def write_new(path,value):
    with Path(path).open('x',encoding='utf-8') as stream:json.dump(value,stream,indent=2,allow_nan=False)


def forward_folds(times):
    """Synthetic time-contract practice. Equal timestamps remain together."""
    times=np.asarray(times)
    if times.ndim!=1 or not 3<=len(times)<=1000 or not np.isfinite(times).all() or np.any(np.diff(times)<0):raise ValueError('Provide 3–1,000 finite time-sorted observations.')
    unique=np.unique(times)
    if len(unique)<3:raise ValueError('Need three distinct timestamps.')
    return [(np.flatnonzero(np.isin(times,unique[train])),np.flatnonzero(np.isin(times,unique[valid])))
            for train,valid in TimeSeriesSplit(n_splits=2).split(unique)]


class PredictionPractice(unittest.TestCase):
    def test_known_and_extreme_predictions(self):
        self.assertEqual(learner_probability([1,2],[2,-1],0),.5)
        self.assertAlmostEqual(learner_probability([1],[1],0),1/(1+math.exp(-1)))
        self.assertEqual(learner_probability([1000],[1000],0),1)
        self.assertEqual(learner_probability([-1000],[1000],0),0)
    def test_contract(self):
        for x,w,b in [([],[],0),([1],[1,2],0),([float('nan')],[1],0),([1],[1],float('inf')),([1001],[1],0)]:
            with self.subTest(),self.assertRaises(ValueError):learner_probability(x,w,b)
    def test_split_contracts(self):
        x=np.arange(24).reshape(12,2);y=np.tile([0,1],6);groups=np.repeat(np.arange(6),2)
        for train,valid in grouped_folds(x,y,groups):self.assertTrue(set(groups[train]).isdisjoint(groups[valid]))
        for train,valid in forward_folds([1,1,2,2,3,3,4,4,5,5,6,6]):self.assertLess(max(np.array([1,1,2,2,3,3,4,4,5,5,6,6])[train]),min(np.array([1,1,2,2,3,3,4,4,5,5,6,6])[valid]))


def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--csv',default='iris.csv')
    modes=parser.add_mutually_exclusive_group(required=True)
    modes.add_argument('--develop',action='store_true');modes.add_argument('--freeze');modes.add_argument('--evaluate');modes.add_argument('--check',action='store_true')
    parser.add_argument('--candidate',choices=CANDIDATES,default='logistic');parser.add_argument('--reason',default='')
    args=parser.parse_args()
    if args.check:
        result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(PredictionPractice))
        raise SystemExit(0 if result.wasSuccessful() else 1)
    parts=loader().load_iris(args.csv)
    if args.develop:print(json.dumps(develop(parts),indent=2,allow_nan=False))
    elif args.freeze:
        write_new(args.freeze,freeze(parts,args.candidate,args.reason))
        print('Frozen without reserved scores. '+EXPOSURE)
    else:
        path=Path(args.evaluate)
        with path.open('rb') as stream:raw=stream.read(100001)
        if len(raw)>100000:raise ValueError('Frozen JSON exceeds 100 KB.')
        result=evaluate(parts,json.loads(raw))
        receipt=path.with_name(path.name+'.exposure.json')
        try:
            write_new(receipt,dict(protocol=PROTOCOL,freeze_sha256=hashlib.sha256(raw).hexdigest(),status='Reserved result requested; recorded before printing.',exposure=EXPOSURE))
            result['local_exposure']='First recorded request for this filename; not proof of unseen data.'
        except FileExistsError:
            result['local_exposure']='Previously requested for this filename; repeated practice evidence.'
        print(json.dumps(result,indent=2,allow_nan=False))

if __name__=='__main__':main()
